From 78cefa83ff49454659bceb75117efb2fbcd31136 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Wed, 4 Mar 2026 08:50:14 -0600 Subject: [PATCH 01/22] feat: add user management functionality with CRUD operations in database management section --- package-lock.json | 750 ++++++++++++++++++++++++++++++++++ src/routes/+page.server.ts | 150 ++++++- src/routes/+page.svelte | 804 +++++++++++++++++++++++++++++-------- 3 files changed, 1542 insertions(+), 162 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6c4a60..725a49e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -313,6 +313,380 @@ "node": ">=16" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/win32-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", @@ -592,6 +966,367 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", @@ -2245,6 +2980,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index c4139cd..8b5af20 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -2,6 +2,7 @@ import { db } from '$lib/server/db'; import { env } from '$env/dynamic/private'; import fs from 'node:fs/promises'; import path from 'node:path'; +import crypto from 'node:crypto'; import { redirect } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { verifyToken } from '$lib/server/auth'; @@ -50,6 +51,7 @@ export const load: PageServerLoad = async ({ cookies }) => { let clientsData: any[] = []; let alertsData: any[] = []; let basesDeDatosList: any[] = []; + let usuariosList: any[] = []; let restoreHistory: Record = {}; let effectivenessByDb: Record = {}; @@ -245,6 +247,11 @@ export const load: PageServerLoad = async ({ cookies }) => { const resBases = await azure.request().query(basesQueries); basesDeDatosList = resBases.recordset; + // Usuarios de la BD MSSQL (tabla CONTROLDESK.dbo.Usuarios) + const usuariosQuery = `SELECT ID, IDNodoSubNodo, ClienteAutoridad, Nombre, Usuario, BD_Shelter FROM [CONTROLDESK].[dbo].[Usuarios] ORDER BY ID`; + const resUsuarios = await azure.request().query(usuariosQuery); + usuariosList = resUsuarios.recordset; + } catch (e: any) { console.error("Error loading Azure/ControlDesk data:", e); errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`; @@ -254,7 +261,7 @@ export const load: PageServerLoad = async ({ cookies }) => { try { let files: string[] = []; try { - files = await fs.readdir(env.BACKUP_PATH); + files = await fs.readdir(env.BACKUP_PATH as string); } catch (e) { files = []; errors.backups = "No se pudo acceder a la carpeta de respaldos."; @@ -263,7 +270,7 @@ export const load: PageServerLoad = async ({ cookies }) => { for (const file of files) { if (file === '.' || file === '..') continue; - const filePath = path.join(env.BACKUP_PATH, file); + const filePath = path.join(env.BACKUP_PATH as string, file); let stats; try { stats = await fs.stat(filePath); @@ -441,9 +448,10 @@ export const load: PageServerLoad = async ({ cookies }) => { backupFiles, clientsData, alertsData, - basesDeDatosList, - restoreHistory, - effectivenessByDb, + basesDeDatosList, + usuariosList, + restoreHistory, + effectivenessByDb, errors, // Return the collected errors currentUser // Añadir usuario actual para la UI @@ -630,5 +638,137 @@ export const actions: Actions = { console.error('Error deleting database:', e); return { success: false, message: e.message }; } + }, + + // ---- Acciones para CONTROLDESK.dbo.Usuarios ---- + + createUsuario: async ({ request }) => { + try { + const formData = await request.formData(); + const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad')); + const nombre = String(formData.get('Nombre') || '').trim(); + const usuario = String(formData.get('Usuario') || '').trim(); + const password = String(formData.get('Password') || ''); + const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; + + if (!idNodoSubNodo || !clienteAutoridad || !nombre || !usuario || !password) { + return { success: false, message: 'Todos los campos obligatorios deben completarse' }; + } + + // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE + const passwordHash = crypto + .createHash('sha512') + .update(password + 'soluciones', 'utf8') + .digest('hex') + .toUpperCase(); + + const azure = await db.getAzure(); + const req = azure.request(); + req.input('idNodo', idNodoSubNodo); + req.input('clienteAut', clienteAutoridad); + req.input('nombre', nombre); + req.input('usuario', usuario); + req.input('passwordHash', passwordHash); + req.input('bdShelter', bdShelter); + + await req.query(` + INSERT INTO [CONTROLDESK].[dbo].[Usuarios] + (IDNodoSubNodo, ClienteAutoridad, Nombre, Usuario, [Password], BD_Shelter) + VALUES + (@idNodo, @clienteAut, @nombre, @usuario, @passwordHash, @bdShelter) + `); + + return { success: true }; + } catch (e: any) { + console.error('Error creating usuario:', e); + return { success: false, message: e.message }; + } + }, + + updateUsuario: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad')); + const nombre = String(formData.get('Nombre') || '').trim(); + const usuario = String(formData.get('Usuario') || '').trim(); + const password = String(formData.get('Password') || ''); + const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; + + if (!id || !idNodoSubNodo || !clienteAutoridad || !nombre || !usuario) { + return { success: false, message: 'Todos los campos obligatorios deben completarse' }; + } + + const azure = await db.getAzure(); + const req = azure.request(); + req.input('id', id); + req.input('idNodo', idNodoSubNodo); + req.input('clienteAut', clienteAutoridad); + req.input('nombre', nombre); + req.input('usuario', usuario); + req.input('bdShelter', bdShelter); + + if (password) { + // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE + const passwordHash = crypto + .createHash('sha512') + .update(password + 'soluciones', 'utf8') + .digest('hex') + .toUpperCase(); + req.input('passwordHash', passwordHash); + await req.query(` + UPDATE [CONTROLDESK].[dbo].[Usuarios] + SET IDNodoSubNodo = @idNodo, + ClienteAutoridad = @clienteAut, + Nombre = @nombre, + Usuario = @usuario, + [Password] = @passwordHash, + BD_Shelter = @bdShelter + WHERE ID = @id + `); + } else { + await req.query(` + UPDATE [CONTROLDESK].[dbo].[Usuarios] + SET IDNodoSubNodo = @idNodo, + ClienteAutoridad = @clienteAut, + Nombre = @nombre, + Usuario = @usuario, + BD_Shelter = @bdShelter + WHERE ID = @id + `); + } + + return { success: true }; + } catch (e: any) { + console.error('Error updating usuario:', e); + return { success: false, message: e.message }; + } + }, + + deleteUsuario: async ({ request }) => { + try { + const formData = await request.formData(); + const id = Number(formData.get('ID')); + + if (!id) { + return { success: false, message: 'ID requerido' }; + } + + const azure = await db.getAzure(); + const req = azure.request(); + req.input('id', id); + + await req.query(` + DELETE FROM [CONTROLDESK].[dbo].[Usuarios] + WHERE ID = @id + `); + + return { success: true }; + } catch (e: any) { + console.error('Error deleting usuario:', e); + return { success: false, message: e.message }; + } } }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9b04e54..b53e572 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,33 +1,33 @@
@@ -738,7 +942,7 @@ person
-

{data.currentUser?.nombre_completo || data.currentUser?.nombre_usuario || 'Usuario'}

+

{data.currentUser?.nombre_completo || data.currentUser?.username || 'Usuario'}

{data.currentUser?.email || ''}

@@ -754,7 +958,7 @@ {:else}
-
+
person
@@ -1548,137 +1752,264 @@

Gestión de Bases de Datos

-

Vista administrativa de las bases de datos registradas en el sistema.

-
- -
+

Vista administrativa de las bases de datos y usuarios registrados en el sistema.

-
-
-
- Mostrando {PAGE_SIZE} por página - - Total: {filterDatabases().length} -
-
- -
-
-
- - - - - - - - - - - - {#each getPaged(filterDatabases(), pageDatabases) as bd} - - - - - - - - {:else} - - - - {/each} - -
IDNodoNombreServidorAcciones
{bd.ID}{bd.NodoSubNodo} - {#if editingDbId === bd.ID} - - {:else} - - {/if} - {bd.ServerName} - {#if editingDbId === bd.ID} -
- - -
- {:else} -
- - -
- {/if} -
- No hay registros de gestión disponibles. -
-
- -
- - Página {pageDatabases} - -
+ +
+ +
+ + + {#if databasesSubTab === 'bases'} +
+
+ +
+
+
+
+ Mostrando {PAGE_SIZE} por página + + Total: {filterDatabases().length} +
+
+ +
+
+
+ + + + + + + + + + + + {#each getPaged(filterDatabases(), pageDatabases) as bd} + + + + + + + + {:else} + + + + {/each} + +
IDNodoNombreServidorAcciones
{bd.ID}{bd.NodoSubNodo} + {#if editingDbId === bd.ID} + + {:else} + + {/if} + {bd.ServerName} + {#if editingDbId === bd.ID} +
+ + +
+ {:else} +
+ + +
+ {/if} +
+ No hay registros de gestión disponibles. +
+
+ +
+ + Página {pageDatabases} + +
+
+
+ {/if} + + + {#if databasesSubTab === 'usuarios'} +
+
+ +
+
+
+
+ Mostrando {PAGE_SIZE} por página + + Total: {filterUsuarios().length} +
+
+ +
+
+
+ + + + + + + + + + + + + {#each getPaged(filterUsuarios(), pageUsuarios) as u} + + + + + + + + + {:else} + + + + {/each} + +
IDIDNodoNombreUsuarioBD ShelterAcciones
{u.ID}{u.IDNodoSubNodo}{u.Nombre}{u.Usuario}{u.BD_Shelter ?? '—'} +
+ + +
+
+ No hay usuarios registrados. +
+
+ +
+ + Página {pageUsuarios} + +
+
+
+ {/if}
{/if}
@@ -1689,10 +2020,12 @@ {#if showDbCrudModal}
{ if (e.target === e.currentTarget) closeDbCrudModal(); }} + onkeydown={(e) => { if (e.key === 'Escape') closeDbCrudModal(); }} >
@@ -1701,6 +2034,7 @@ +
+ + { e.preventDefault(); submitUsuarioForm(); }}> + {#if usuarioFormError} +
+ {usuarioFormError} +
+ {/if} +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +

+ Se almacena como SHA-512(contraseña + "soluciones") en mayúsculas. +

+
+ + +
+ + +
+
+ +
+ + +
+ +
+
+{/if} -- 2.49.1 From 0da6e5d6f979c21698f053d17a102c4383bcf893 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Wed, 4 Mar 2026 09:32:13 -0600 Subject: [PATCH 02/22] feat: update user form to use numeric ClienteAutoridad and improve node selection --- src/routes/+page.svelte | 190 ++++++++++++++++++++++++++++++++++------ 1 file changed, 161 insertions(+), 29 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b53e572..6e72a31 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -93,7 +93,7 @@ let formUsuario = $state({ ID: null, IDNodoSubNodo: '', - ClienteAutoridad: '', + ClienteAutoridad: 0, Nombre: '', Usuario: '', Password: '', @@ -101,9 +101,9 @@ }); let usuarioFormError = $state(''); - const openCreateUsuarioModal = () => { + const openCreateUsuarioModal = (prefillNodoId?: number | string) => { isEditingUsuario = false; - formUsuario = { ID: null, IDNodoSubNodo: '', ClienteAutoridad: '', Nombre: '', Usuario: '', Password: '', BD_Shelter: '' }; + formUsuario = { ID: null, IDNodoSubNodo: prefillNodoId ?? '', ClienteAutoridad: 0, Nombre: '', Usuario: '', Password: '', BD_Shelter: '' }; usuarioFormError = ''; showUsuarioModal = true; }; @@ -113,7 +113,7 @@ formUsuario = { ID: u.ID, IDNodoSubNodo: u.IDNodoSubNodo ?? '', - ClienteAutoridad: u.ClienteAutoridad ?? '', + ClienteAutoridad: u.ClienteAutoridad ?? 0, Nombre: u.Nombre ?? '', Usuario: u.Usuario ?? '', Password: '', // No pre-llenar, vacío = no cambiar @@ -187,16 +187,25 @@ } }; + // Helper: resolve nodo name from IDNodoSubNodo + const getNodoName = (idNodo: number | string): string => { + const list = (basesDeDatosList ?? []) as any[]; + const match = list.find((bd) => String(bd.ID) === String(idNodo)); + return match ? `${match.Nombre} (${match.ID})` : String(idNodo); + }; + const filterUsuarios = () => { const term = usuariosSearch.trim().toLowerCase(); const rows = usuariosList ?? []; if (!term) return rows; return rows.filter((u) => { + const nodoName = getNodoName(u.IDNodoSubNodo).toLowerCase(); return ( String(u.ID ?? '').toLowerCase().includes(term) || String(u.Nombre ?? '').toLowerCase().includes(term) || String(u.Usuario ?? '').toLowerCase().includes(term) || - String(u.BD_Shelter ?? '').toLowerCase().includes(term) + String(u.BD_Shelter ?? '').toLowerCase().includes(term) || + nodoName.includes(term) ); }); }; @@ -254,6 +263,12 @@ // Modal grande para crear/editar base de datos (CRUD completo) let showDbCrudModal = $state(false); let isEditingDb = $state(false); + let dbModalTab = $state<'datos' | 'usuarios'>('datos'); + + // Filtra usuarios asociados a una base de datos por IDNodoSubNodo + const getUsuariosForDb = (dbId: number | string): any[] => { + return (usuariosList ?? []).filter((u) => String(u.IDNodoSubNodo) === String(dbId)); + }; let formDb = $state({ ID: null, NodoSubNodo: '', @@ -268,6 +283,7 @@ const openCreateDbModal = () => { isEditingDb = false; + dbModalTab = 'datos'; formDb = { ID: null, NodoSubNodo: '', @@ -284,6 +300,7 @@ const openEditDbModal = (bd: any) => { isEditingDb = true; + dbModalTab = 'datos'; formDb = { ID: bd.ID, NodoSubNodo: bd.NodoSubNodo ?? '', @@ -1917,7 +1934,7 @@
-
{e.preventDefault(); submitDbForm();}}> + + {#if isEditingDb} +
+ + +
+ {/if} + + {#if !isEditingDb || dbModalTab === 'datos'} + {e.preventDefault(); submitDbForm();}}>
@@ -2187,6 +2236,85 @@
+ {/if} + + + {#if isEditingDb && dbModalTab === 'usuarios'} +
+
+

+ Usuarios vinculados a esta base de datos (ID {formDb.ID}). +

+ +
+ {#each [getUsuariosForDb(formDb.ID)] as dbUsers} + {#if dbUsers.length === 0} +
+ No hay usuarios vinculados a esta base de datos. +
+ {:else} +
+ + + + + + + + + + + + + {#each dbUsers as u (u.ID)} + + + + + + + + + {/each} + +
IDTipoNombreUsuarioBD ShelterAcciones
{u.ID} + {#if u.ClienteAutoridad} + Autoridad + {:else} + Cliente + {/if} + {u.Nombre ?? '—'}{u.Usuario ?? '—'}{u.BD_Shelter ?? '—'} +
+ + +
+
+
+ {/if} + {/each} +
+ {/if}
{/if} @@ -2223,34 +2351,38 @@ {/if}
- -
+ +
- + class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-300" + > + + {#each (basesDeDatosList ?? []) as bd} + + {/each} +
- +
- + class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-300" + > + + +
@@ -2300,7 +2432,7 @@ placeholder={isEditingUsuario ? 'Dejar vacío para no cambiar' : 'Contraseña'} />

- Se almacena como SHA-512(contraseña + "soluciones") en mayúsculas. + Se almacena encriptada.

-- 2.49.1 From d181e71c0153f7dd9fd9e13da9c47e47ff6a1cd7 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Wed, 4 Mar 2026 09:42:24 -0600 Subject: [PATCH 03/22] feat: add functionality to suggest next NodoSubNodo based on existing entries --- src/routes/+page.svelte | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6e72a31..0522b4c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -269,6 +269,27 @@ const getUsuariosForDb = (dbId: number | string): any[] => { return (usuariosList ?? []).filter((u) => String(u.IDNodoSubNodo) === String(dbId)); }; + + // Calcula el siguiente NodoSubNodo sugerido tomando el sufijo numérico de 3 dígitos máximo + const getNextNodoSubNodo = (): string => { + const list = (basesDeDatosList ?? []) as any[]; + if (!list.length) return ''; + let maxNum = -1; + let bestPrefix = ''; + for (const bd of list) { + const nodo = String(bd.NodoSubNodo ?? ''); + if (nodo.length >= 3) { + const suffix = nodo.slice(-3); + const num = parseInt(suffix, 10); + if (!isNaN(num) && num > maxNum) { + maxNum = num; + bestPrefix = nodo.slice(0, -3); + } + } + } + if (maxNum === -1) return ''; + return bestPrefix + String(maxNum + 1).padStart(3, '0'); + }; let formDb = $state({ ID: null, NodoSubNodo: '', @@ -284,9 +305,10 @@ const openCreateDbModal = () => { isEditingDb = false; dbModalTab = 'datos'; + const suggestedNodo = getNextNodoSubNodo(); formDb = { ID: null, - NodoSubNodo: '', + NodoSubNodo: suggestedNodo, RFC: '', Nombre: '', Sucursal: '', @@ -2106,8 +2128,17 @@ bind:value={formDb.NodoSubNodo} required class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-300" - placeholder="Ej: Nodo01" + placeholder="Ej: Nodo001" /> + {#if !isEditingDb} + {@const suggested = getNextNodoSubNodo()} + {#if suggested} +

+ lightbulb + Siguiente sugerido: {suggested} +

+ {/if} + {/if}
-- 2.49.1 From 97b67afb25db01e410f5d6184c266647eaf2d5e0 Mon Sep 17 00:00:00 2001 From: ernestohc21 Date: Tue, 7 Apr 2026 07:36:18 -0600 Subject: [PATCH 04/22] Ultimos cambios en el proyecto local --- .claude/settings.local.json | 10 + package-lock.json | 906 +++++++++++++++++++++++++-- package.json | 1 + src/lib/components/Navbar.svelte | 8 + src/lib/components/Sidebar.svelte | 8 + src/routes/+page.svelte | 10 + src/routes/reportes/+page.server.ts | 39 ++ src/routes/reportes/+page.svelte | 374 +++++++++++ src/routes/reportes/excel/+server.ts | 259 ++++++++ 9 files changed, 1560 insertions(+), 55 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/routes/reportes/+page.server.ts create mode 100644 src/routes/reportes/+page.svelte create mode 100644 src/routes/reportes/excel/+server.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0264318 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs ls:*)", + "Bash(npm install:*)", + "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", + "Bash(docker-compose up:*)" + ] + } +} diff --git a/package-lock.json b/package-lock.json index 725a49e..0acd543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", + "exceljs": "^4.4.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", @@ -704,6 +705,47 @@ "node": ">=12" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1058,9 +1100,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1075,9 +1114,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1092,9 +1128,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1109,9 +1142,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1126,9 +1156,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1143,9 +1170,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1160,9 +1184,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1177,9 +1198,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1194,9 +1212,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1211,9 +1226,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1228,9 +1240,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1245,9 +1254,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1262,9 +1268,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1670,6 +1673,104 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1730,6 +1831,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -1805,7 +1912,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -1852,6 +1958,28 @@ "node": ">= 18" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1877,6 +2005,12 @@ "readable-stream": "^4.2.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1951,7 +2085,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2045,6 +2178,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2148,6 +2298,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2199,11 +2361,48 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/content-disposition": { @@ -2243,6 +2442,51 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2307,6 +2551,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2487,6 +2737,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2518,6 +2813,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -2783,6 +3087,40 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -2853,6 +3191,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2973,11 +3324,16 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -2995,6 +3351,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3101,7 +3473,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -3163,7 +3534,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -3327,6 +3697,12 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -3343,7 +3719,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -3881,6 +4256,54 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3912,6 +4335,63 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3932,6 +4412,12 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -3939,6 +4425,36 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3951,12 +4467,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -3975,12 +4510,30 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4098,7 +4651,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4111,7 +4663,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4121,7 +4672,6 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -4418,7 +4968,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4501,7 +5050,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4543,6 +5091,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4556,7 +5110,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4921,6 +5474,12 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5032,6 +5591,36 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5130,7 +5719,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -5325,6 +5913,18 @@ "rimraf": "^2.5.2" } }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -5444,6 +6044,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5896,6 +6502,71 @@ "node": ">=10.13.0" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/tar-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -5998,6 +6669,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6030,6 +6710,15 @@ "node": ">=6" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -6177,6 +6866,60 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6212,7 +6955,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -6411,7 +7153,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/wsl-utils": { @@ -6430,6 +7171,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -6461,6 +7208,55 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } } } } diff --git a/package.json b/package.json index 855de04..76bacaa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bcrypt": "^6.0.0", "bootstrap": "^5.3.3", "dotenv": "^16.4.5", + "exceljs": "^4.4.0", "express": "^4.18.2", "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte index 9ce79e0..d3d007a 100644 --- a/src/lib/components/Navbar.svelte +++ b/src/lib/components/Navbar.svelte @@ -27,6 +27,14 @@
+ +
{/if} diff --git a/src/routes/reportes/+page.server.ts b/src/routes/reportes/+page.server.ts new file mode 100644 index 0000000..f922aa8 --- /dev/null +++ b/src/routes/reportes/+page.server.ts @@ -0,0 +1,39 @@ +import { db } from '$lib/server/db'; +import { verifyToken } from '$lib/server/auth'; +import { getUserById } from '$lib/server/users'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + const currentUser = await getUserById(session.userId); + if (!currentUser || !currentUser.activo) throw redirect(303, '/login'); + + // Obtener lista de bases activas para mostrar en UI + let bases: { ID: number; Nombre: string; NodoSubNodo: string; BDName: string }[] = []; + let errorBases: string | null = null; + + try { + const azure = await db.getAzure(); + const res = await azure.request().query(` + SELECT ID, Nombre, NodoSubNodo, BDName + FROM [CONTROLDESK].[dbo].[BasesdeDatos] + WHERE Activo = 1 AND BDName IS NOT NULL AND BDName <> '' + ORDER BY NodoSubNodo + `); + bases = res.recordset; + } catch (e: any) { + errorBases = `No se pudo cargar el catálogo de bases: ${e.message}`; + } + + return { + user: { username: currentUser.username, es_admin: currentUser.es_admin }, + bases, + errorBases + }; +}; diff --git a/src/routes/reportes/+page.svelte b/src/routes/reportes/+page.svelte new file mode 100644 index 0000000..0141096 --- /dev/null +++ b/src/routes/reportes/+page.svelte @@ -0,0 +1,374 @@ + + + + Reportes – TransmitirAS + + + + +
+
+ + +
+

Reportes

+

+ Genera un Excel consolidado con el conteo de pedimentos de importación + (tipo = 'I') + por año, mes y clave de pedimento. +

+
+ + +
+ + +
+
+ assessment +
+
+

Pedimentos de Importación por Nodo

+

SPedimentos WHERE tipo = 'I' — agrupado por año / mes / CLAVEPED

+
+
+ +
+ + +
+

+ Bases de datos a incluir +

+ + +
+ + + + + + {#if dropdownOpen} +
e.stopPropagation()} + > + +
+
+ search + + {#if busqueda} + + {/if} +
+
+ + +
+ +
+ + +
    + {#each basesFiltradas as base} + {@const checked = seleccionadas.has(base.BDName)} +
  • + +
  • + {:else} +
  • + Sin resultados para "{busqueda}" +
  • + {/each} +
+ + +
+ + {seleccionadas.size === 0 ? 'Se incluirán todas' : `${seleccionadas.size} de ${data.bases.length} seleccionadas`} + + +
+
+ {/if} +
+ + + {#if seleccionadas.size > 0 && seleccionadas.size < data.bases.length} +
+ {#each [...seleccionadas] as bd} + {@const base = data.bases.find(b => b.BDName === bd)} + + {base?.NodoSubNodo ?? bd} + + + {/each} + +
+ {/if} +
+ + +
+

Filtros de fecha (opcionales)

+
+
+ + +
+ +
+ + +
+
+
+ + + {#if data.errorBases} +
+ warning + {data.errorBases} +
+ {/if} + {#if msgError} +
+ error_outline + {msgError} +
+ {/if} + + +
+ +
+
+
+ +
+
diff --git a/src/routes/reportes/excel/+server.ts b/src/routes/reportes/excel/+server.ts new file mode 100644 index 0000000..763647b --- /dev/null +++ b/src/routes/reportes/excel/+server.ts @@ -0,0 +1,259 @@ +import { db, sql } from '$lib/server/db'; +import { verifyToken } from '$lib/server/auth'; +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +const QUERY_PEDIMENTOS = ` + SELECT + YEAR(FECHA_PAGO_ISO) AS anio, + MONTH(FECHA_PAGO_ISO) AS mes, + CLAVEPED, + COUNT(*) AS total_pedimentos + FROM SPedimentos + WHERE tipo = 'I' + GROUP BY + YEAR(FECHA_PAGO_ISO), + MONTH(FECHA_PAGO_ISO), + CLAVEPED + ORDER BY + anio, + mes, + CLAVEPED +`; + +const MONTH_NAMES = [ + '', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' +]; + +function styleHeader(row: ExcelJS.Row) { + row.eachCell((cell) => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } }; + cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }; + cell.alignment = { vertical: 'middle', horizontal: 'center' }; + cell.border = { + bottom: { style: 'thin', color: { argb: 'FF94A3B8' } } + }; + }); + row.height = 20; +} + +export const GET: RequestHandler = async ({ cookies, url }) => { + // Auth + const token = cookies.get('session_token'); + if (!token) throw redirect(303, '/login'); + const session = verifyToken(token); + if (!session) throw redirect(303, '/login'); + + // Parámetros de filtro opcionales + const filterAnio = url.searchParams.get('anio') || ''; + const filterMes = url.searchParams.get('mes') || ''; + // Bases seleccionadas (array de BDName); vacío = todas + const filterBases = url.searchParams.getAll('bd'); + + // 1. Obtener listado de bases de datos activas desde CONTROLDESK + let bases: { ID: number; Nombre: string; NodoSubNodo: string; BDName: string; ServerName: string }[] = []; + try { + const azure = await db.getAzure(); + const res = await azure.request().query(` + SELECT ID, Nombre, NodoSubNodo, BDName, ServerName + FROM [CONTROLDESK].[dbo].[BasesdeDatos] + WHERE Activo = 1 AND BDName IS NOT NULL AND BDName <> '' + ORDER BY NodoSubNodo + `); + // Filtrar por bases seleccionadas si se especificaron + if (filterBases.length > 0) { + bases = res.recordset.filter((b: any) => filterBases.includes(b.BDName)); + } else { + bases = res.recordset; + } + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo catálogo de bases: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 2. Para cada base, conectar y ejecutar la query + const primary = await db.getPrimary(); + + type ResultRow = { + nodo: string; + nombre: string; + bdName: string; + anio: number; + mes: number; + mesNombre: string; + claveped: string; + total: number; + }; + + const consolidado: ResultRow[] = []; + const errores: { nodo: string; bdName: string; error: string }[] = []; + + for (const base of bases) { + try { + let query = ` + SELECT + YEAR(FECHA_PAGO_ISO) AS anio, + MONTH(FECHA_PAGO_ISO) AS mes, + CLAVEPED, + COUNT(*) AS total_pedimentos + FROM [${base.BDName}].[dbo].[SPedimentos] + WHERE tipo = 'I' + `; + + if (filterAnio) query += ` AND YEAR(FECHA_PAGO_ISO) = ${parseInt(filterAnio)}`; + if (filterMes) query += ` AND MONTH(FECHA_PAGO_ISO) = ${parseInt(filterMes)}`; + + query += ` + GROUP BY + YEAR(FECHA_PAGO_ISO), + MONTH(FECHA_PAGO_ISO), + CLAVEPED + ORDER BY + anio, mes, CLAVEPED + `; + + const req = primary.request(); + const result = await req.query(query); + + for (const row of result.recordset) { + consolidado.push({ + nodo: base.NodoSubNodo, + nombre: base.Nombre, + bdName: base.BDName, + anio: row.anio, + mes: row.mes, + mesNombre: MONTH_NAMES[row.mes] ?? String(row.mes), + claveped: row.CLAVEPED, + total: row.total_pedimentos + }); + } + } catch (e: any) { + errores.push({ nodo: base.NodoSubNodo, bdName: base.BDName, error: e.message }); + } + } + + // 3. Generar Excel + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + // ── Hoja: Consolidado ────────────────────────────────────────────────── + const wsConsolidado = workbook.addWorksheet('Consolidado', { + views: [{ state: 'frozen', ySplit: 1 }] + }); + + wsConsolidado.columns = [ + { header: 'Nodo/SubNodo', key: 'nodo', width: 22 }, + { header: 'Cliente', key: 'nombre', width: 28 }, + { header: 'Base de Datos', key: 'bdName', width: 22 }, + { header: 'Año', key: 'anio', width: 8 }, + { header: 'Mes (Núm)', key: 'mes', width: 10 }, + { header: 'Mes', key: 'mesNombre', width: 14 }, + { header: 'Clave Pedimento', key: 'claveped', width: 18 }, + { header: 'Total Pedimentos', key: 'total', width: 18 } + ]; + + styleHeader(wsConsolidado.getRow(1)); + + consolidado.forEach((row, i) => { + const excelRow = wsConsolidado.addRow(row); + const fill: ExcelJS.FillPattern = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: i % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF' } + }; + excelRow.eachCell((cell) => { + cell.fill = fill; + cell.alignment = { vertical: 'middle' }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFE2E8F0' } } }; + }); + excelRow.height = 16; + }); + + // Totales por nodo (fila al final) + wsConsolidado.addRow([]); + const totalRow = wsConsolidado.addRow({ + nodo: 'TOTAL GENERAL', + total: consolidado.reduce((sum, r) => sum + r.total, 0) + }); + totalRow.getCell('nodo').font = { bold: true }; + totalRow.getCell('total').font = { bold: true }; + totalRow.getCell('total').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } }; + + // Auto-filter en consolidado + wsConsolidado.autoFilter = { + from: { row: 1, column: 1 }, + to: { row: 1, column: 8 } + }; + + // ── Hoja por base de datos ───────────────────────────────────────────── + const baseNames = [...new Set(consolidado.map(r => r.bdName))]; + + for (const bdName of baseNames) { + const filas = consolidado.filter(r => r.bdName === bdName); + const nodo = filas[0]?.nodo ?? bdName; + const sheetName = (nodo.length > 28 ? nodo.slice(0, 28) : nodo).replace(/[\\/*?[\]]/g, '_'); + + const ws = workbook.addWorksheet(sheetName, { + views: [{ state: 'frozen', ySplit: 1 }] + }); + + ws.columns = [ + { header: 'Año', key: 'anio', width: 8 }, + { header: 'Mes (Núm)', key: 'mes', width: 10 }, + { header: 'Mes', key: 'mesNombre', width: 14 }, + { header: 'Clave Pedimento', key: 'claveped', width: 18 }, + { header: 'Total Pedimentos', key: 'total', width: 18 } + ]; + + styleHeader(ws.getRow(1)); + + filas.forEach((row, i) => { + const excelRow = ws.addRow(row); + const fill: ExcelJS.FillPattern = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: i % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF' } + }; + excelRow.eachCell((cell) => { + cell.fill = fill; + cell.alignment = { vertical: 'middle' }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFE2E8F0' } } }; + }); + excelRow.height = 16; + }); + + ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; + } + + // ── Hoja: Errores ────────────────────────────────────────────────────── + if (errores.length > 0) { + const wsErr = workbook.addWorksheet('Errores'); + wsErr.columns = [ + { header: 'Nodo/SubNodo', key: 'nodo', width: 22 }, + { header: 'Base de Datos', key: 'bdName', width: 22 }, + { header: 'Error', key: 'error', width: 60 } + ]; + styleHeader(wsErr.getRow(1)); + errores.forEach(e => wsErr.addRow(e)); + } + + // ── Serializar y devolver ────────────────────────────────────────────── + const buffer = await workbook.xlsx.writeBuffer(); + + const fechaHoy = new Date().toISOString().slice(0, 10); + const filename = `reporte_pedimentos_${fechaHoy}.xlsx`; + + return new Response(buffer as Buffer, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store' + } + }); +}; -- 2.49.1 From 0bf9c9e68dad2f8057ee3dac2460154b27284b17 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 14 Apr 2026 14:16:11 -0500 Subject: [PATCH 05/22] Refactor database configuration and user management - Updated .env.example to consolidate SQL Server credentials under PANEL_MSSQL_* variables. - Removed deprecated docker-compose.postgres.yml file. - Adjusted docker-compose.yml to utilize new SQL Server credential structure. - Enhanced README.md with Docker build and push instructions. - Refined database schema in schema.sql to align with new user and permission structures. - Updated init-database.js to reflect changes in user and session table names. - Modified user management functions in users.ts to accommodate new database schema. - Streamlined API routes to utilize PostgreSQL for user and database management. - Improved error handling and logging in various server routes. --- .env.example | 25 +- README.md | 14 + database/schema.sql | 96 +++-- docker-compose.postgres.yml | 52 --- docker-compose.yml | 23 +- scripts/init-database.js | 29 +- src/lib/server/controldesk-pg.ts | 335 ++++++++++++++++ src/lib/server/dashboard-pg.ts | 21 + src/lib/server/db.ts | 58 +-- src/lib/server/mssql-nodes.ts | 288 ++++++++++++++ src/lib/server/users.ts | 155 +++++--- src/routes/+page.server.ts | 473 ++++++----------------- src/routes/+page.svelte | 12 +- src/routes/api/dashboard.json/+server.ts | 55 +-- src/routes/api/health/+server.ts | 9 +- src/routes/reportes/+page.server.ts | 11 +- src/routes/reportes/excel/+server.ts | 30 +- src/routes/usuarios/+page.server.ts | 66 +--- 18 files changed, 1004 insertions(+), 748 deletions(-) delete mode 100644 docker-compose.postgres.yml create mode 100644 src/lib/server/controldesk-pg.ts create mode 100644 src/lib/server/dashboard-pg.ts create mode 100644 src/lib/server/mssql-nodes.ts diff --git a/.env.example b/.env.example index 0019580..4d2bfcb 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,11 @@ -# SQL Server - Servidor Primary (.202) -DB_PRIMARY_HOST=104.197.7.202 -DB_PRIMARY_USER=sa -DB_PRIMARY_PASS=Clave.2025 -DB_PRIMARY_DB=master +# Credenciales SQL Server (mismo usuario en todos los nodos salvo sql_password en database_nodes). +# Si no defines PANEL_MSSQL_*, se usan DB_PRIMARY_* / DB_SECONDARY_* como respaldo. +PANEL_MSSQL_USER=sa +PANEL_MSSQL_PASSWORD=Clave.2025 +# En contenedor Docker: reescribe localhost en server_name → host.docker.internal +# PANEL_MSSQL_DOCKER=true -# SQL Server - Servidor Secondary (.152) -DB_SECONDARY_HOST=104.192.7.152 -DB_SECONDARY_USER=sa -DB_SECONDARY_PASS=Clave.2025 -DB_SECONDARY_DB=master - -# SQL Server - Azure/CONTROLDESK -DB_AZURE_HOST=104.192.7.152 -DB_AZURE_USER=sa -DB_AZURE_PASS=Clave.2025 -DB_AZURE_DB=CONTROLDESK - -# PostgreSQL - Base de datos de usuarios (Docker local) +# PostgreSQL - Usuarios del panel + catálogo ControlDesk (tablas a24c.* las crea otra app) DB_POSTGRES_HOST=localhost DB_POSTGRES_PORT=5432 DB_POSTGRES_USER=postgres diff --git a/README.md b/README.md index 2a3ebcc..930a5ec 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ Este proyecto es una migración moderna del panel de control legacy `TransmitirA - **Node.js**: v18 o superior. - **SQL Server**: Acceso a las instancias de base de datos definidas en `.env`. +```bash +docker build \ + -t dev.aduanasoft.com:8443/databases_a24c/frontend:latest \ + -f ./Dockerfile \ + ./ +``` + +Subirlas a harbor + +``` +docker login dev.aduanasoft.com:8443 +docker push dev.aduanasoft.com:8443/databases_a24c/frontend:latest +``` + ## Configuración 1. **Instalar dependencias**: diff --git a/database/schema.sql b/database/schema.sql index cd414e7..6ab2ca0 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,55 +1,58 @@ --- Schema para sistema de usuarios y permisos --- Ejecutar en PostgreSQL (base de datos CONTROLDESK) +-- Panel Transmitiras: usuarios, permisos y sesiones (mismo modelo que +-- ~/dev/a24c/backend/api/v1/modules/dashboard — tablas en inglés, esquema a24c). +-- Ejecutar en PostgreSQL (misma base donde está el catálogo ControlDesk, p. ej. CONTROLDESK). --- Tabla de usuarios -CREATE TABLE IF NOT EXISTS usuarios ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - nombre_completo VARCHAR(100), - activo BOOLEAN DEFAULT true, - es_admin BOOLEAN DEFAULT false, - fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ultimo_acceso TIMESTAMP, - CONSTRAINT chk_username_length CHECK (char_length(username) >= 3) +CREATE SCHEMA IF NOT EXISTS a24c; + +CREATE TABLE IF NOT EXISTS a24c.dashboard_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT true, + is_admin BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_access_at TIMESTAMPTZ, + CONSTRAINT chk_dashboard_users_username_length CHECK (char_length(username) >= 3) ); --- Tabla de permisos de usuario a bases de datos -CREATE TABLE IF NOT EXISTS usuario_base_datos ( - id SERIAL PRIMARY KEY, - usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - base_datos_nombre VARCHAR(255) NOT NULL, -- Nombre de la base de datos (visible_name) - puede_ver BOOLEAN DEFAULT true, - puede_descargar_backup BOOLEAN DEFAULT false, - puede_restaurar BOOLEAN DEFAULT false, - fecha_asignacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(usuario_id, base_datos_nombre) +CREATE TABLE IF NOT EXISTS a24c.dashboard_user_database_permissions ( + id SERIAL PRIMARY KEY, + dashboard_user_id INTEGER NOT NULL REFERENCES a24c.dashboard_users (id) ON DELETE CASCADE, + database_name VARCHAR(255) NOT NULL, + can_view BOOLEAN NOT NULL DEFAULT true, + can_download_backup BOOLEAN NOT NULL DEFAULT false, + can_restore BOOLEAN NOT NULL DEFAULT false, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (dashboard_user_id, database_name) ); --- Tabla de sesiones -CREATE TABLE IF NOT EXISTS sesiones ( - id SERIAL PRIMARY KEY, - usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - token VARCHAR(255) UNIQUE NOT NULL, - ip_address VARCHAR(50), - user_agent TEXT, - fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - fecha_expiracion TIMESTAMP NOT NULL, - activa BOOLEAN DEFAULT true +CREATE TABLE IF NOT EXISTS a24c.dashboard_sessions ( + id SERIAL PRIMARY KEY, + dashboard_user_id INTEGER NOT NULL REFERENCES a24c.dashboard_users (id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + ip_address VARCHAR(50), + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true ); --- Índices para mejor rendimiento -CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_usuario ON usuario_base_datos(usuario_id); -CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_nombre ON usuario_base_datos(base_datos_nombre); -CREATE INDEX IF NOT EXISTS idx_usuarios_activo ON usuarios(activo); -CREATE INDEX IF NOT EXISTS idx_sesiones_token ON sesiones(token); -CREATE INDEX IF NOT EXISTS idx_sesiones_usuario ON sesiones(usuario_id); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_user_db_perm_user + ON a24c.dashboard_user_database_permissions (dashboard_user_id); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_user_db_perm_dbname + ON a24c.dashboard_user_database_permissions (database_name); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_users_active ON a24c.dashboard_users (is_active); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_sessions_token ON a24c.dashboard_sessions (token); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_sessions_user ON a24c.dashboard_sessions (dashboard_user_id); --- Insertar usuario admin por defecto --- Password: Admin123! --- Nota: Este hash es temporal, debes cambiarlo después del primer inicio de sesión -INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin, activo) +COMMENT ON TABLE a24c.dashboard_users IS 'Operadores del panel Transmitiras (JWT/bcrypt). Paridad api/v1/modules/dashboard/users.'; +COMMENT ON TABLE a24c.dashboard_user_database_permissions IS 'Permisos por base de datos. Paridad dashboard/user_database_permissions.'; +COMMENT ON TABLE a24c.dashboard_sessions IS 'Sesiones persistidas (opcional; el panel usa JWT en cookie). Paridad dashboard/sessions.'; + +-- Usuario admin por defecto (cambiar password_hash tras primer arranque; ver scripts/generate-password-hash.js) +INSERT INTO a24c.dashboard_users (username, email, password_hash, full_name, is_admin, is_active) VALUES ( 'admin', 'admin@aduanasoft.com', @@ -59,8 +62,3 @@ VALUES ( true ) ON CONFLICT (username) DO NOTHING; - --- Comentarios en tablas -COMMENT ON TABLE usuarios IS 'Usuarios del sistema de gestión de bases de datos'; -COMMENT ON TABLE usuario_base_datos IS 'Permisos de acceso de usuarios a bases de datos específicas'; -COMMENT ON TABLE sesiones IS 'Sesiones activas de usuarios'; diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml deleted file mode 100644 index 82a2fe3..0000000 --- a/docker-compose.postgres.yml +++ /dev/null @@ -1,52 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - container_name: aduanasoft-postgres - restart: unless-stopped - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: Control. - POSTGRES_DB: CONTROLDESK - PGDATA: /var/lib/postgresql/data/pgdata - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - aduanasoft-network - - # Tu aplicación Node.js (opcional, comentado por ahora) - # app: - # build: . - # container_name: aduanasoft-app - # restart: unless-stopped - # ports: - # - "3000:3000" - # environment: - # - NODE_ENV=production - # - DB_POSTGRES_HOST=postgres - # - DB_POSTGRES_PORT=5432 - # - DB_POSTGRES_USER=postgres - # - DB_POSTGRES_PASS=Control. - # - DB_POSTGRES_DB=CONTROLDESK - # depends_on: - # postgres: - # condition: service_healthy - # networks: - # - aduanasoft-network - -volumes: - postgres_data: - driver: local - -networks: - aduanasoft-network: - driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index ded764b..3b078be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,27 +5,18 @@ services: - "3000:3000" environment: # Use host.docker.internal to access resources on the host machine - - DB_PRIMARY_HOST=host.docker.internal - - DB_PRIMARY_USER=${DB_PRIMARY_USER} - - DB_PRIMARY_PASS=${DB_PRIMARY_PASS} - - DB_PRIMARY_DB=${DB_PRIMARY_DB} + # SQL Server: credenciales globales (server_name / database_name vienen de a24c.database_nodes en Postgres) + - PANEL_MSSQL_USER=${PANEL_MSSQL_USER} + - PANEL_MSSQL_PASSWORD=${PANEL_MSSQL_PASSWORD} + - PANEL_MSSQL_DOCKER=${PANEL_MSSQL_DOCKER:-true} + - IN_DOCKER=true - - DB_SECONDARY_HOST=host.docker.internal - - DB_SECONDARY_USER=${DB_SECONDARY_USER} - - DB_SECONDARY_PASS=${DB_SECONDARY_PASS} - - DB_SECONDARY_DB=${DB_SECONDARY_DB} - - - DB_AZURE_HOST=${DB_AZURE_HOST} - - DB_AZURE_USER=${DB_AZURE_USER} - - DB_AZURE_PASS=${DB_AZURE_PASS} - - DB_AZURE_DB=${DB_AZURE_DB} - - # PostgreSQL connection (connect to postgres service) + # PostgreSQL: catálogo ControlDesk (a24c.*) + usuarios del panel - DB_POSTGRES_HOST=postgres - DB_POSTGRES_PORT=5432 - DB_POSTGRES_USER=${DB_POSTGRES_USER} - DB_POSTGRES_PASS=${DB_POSTGRES_PASS} - - DB_POSTGRES_DB=${DB_POSTGRES_DB} + - DB_POSTGRES_DB=${DB_POSTGRES_DB} # JWT Secret - JWT_SECRET=${JWT_SECRET} diff --git a/scripts/init-database.js b/scripts/init-database.js index 7d5bfd2..331ce27 100644 --- a/scripts/init-database.js +++ b/scripts/init-database.js @@ -41,17 +41,21 @@ async function initDatabase() { // Verificar las tablas creadas console.log('4. Verificando tablas creadas...'); const result = await client.query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('usuarios', 'usuario_base_datos', 'sesiones') + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'a24c' + AND table_name IN ( + 'dashboard_users', + 'dashboard_user_database_permissions', + 'dashboard_sessions' + ) ORDER BY table_name; `); - + if (result.rows.length === 3) { - console.log(' ✓ Tablas verificadas:'); + console.log(' ✓ Tablas verificadas (esquema a24c):'); result.rows.forEach(row => { - console.log(` - ${row.table_name}`); + console.log(` - a24c.${row.table_name}`); }); } else { console.log(' ⚠ No todas las tablas fueron creadas'); @@ -59,13 +63,16 @@ async function initDatabase() { // Verificar usuario admin console.log('\n5. Verificando usuario admin...'); - const userResult = await client.query('SELECT username, email, es_admin FROM usuarios WHERE username = $1', ['admin']); - + const userResult = await client.query( + 'SELECT username, email, is_admin FROM a24c.dashboard_users WHERE username = $1', + ['admin'] + ); + if (userResult.rows.length > 0) { console.log(' ✓ Usuario admin creado:'); console.log(` - Username: ${userResult.rows[0].username}`); console.log(` - Email: ${userResult.rows[0].email}`); - console.log(` - Es Admin: ${userResult.rows[0].es_admin}`); + console.log(` - Es Admin: ${userResult.rows[0].is_admin}`); } else { console.log(' ⚠ Usuario admin no encontrado'); } @@ -79,7 +86,7 @@ async function initDatabase() { console.log('1. Ejecuta: node scripts/generate-password-hash.js'); console.log('2. Copia el hash generado'); console.log('3. Ejecuta este SQL en PostgreSQL:'); - console.log(' UPDATE usuarios SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';'); + console.log(' UPDATE a24c.dashboard_users SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';'); console.log('\n4. Luego inicia el servidor: npm run dev'); console.log('5. Ve a: http://localhost:5173/login'); console.log(' Usuario: admin'); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts new file mode 100644 index 0000000..b6885b4 --- /dev/null +++ b/src/lib/server/controldesk-pg.ts @@ -0,0 +1,335 @@ +/** + * Catálogo ControlDesk en PostgreSQL (esquema a24c; DDL lo aprovisiona otra aplicación). + * Devuelve columnas con alias en español para compatibilidad con la UI existente. + */ +import { env } from '$env/dynamic/private'; +import { pgPool } from './db'; + +function schemaName(): string { + const s = 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qNodes(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."database_nodes"`; +} + +function qUsers(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."portal_users"`; +} + +const ROW_DATABASE_NODE = ` + id AS "ID", + node_subnode_key AS "NodoSubNodo", + is_active AS "Activo", + rfc AS "RFC", + legal_name AS "Nombre", + branch_name AS "Sucursal", + notification_email AS "CorreoNotificacion", + server_name AS "ServerName", + database_name AS "BDName" +`; + +const ROW_PORTAL_USER = ` + id AS "ID", + database_node_id AS "IDNodoSubNodo", + is_authority_client AS "ClienteAutoridad", + full_name AS "Nombre", + username AS "Usuario", + bd_shelter AS "BD_Shelter" +`; + +export async function listDatabaseNodes(): Promise { + const sql = `SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()}`; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function listDatabaseNodesActive(): Promise { + const sql = ` + SELECT ${ROW_DATABASE_NODE} + FROM ${qNodes()} + WHERE is_active = 1 + AND database_name IS NOT NULL + AND TRIM(database_name) <> '' + ORDER BY node_subnode_key + `; + const r = await pgPool.query(sql); + return r.rows; +} + +/** Nodos activos con `sql_password` para conectar a SQL Server (no exponer al cliente). */ +export async function listDatabaseNodesForMssql(): Promise { + const where = ` + WHERE is_active = 1 + AND database_name IS NOT NULL + AND TRIM(database_name) <> '' + ORDER BY node_subnode_key + `; + try { + const r = await pgPool.query( + `SELECT ${ROW_DATABASE_NODE}, sql_password AS "sql_password" FROM ${qNodes()} ${where}` + ); + return r.rows; + } catch (e: any) { + if (e?.code === '42703') { + const r = await pgPool.query(`SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()} ${where}`); + return r.rows.map((row: any) => ({ ...row, sql_password: null })); + } + throw e; + } +} + +export async function listClientsCatalog(): Promise { + const sql = ` + SELECT + id AS "ID", + legal_name AS "Nombre", + node_subnode_key AS "NodoSubNodo", + notification_email AS "CorreoNotificacion", + is_active AS "Activo", + database_name AS "BDName" + FROM ${qNodes()} + `; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function listPortalUsers(): Promise { + const sql = `SELECT ${ROW_PORTAL_USER} FROM ${qUsers()} ORDER BY id`; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function lookupNodeByNodoOrBdName(nodoName: string): Promise { + const sql = ` + SELECT + legal_name AS "Nombre", + node_subnode_key AS "NodoSubNodo", + rfc AS "RFC" + FROM ${qNodes()} + WHERE LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(database_name)) = LOWER(TRIM($1::text)) + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodoName]); + return r.rows[0] ?? null; +} + +/** + * Asocia el nombre de archivo de respaldo (sin extensión) a una fila de `database_nodes`. + * Cubre: igualdad sin mayúsculas y prefijos tipo `NODO001_full_20240414` → nodo `NODO001`. + */ +export function matchNodeRowFromBackupStem(stem: string, nodes: any[]): any | null { + const raw = String(stem ?? '').trim(); + if (!raw || !nodes?.length) return null; + const key = raw.toLowerCase(); + + const nodoOf = (row: any) => String(row.NodoSubNodo ?? '').trim().toLowerCase(); + const bdOf = (row: any) => String(row.BDName ?? '').trim().toLowerCase(); + + for (const row of nodes) { + const n = nodoOf(row); + const b = bdOf(row); + if (n && n === key) return row; + if (b && b === key) return row; + } + + type Cand = { row: any; len: number }; + const cands: Cand[] = []; + for (const row of nodes) { + const n = nodoOf(row); + const b = bdOf(row); + if (n && (key === n || key.startsWith(`${n}_`) || key.startsWith(`${n}-`))) { + cands.push({ row, len: n.length }); + } + if (b && b !== n && (key === b || key.startsWith(`${b}_`) || key.startsWith(`${b}-`))) { + cands.push({ row, len: b.length }); + } + } + if (!cands.length) return null; + cands.sort((a, b) => b.len - a.len); + return cands[0].row; +} + +/** Datos de contacto para alertas (equivalente a la consulta previa sobre Usuarios/BasesDeDatos). */ +export async function lookupAlertClientData(nodoName: string): Promise { + const sql = ` + SELECT + pu.is_authority_client AS "ClienteAutoridad", + pu.full_name AS "Nombre", + pu.username AS "Usuario", + dn.notification_email AS "CorreoNotificacion" + FROM ${qUsers()} pu + LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id + WHERE pu.username = $1 + OR dn.database_name = $1 + OR dn.node_subnode_key = $1 + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodoName]); + return r.rows[0] ?? null; +} + +export async function updateNodeActive(id: number, activo: boolean): Promise { + await pgPool.query(`UPDATE ${qNodes()} SET is_active = $1 WHERE id = $2`, [activo ? 1 : 0, id]); +} + +export async function updateNodeLegalName(id: number, nombre: string): Promise { + await pgPool.query(`UPDATE ${qNodes()} SET legal_name = $1 WHERE id = $2`, [nombre, id]); +} + +export async function insertDatabaseNode(row: { + nodoSubNodo: string; + rfc: string; + nombre: string; + sucursal: string; + correo: string; + serverName: string; + bdName: string; + activo: number; +}): Promise { + await pgPool.query( + ` + INSERT INTO ${qNodes()} ( + node_subnode_key, rfc, legal_name, branch_name, + notification_email, server_name, database_name, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + row.nodoSubNodo, + row.rfc, + row.nombre, + row.sucursal, + row.correo, + row.serverName, + row.bdName, + row.activo + ] + ); +} + +export async function updateDatabaseNode( + id: number, + row: { + nodoSubNodo: string; + rfc: string; + nombre: string; + sucursal: string; + correo: string; + serverName: string; + bdName: string; + activo: number; + } +): Promise { + await pgPool.query( + ` + UPDATE ${qNodes()} SET + node_subnode_key = $1, + rfc = $2, + legal_name = $3, + branch_name = $4, + notification_email = $5, + server_name = $6, + database_name = $7, + is_active = $8 + WHERE id = $9 + `, + [ + row.nodoSubNodo, + row.rfc, + row.nombre, + row.sucursal, + row.correo, + row.serverName, + row.bdName, + row.activo, + id + ] + ); +} + +export async function deleteDatabaseNode(id: number): Promise { + await pgPool.query(`DELETE FROM ${qNodes()} WHERE id = $1`, [id]); +} + +export async function insertPortalUser(row: { + databaseNodeId: number; + isAuthorityClient: number; + fullName: string; + username: string; + passwordHash: string; + bdShelter: string | null; +}): Promise { + await pgPool.query( + ` + INSERT INTO ${qUsers()} ( + database_node_id, is_authority_client, full_name, username, password_hash, bd_shelter + ) VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + row.databaseNodeId, + row.isAuthorityClient, + row.fullName, + row.username, + row.passwordHash, + row.bdShelter + ] + ); +} + +export async function updatePortalUser( + id: number, + row: { + databaseNodeId: number; + isAuthorityClient: number; + fullName: string; + username: string; + bdShelter: string | null; + passwordHash?: string; + } +): Promise { + if (row.passwordHash !== undefined) { + await pgPool.query( + ` + UPDATE ${qUsers()} SET + database_node_id = $1, + is_authority_client = $2, + full_name = $3, + username = $4, + password_hash = $5, + bd_shelter = $6 + WHERE id = $7 + `, + [ + row.databaseNodeId, + row.isAuthorityClient, + row.fullName, + row.username, + row.passwordHash, + row.bdShelter, + id + ] + ); + } else { + await pgPool.query( + ` + UPDATE ${qUsers()} SET + database_node_id = $1, + is_authority_client = $2, + full_name = $3, + username = $4, + bd_shelter = $5 + WHERE id = $6 + `, + [row.databaseNodeId, row.isAuthorityClient, row.fullName, row.username, row.bdShelter, id] + ); + } +} + +export async function deletePortalUser(id: number): Promise { + await pgPool.query(`DELETE FROM ${qUsers()} WHERE id = $1`, [id]); +} diff --git a/src/lib/server/dashboard-pg.ts b/src/lib/server/dashboard-pg.ts new file mode 100644 index 0000000..f0e328c --- /dev/null +++ b/src/lib/server/dashboard-pg.ts @@ -0,0 +1,21 @@ +/** + * Tablas del panel Transmitiras en PostgreSQL (esquema a24c), alineadas con + * ~/dev/a24c/backend/api/v1/modules/dashboard (inglés). + */ +import { env } from '$env/dynamic/private'; + +export function dashboardSchema(): string { + const s = env.DB_CONTROLDESK_SCHEMA || 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qTable(table: string): string { + const s = dashboardSchema().replace(/"/g, '""'); + const t = table.replace(/"/g, '""'); + return `"${s}"."${t}"`; +} + +export const tableDashboardUsers = () => qTable('dashboard_users'); +export const tableDashboardUserDbPerms = () => qTable('dashboard_user_database_permissions'); +export const tableDashboardSessions = () => qTable('dashboard_sessions'); diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index bbed80b..552dacf 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1,4 +1,3 @@ -import sql from 'mssql'; import pkg from 'pg'; const { Pool } = pkg; import { env } from '$env/dynamic/private'; @@ -15,66 +14,11 @@ const pgPool = new Pool({ connectionTimeoutMillis: 5000 }); -const primaryConfig: sql.config = { - user: env.DB_PRIMARY_USER, - password: env.DB_PRIMARY_PASS, - server: env.DB_PRIMARY_HOST, - database: env.DB_PRIMARY_DB, - options: { - encrypt: true, - trustServerCertificate: true - } -}; - -const secondaryConfig: sql.config = { - user: env.DB_SECONDARY_USER, - password: env.DB_SECONDARY_PASS, - server: env.DB_SECONDARY_HOST, - database: env.DB_SECONDARY_DB, - options: { - encrypt: true, - trustServerCertificate: true - } -}; - -const azureConfig: sql.config = { - user: env.DB_AZURE_USER, - password: env.DB_AZURE_PASS, - server: env.DB_AZURE_HOST, - database: env.DB_AZURE_DB, - options: { - encrypt: true, - trustServerCertificate: true - } -}; - class Database { - private primaryPool: sql.ConnectionPool | null = null; - private secondaryPool: sql.ConnectionPool | null = null; - private azurePool: sql.ConnectionPool | null = null; - - async getPrimary(): Promise { - if (this.primaryPool?.connected) return this.primaryPool; - this.primaryPool = await new sql.ConnectionPool(primaryConfig).connect(); - return this.primaryPool; - } - - async getSecondary(): Promise { - if (this.secondaryPool?.connected) return this.secondaryPool; - this.secondaryPool = await new sql.ConnectionPool(secondaryConfig).connect(); - return this.secondaryPool; - } - - async getAzure(): Promise { - if (this.azurePool?.connected) return this.azurePool; - this.azurePool = await new sql.ConnectionPool(azureConfig).connect(); - return this.azurePool; - } - async getPostgres() { return pgPool.connect(); } } export const db = new Database(); -export { sql, pgPool }; +export { pgPool }; diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts new file mode 100644 index 0000000..279b935 --- /dev/null +++ b/src/lib/server/mssql-nodes.ts @@ -0,0 +1,288 @@ +/** + * Conexiones SQL Server por nodo (paridad con a24c: server_name / database_name en database_nodes, + * usuario global PANEL_MSSQL_USER, contraseña por nodo sql_password o PANEL_MSSQL_PASSWORD). + */ +import sql from 'mssql'; +import { env } from '$env/dynamic/private'; + +const MAX_POOLS = 16; +const poolMap = new Map(); + +export function adjustMssqlServerForDocker(serverName: string): string { + const docker = + String(env.PANEL_MSSQL_DOCKER || '') + .trim() + .toLowerCase() === 'true' || + String(env.IN_DOCKER || '') + .trim() + .toLowerCase() === 'true'; + if (!docker) return serverName.trim(); + const parts = serverName.trim().split(',', 2); + const host = (parts[0] || '').trim().toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') { + const port = parts[1]?.trim(); + return port ? `host.docker.internal,${port}` : 'host.docker.internal'; + } + return serverName.trim(); +} + +export function resolveMssqlUser(): string { + return ( + String(env.PANEL_MSSQL_USER || '').trim() + ); +} + +/** Contraseña SQL: columna sql_password del nodo (texto plano) o variable global. */ +export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefined): string { + const raw = nodeSqlPassword != null ? String(nodeSqlPassword).trim() : ''; + if (raw) return raw; + return String( + env.PANEL_MSSQL_PASSWORD || '' + ).trim(); +} + +function poolCacheKey(server: string, user: string, password: string): string { + return `${server}\t${user}\t${password}`; +} + +async function evictPoolIfNeeded(): Promise { + while (poolMap.size >= MAX_POOLS) { + const first = poolMap.keys().next().value as string | undefined; + if (!first) break; + const old = poolMap.get(first); + poolMap.delete(first); + try { + await old?.close(); + } catch { + /* ignore */ + } + } +} + +/** + * Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes). + */ +export async function getMssqlPoolMaster(serverHost: string, password: string): Promise { + const user = resolveMssqlUser(); + if (!user || !password) { + throw new Error( + 'Falta PANEL_MSSQL_USER / PANEL_MSSQL_PASSWORD (o sql_password en database_nodes).' + ); + } + const server = adjustMssqlServerForDocker(serverHost); + const key = poolCacheKey(server, user, password); + const existing = poolMap.get(key); + if (existing?.connected) return existing; + + await evictPoolIfNeeded(); + + const cfg: sql.config = { + user, + password, + server, + database: 'master', + options: { + encrypt: true, + trustServerCertificate: true, + connectTimeout: 30000 + } + }; + const pool = await new sql.ConnectionPool(cfg).connect(); + poolMap.set(key, pool); + return pool; +} + +export type CatalogNodeRow = { + ID: number; + ServerName: string; + BDName: string; + NodoSubNodo: string; + Nombre: string; + Activo?: number; + sql_password?: string | null; +}; + +/** Métricas de una base en un servidor (conexión a master). */ +export async function queryDatabaseMetricsOnServer( + pool: sql.ConnectionPool, + databaseName: string +): Promise { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + // No unir restorehistory en el mismo SELECT que agrega mf.size: cada fila de + // restorehistory duplicaría los archivos y SUM inflaría el tamaño (p. ej. cientos de TB). + const result = await req.query(` + SELECT + d.name AS visible_name, + d.name AS original_name, + CAST(( + SELECT SUM(mf2.size) * 8.0 / 1024 + FROM sys.master_files mf2 + WHERE mf2.database_id = d.database_id + ) AS DECIMAL(10,2)) AS size_mb, + CAST(( + SELECT SUM(mf2.size) * 8.0 / 1024 / 1024 + FROM sys.master_files mf2 + WHERE mf2.database_id = d.database_id + ) AS DECIMAL(10,2)) AS total_size_gb, + ( + SELECT MAX(rh.restore_date) + FROM msdb.dbo.restorehistory rh + WHERE rh.destination_database_name = d.name + ) AS last_restore_date, + d.create_date, + d.state_desc, + d.recovery_model_desc + FROM sys.databases d + WHERE d.name = @dbname + `); + const row = result.recordset?.[0]; + return row ?? null; +} + +/** Alertas: misma regla que antes (sin restore reciente o null). */ +export async function queryDatabaseAlertRow( + pool: sql.ConnectionPool, + databaseName: string +): Promise<{ visible_name: string; last_restore_date: Date | null } | null> { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + const result = await req.query(` + SELECT + d.name AS visible_name, + MAX(rh.restore_date) AS last_restore_date + FROM sys.databases d + LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name + WHERE d.name = @dbname + GROUP BY d.name + HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL + `); + const row = result.recordset?.[0]; + return row + ? { + visible_name: String(row.visible_name), + last_restore_date: row.last_restore_date ?? null + } + : null; +} + +export async function queryRestoreHistoryForDatabase( + pool: sql.ConnectionPool, + databaseName: string +): Promise<{ restore_date: Date }[]> { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + const result = await req.query(` + SELECT restore_date + FROM msdb.dbo.restorehistory + WHERE destination_database_name = @dbname + AND restore_date >= DATEADD(DAY, -120, GETDATE()) + `); + return (result.recordset as any[]).map((r) => ({ restore_date: r.restore_date })); +} + +function computeEffectivenessFromHistory( + restoreHistory: Record +): Record { + const effectivenessByDb: Record = {}; + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter((m) => m >= 0); + + for (const [dbName, history] of Object.entries(restoreHistory)) { + const monthly: { + [monthKey: string]: { daysWithRestore: Set; totalDays: number }; + } = {}; + for (const m of monthsToInclude) { + const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; + monthly[monthKey] = { + daysWithRestore: new Set(), + totalDays: new Date(currentYear, m + 1, 0).getDate() + }; + } + for (const h of history) { + const d = new Date(h.restore_date); + const y = d.getFullYear(); + const m = d.getMonth(); + if (y !== currentYear || !monthsToInclude.includes(m)) continue; + const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`; + const dayKey = d.toISOString().slice(0, 10); + monthly[monthKey]?.daysWithRestore.add(dayKey); + } + effectivenessByDb[dbName] = Object.entries(monthly).map(([month, data]) => { + const eff = + data.totalDays === 0 ? 0 : (data.daysWithRestore.size / data.totalDays) * 100; + return { month, effectiveness: Number(eff.toFixed(1)) }; + }); + } + return effectivenessByDb; +} + +export type SqlDashboardBundle = { + databaseRows: any[]; + summaryMain: { total_databases: number; total_size_gb: number }; + alertsData: any[]; + restoreHistory: Record; + effectivenessByDb: Record; +}; + +/** + * Recorre nodos activos del catálogo y consulta SQL Server en server_name (BD = database_name). + */ +export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promise { + const databaseRows: any[] = []; + const alertsData: any[] = []; + const restoreHistory: Record = {}; + let totalSizeGb = 0; + + for (const node of nodes) { + const dbn = String(node.BDName || '').trim(); + if (!dbn) continue; + const pwd = resolveNodeSqlPassword(node.sql_password); + try { + const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd); + const row = await queryDatabaseMetricsOnServer(pool, dbn); + if (!row) continue; + + const visible = String(row.visible_name ?? dbn); + const keyLower = visible.toLowerCase(); + + databaseRows.push({ + ...row, + visible_name: visible, + original_name: row.original_name ?? visible, + NodoSubNodo: node.NodoSubNodo, + client_name: node.Nombre, + BDName: dbn, + _node_id: node.ID, + _server: adjustMssqlServerForDocker(String(node.ServerName || '').trim()) + }); + + totalSizeGb += Number(row.total_size_gb) || 0; + + const alertRow = await queryDatabaseAlertRow(pool, dbn); + if (alertRow) alertsData.push(alertRow); + + const hist = await queryRestoreHistoryForDatabase(pool, dbn); + if (!restoreHistory[keyLower]) restoreHistory[keyLower] = []; + for (const h of hist) { + restoreHistory[keyLower].push(h); + } + } catch (e) { + console.error( + `SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`, + e + ); + } + } + + const summaryMain = { + total_databases: databaseRows.length, + total_size_gb: Math.round(totalSizeGb * 100) / 100 + }; + + const effectivenessByDb = computeEffectivenessFromHistory(restoreHistory); + + return { databaseRows, summaryMain, alertsData, restoreHistory, effectivenessByDb }; +} diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index 1872b3b..5062b1f 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -1,15 +1,48 @@ import { pgPool } from './db'; import { hashPassword, verifyPassword, type Usuario } from './auth'; -import type { PoolClient } from 'pg'; +import { + tableDashboardUsers, + tableDashboardUserDbPerms +} from './dashboard-pg'; + +/** Mapea fila SQL (alias español opcional) al tipo UI. */ +function rowToUsuario(row: Record): Usuario { + return { + id: row.id as number, + username: row.username as string, + email: (row.email as string) ?? '', + nombre_completo: (row.nombre_completo ?? row.full_name ?? '') as string, + activo: (row.activo ?? row.is_active) as boolean, + es_admin: (row.es_admin ?? row.is_admin) as boolean + }; +} + +/** Solo para login (incluye hash). */ +const SELECT_USER_LOGIN = ` + id, username, email, password_hash, + full_name AS nombre_completo, + is_active AS activo, + is_admin AS es_admin +`; + +const SELECT_USER_PUBLIC = ` + id, username, email, + full_name AS nombre_completo, + is_active AS activo, + is_admin AS es_admin +`; /** * Autenticar usuario por username y password */ export async function authenticateUser(username: string, password: string): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, password_hash, nombre_completo, activo, es_admin FROM usuarios WHERE username = $1 AND activo = true', + `SELECT ${SELECT_USER_LOGIN} + FROM ${t} + WHERE username = $1 AND is_active = true`, [username] ); @@ -24,20 +57,11 @@ export async function authenticateUser(username: string, password: string): Prom return null; } - // Actualizar último acceso - await client.query( - 'UPDATE usuarios SET ultimo_acceso = CURRENT_TIMESTAMP WHERE id = $1', - [user.id] - ); + await client.query(`UPDATE ${t} SET last_access_at = CURRENT_TIMESTAMP WHERE id = $1`, [ + user.id + ]); - return { - id: user.id, - username: user.username, - email: user.email, - nombre_completo: user.nombre_completo, - activo: user.activo, - es_admin: user.es_admin - }; + return rowToUsuario(user); } finally { client.release(); } @@ -48,9 +72,10 @@ export async function authenticateUser(username: string, password: string): Prom */ export async function getUserById(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios WHERE id = $1', + `SELECT ${SELECT_USER_PUBLIC} FROM ${t} WHERE id = $1`, [userId] ); @@ -58,7 +83,7 @@ export async function getUserById(userId: number): Promise { return null; } - return result.rows[0]; + return rowToUsuario(result.rows[0]); } finally { client.release(); } @@ -75,17 +100,24 @@ export async function createUser(data: { es_admin?: boolean; }): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const passwordHash = await hashPassword(data.password); const result = await client.query( - `INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, username, email, nombre_completo, activo, es_admin`, - [data.username, data.email, passwordHash, data.nombre_completo || '', data.es_admin || false] + `INSERT INTO ${t} (username, email, password_hash, full_name, is_admin, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING ${SELECT_USER_PUBLIC}`, + [ + data.username, + data.email, + passwordHash, + data.nombre_completo || '', + data.es_admin || false + ] ); - return result.rows[0]; + return rowToUsuario(result.rows[0]); } finally { client.release(); } @@ -105,9 +137,10 @@ export async function updateUser( } ): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const updates: string[] = []; - const values: any[] = []; + const values: unknown[] = []; let paramIndex = 1; if (data.email !== undefined) { @@ -116,17 +149,17 @@ export async function updateUser( } if (data.nombre_completo !== undefined) { - updates.push(`nombre_completo = $${paramIndex++}`); + updates.push(`full_name = $${paramIndex++}`); values.push(data.nombre_completo); } if (data.activo !== undefined) { - updates.push(`activo = $${paramIndex++}`); + updates.push(`is_active = $${paramIndex++}`); values.push(data.activo); } if (data.es_admin !== undefined) { - updates.push(`es_admin = $${paramIndex++}`); + updates.push(`is_admin = $${paramIndex++}`); values.push(data.es_admin); } @@ -143,14 +176,14 @@ export async function updateUser( values.push(userId); const result = await client.query( - `UPDATE usuarios + `UPDATE ${t} SET ${updates.join(', ')} WHERE id = $${paramIndex} - RETURNING id, username, email, nombre_completo, activo, es_admin`, + RETURNING ${SELECT_USER_PUBLIC}`, values ); - return result.rows[0] || null; + return result.rows[0] ? rowToUsuario(result.rows[0]) : null; } finally { client.release(); } @@ -161,8 +194,9 @@ export async function updateUser( */ export async function deleteUser(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { - const result = await client.query('DELETE FROM usuarios WHERE id = $1', [userId]); + const result = await client.query(`DELETE FROM ${t} WHERE id = $1`, [userId]); return result.rowCount ? result.rowCount > 0 : false; } finally { client.release(); @@ -174,11 +208,12 @@ export async function deleteUser(userId: number): Promise { */ export async function listUsers(): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios ORDER BY id DESC' + `SELECT ${SELECT_USER_PUBLIC} FROM ${t} ORDER BY id DESC` ); - return result.rows; + return result.rows.map(rowToUsuario); } finally { client.release(); } @@ -189,12 +224,13 @@ export async function listUsers(): Promise { */ export async function getUserDatabasePermissions(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { const result = await client.query( - 'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true', + `SELECT database_name FROM ${t} WHERE dashboard_user_id = $1 AND can_view = true`, [userId] ); - return result.rows.map(row => row.base_datos_nombre); + return result.rows.map((row) => row.database_name as string); } finally { client.release(); } @@ -213,15 +249,17 @@ export async function assignDatabaseToUser( } = {} ): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { await client.query( - `INSERT INTO usuario_base_datos (usuario_id, base_datos_nombre, puede_ver, puede_descargar_backup, puede_restaurar) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (usuario_id, base_datos_nombre) + `INSERT INTO ${t} ( + dashboard_user_id, database_name, can_view, can_download_backup, can_restore + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (dashboard_user_id, database_name) DO UPDATE SET - puede_ver = $3, - puede_descargar_backup = $4, - puede_restaurar = $5`, + can_view = EXCLUDED.can_view, + can_download_backup = EXCLUDED.can_download_backup, + can_restore = EXCLUDED.can_restore`, [ userId, baseDatosNombre, @@ -240,9 +278,10 @@ export async function assignDatabaseToUser( */ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: string): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { await client.query( - 'DELETE FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2', + `DELETE FROM ${t} WHERE dashboard_user_id = $1 AND database_name = $2`, [userId, baseDatosNombre] ); } finally { @@ -255,24 +294,21 @@ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: st */ export async function userCanViewDatabase(userId: number, baseDatosNombre: string): Promise { const client = await pgPool.connect(); + const tu = tableDashboardUsers(); + const tp = tableDashboardUserDbPerms(); try { - // Los admins pueden ver todo - const userResult = await client.query( - 'SELECT es_admin FROM usuarios WHERE id = $1', - [userId] - ); + const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]); - if (userResult.rows.length > 0 && userResult.rows[0].es_admin) { + if (userResult.rows.length > 0 && userResult.rows[0].is_admin) { return true; } - // Verificar permiso específico const permResult = await client.query( - 'SELECT puede_ver FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2', + `SELECT can_view FROM ${tp} WHERE dashboard_user_id = $1 AND database_name = $2`, [userId, baseDatosNombre] ); - return permResult.rows.length > 0 && permResult.rows[0].puede_ver; + return permResult.rows.length > 0 && permResult.rows[0].can_view; } finally { client.release(); } @@ -286,26 +322,25 @@ export async function filterDatabasesByUserPermissions { const client = await pgPool.connect(); + const tu = tableDashboardUsers(); + const tp = tableDashboardUserDbPerms(); try { - // Los admins ven todo - const userResult = await client.query( - 'SELECT es_admin FROM usuarios WHERE id = $1', - [userId] - ); + const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]); - if (userResult.rows.length > 0 && userResult.rows[0].es_admin) { + if (userResult.rows.length > 0 && userResult.rows[0].is_admin) { return databases; } - // Obtener bases de datos permitidas const permResult = await client.query( - 'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true', + `SELECT database_name FROM ${tp} WHERE dashboard_user_id = $1 AND can_view = true`, [userId] ); - const allowedDatabases = new Set(permResult.rows.map(row => row.base_datos_nombre.toLowerCase())); + const allowedDatabases = new Set( + permResult.rows.map((row) => String(row.database_name).toLowerCase()) + ); - return databases.filter(db => allowedDatabases.has(db.visible_name.toLowerCase())); + return databases.filter((db) => allowedDatabases.has(db.visible_name.toLowerCase())); } finally { client.release(); } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 8b5af20..82c39c8 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,4 +1,3 @@ -import { db } from '$lib/server/db'; import { env } from '$env/dynamic/private'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -7,6 +6,28 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { verifyToken } from '$lib/server/auth'; import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; +import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { + listClientsCatalog, + listDatabaseNodes, + listDatabaseNodesForMssql, + listPortalUsers, + matchNodeRowFromBackupStem, + lookupAlertClientData, + updateNodeActive, + updateNodeLegalName, + insertDatabaseNode, + updateDatabaseNode, + deleteDatabaseNode, + insertPortalUser, + updatePortalUser, + deletePortalUser +} from '$lib/server/controldesk-pg'; + +function parseActivoField(formData: FormData): number { + const v = formData.get('Activo'); + return v === 'true' || v === '1' ? 1 : 0; +} // Helper to check disk space (Simple Windows implementation) // Note: In production, consider a specialized library @@ -40,7 +61,7 @@ export const load: PageServerLoad = async ({ cookies }) => { // Initialize result containers let databaseRows: any[] = []; - let summaryMain: any = null; + let summaryMain: any = { total_databases: 0, total_size_gb: 0 }; let restoredCount = 0; let notRestoredCount = 0; @@ -63,198 +84,39 @@ export const load: PageServerLoad = async ({ cookies }) => { backups: null as string | null }; - // --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) --- + // --- 1. SQL Server: una conexión por servidor (master) según a24c.database_nodes --- + let nodesForSql: CatalogNodeRow[] = []; try { - // Usamos el servidor SECUNDARIO (152) como fuente del panel principal - const primary = await db.getSecondary(); - - // Main Database Rows - Bases restauradas localmente - const queryMain = ` - SELECT - d.name AS visible_name, - d.name AS original_name, - CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb, - CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb, - MAX(rh.restore_date) AS last_restore_date - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0 - LEFT JOIN - msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name - WHERE - d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - GROUP BY - d.name - ORDER BY - d.name - `; - const resultMain = await primary.request().query(queryMain); - databaseRows = resultMain.recordset; - - // Summary Main - const querySummary = ` - SELECT - COUNT(DISTINCT d.database_id) AS total_databases, - SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id - WHERE - mf.type = 0 AND d.name != 'tempdb' - `; - const resSummary = await primary.request().query(querySummary); - summaryMain = resSummary.recordset[0]; - - // Alerts Data (bases sin restore reciente) - const sqlAlerts = ` - SELECT - d.name AS visible_name, - MAX(rh.restore_date) AS last_restore_date - FROM sys.databases d - LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name - WHERE d.name != 'tempdb' - GROUP BY d.name - HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL - `; - const resAlerts = await primary.request().query(sqlAlerts); - alertsData = resAlerts.recordset; - - // Historial de restauraciones por base (últimos 60 días) - const sqlHistory = ` - SELECT - destination_database_name AS visible_name, - restore_date - FROM msdb.dbo.restorehistory - WHERE restore_date >= DATEADD(DAY, -120, GETDATE()) - `; - const resHistory = await primary.request().query(sqlHistory); - for (const row of resHistory.recordset as any[]) { - const name = String(row.visible_name ?? '').toLowerCase(); - if (!restoreHistory[name]) restoreHistory[name] = []; - restoreHistory[name].push({ restore_date: row.restore_date }); - } - - // Calcular efectividad mensual por base (1 restore esperado por día) - const now = new Date(); - const currentYear = now.getFullYear(); - const currentMonth = now.getMonth(); // 0-11 - - const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter( - (m) => m >= 0 - ); - - for (const [dbName, history] of Object.entries(restoreHistory)) { - const monthly: { [monthKey: string]: { daysWithRestore: Set; totalDays: number } } = {}; - - // inicializar meses con días del mes - for (const m of monthsToInclude) { - const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; - monthly[monthKey] = { - daysWithRestore: new Set(), - totalDays: new Date(currentYear, m + 1, 0).getDate() - }; - } - - for (const h of history) { - const d = new Date(h.restore_date); - const y = d.getFullYear(); - const m = d.getMonth(); - if (y !== currentYear || !monthsToInclude.includes(m)) continue; - const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`; - const dayKey = d.toISOString().slice(0, 10); - monthly[monthKey]?.daysWithRestore.add(dayKey); - } - - effectivenessByDb[dbName] = Object.entries(monthly).map( - ([month, data]) => { - const eff = - data.totalDays === 0 - ? 0 - : (data.daysWithRestore.size / data.totalDays) * 100; - return { month, effectiveness: Number(eff.toFixed(1)) }; - } - ); - } - + nodesForSql = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; } catch (e: any) { - console.error("Error loading Primary DB data:", e); - errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`; + console.error('Error leyendo database_nodes para SQL Server:', e); + errors.primary = `PostgreSQL / database_nodes: ${e.message}`; } - // --- 2. Load Secondary Data (Servidor con TODAS las bases de clientes en 202) --- try { - // Aquí usamos el servidor PRIMARIO (202) para la segunda sección - const secondary = await db.getPrimary(); - - // Consultar TODAS las bases de datos en el servidor secundario - const queryMainSec = ` - SELECT - d.name AS visible_name, - d.name AS original_name, - CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb, - d.create_date, - d.state_desc, - d.recovery_model_desc - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0 - WHERE - d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - GROUP BY - d.name, d.create_date, d.state_desc, d.recovery_model_desc - ORDER BY - d.name - `; - - const resultSecondary = await secondary.request().query(queryMainSec); - databaseRowsAZ = resultSecondary.recordset; - - const querySummarySec = ` - SELECT - COUNT(d.database_id) AS total_databases, - CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb - FROM - sys.databases d - LEFT JOIN - sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0 - WHERE - d.name NOT IN ('master', 'tempdb', 'model', 'msdb') - `; - const resSummarySec = await secondary.request().query(querySummarySec); - summaryAZ = resSummarySec.recordset[0]; - + const bundle = await loadSqlDashboardFromNodes(nodesForSql); + databaseRows = bundle.databaseRows; + summaryMain = bundle.summaryMain; + alertsData = bundle.alertsData; + restoreHistory = bundle.restoreHistory; + effectivenessByDb = bundle.effectivenessByDb; + databaseRowsAZ = [...bundle.databaseRows]; + summaryAZ = { ...bundle.summaryMain }; } catch (e: any) { - console.error("Error loading Secondary DB data:", e); - errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`; + console.error('Error métricas SQL Server por nodo:', e); + errors.primary = `${errors.primary ? errors.primary + ' · ' : ''}SQL Server (nodos): ${e.message}`; } - // --- 3. Load Azure/ControlDesk Data (Catálogo de Clientes) --- - // Note: We need this connection to hydrate backup info and alerts too - let azure: any = null; + // --- 3. Catálogo ControlDesk (PostgreSQL, esquema a24c) --- + let controlDeskOk = false; try { - azure = await db.getAzure(); - - // Clients Catalog - desde CONTROLDESK en Azure - const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM [CONTROLDESK].[dbo].[BasesdeDatos]`; - const resClients = await azure.request().query(clientsQuery); - clientsData = resClients.recordset; - - // DB Management List - const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM [CONTROLDESK].[dbo].[BasesDeDatos]`; - const resBases = await azure.request().query(basesQueries); - basesDeDatosList = resBases.recordset; - - // Usuarios de la BD MSSQL (tabla CONTROLDESK.dbo.Usuarios) - const usuariosQuery = `SELECT ID, IDNodoSubNodo, ClienteAutoridad, Nombre, Usuario, BD_Shelter FROM [CONTROLDESK].[dbo].[Usuarios] ORDER BY ID`; - const resUsuarios = await azure.request().query(usuariosQuery); - usuariosList = resUsuarios.recordset; - + clientsData = await listClientsCatalog(); + basesDeDatosList = await listDatabaseNodes(); + usuariosList = await listPortalUsers(); + controlDeskOk = true; } catch (e: any) { - console.error("Error loading Azure/ControlDesk data:", e); - errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`; + console.error('Error loading ControlDesk (PostgreSQL):', e); + errors.azure = `Error conectando al catálogo ControlDesk (PostgreSQL): ${e.message}`; } // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- @@ -279,24 +141,17 @@ export const load: PageServerLoad = async ({ cookies }) => { const nodoName = path.parse(file).name; let clientData: any = null; - // Try to fetch metadata if azure DB is available - if (azure) { + if (controlDeskOk && basesDeDatosList.length) { try { - const clientQuery = ` - SELECT TOP 1 Nombre, NodoSubNodo, RFC - FROM [CONTROLDESK].[dbo].[BasesDeDatos] - WHERE NodoSubNodo = @nodoName OR BDName = @nodoName - `; - const req = azure.request(); - req.input('nodoName', nodoName); - const res = await req.query(clientQuery); - clientData = res.recordset[0]; - } catch {} + clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); + } catch { + /* ignore */ + } } backupFiles.push({ name: file, - nodo_name: nodoName, + nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, client_name: clientData?.Nombre ?? 'Cliente no identificado', client_authority: clientData?.RFC ?? 'N/A', bd_shelter: 'N/A', @@ -312,38 +167,26 @@ export const load: PageServerLoad = async ({ cookies }) => { return db - da; }); - // Hydrate Alerts if possible - if (azure && alertsData.length > 0) { + if (controlDeskOk && alertsData.length > 0) { const hydratedAlerts = []; for (const alert of alertsData) { const nodoName = alert.visible_name; let cData: any = null; try { - const req = azure.request(); - req.input('nodoName', nodoName); - const res = await req.query(` - SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion - FROM [CONTROLDESK].[dbo].[Usuarios] AS u - LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo - WHERE u.Usuario = @nodoName - `); - cData = res.recordset[0]; - } catch {} - + cData = await lookupAlertClientData(String(nodoName)); + } catch { + /* ignore */ + } + hydratedAlerts.push({ ...alert, clientData: cData }); } alertsData = hydratedAlerts; } - // Enriquecer databaseRows (servidor local restaurado) con NodoSubNodo y Nombre desde BasesDeDatos - if (azure && databaseRows.length > 0) { + // Enriquecer databaseRows con catálogo ControlDesk (PostgreSQL) + if (controlDeskOk && databaseRows.length > 0) { try { - const req = azure.request(); - const res = await req.query(` - SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName - FROM [CONTROLDESK].[dbo].[BasesDeDatos] - `); - const bases = res.recordset as any[]; + const bases = (await listDatabaseNodes()) as any[]; const mapByBdName = new Map(); const mapByNodo = new Map(); @@ -467,22 +310,13 @@ export const actions: Actions = { const activoRaw = formData.get('activo'); if (!idRaw || !activoRaw) { - return { success: false, message: 'Pare1metros incompletos' }; + return { success: false, message: 'Parámetros incompletos' }; } const id = Number(idRaw); const activo = activoRaw === 'true'; - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('activo', activo ? 1 : 0); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesdeDatos] - SET Activo = @activo - WHERE ID = @id - `); + await updateNodeActive(id, activo); return { success: true }; } catch (e: any) { @@ -498,26 +332,17 @@ export const actions: Actions = { const nombreRaw = formData.get('nombre'); if (!idRaw || !nombreRaw) { - return { success: false, message: 'Pare1metros incompletos' }; + return { success: false, message: 'Parámetros incompletos' }; } const id = Number(idRaw); const nombre = String(nombreRaw).trim(); if (!nombre) { - return { success: false, message: 'El nombre no puede estar vacedo' }; + return { success: false, message: 'El nombre no puede estar vacío' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('nombre', nombre); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesdeDatos] - SET Nombre = @nombre - WHERE ID = @id - `); + await updateNodeLegalName(id, nombre); return { success: true }; } catch (e: any) { @@ -536,28 +361,22 @@ export const actions: Actions = { const correo = String(formData.get('CorreoNotificacion') || '').trim(); const serverName = String(formData.get('ServerName') || '').trim(); const bdName = String(formData.get('BDName') || '').trim(); - const activo = formData.get('Activo') === 'true' ? 1 : 0; + const activo = parseActivoField(formData); if (!nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) { return { success: false, message: 'Todos los campos son requeridos' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('nodoSubNodo', nodoSubNodo); - req.input('rfc', rfc); - req.input('nombre', nombre); - req.input('sucursal', sucursal); - req.input('correo', correo); - req.input('serverName', serverName); - req.input('bdName', bdName); - req.input('activo', activo); - - await req.query(` - INSERT INTO [CONTROLDESK].[dbo].[BasesDeDatos] - (NodoSubNodo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName, Activo) - VALUES (@nodoSubNodo, @rfc, @nombre, @sucursal, @correo, @serverName, @bdName, @activo) - `); + await insertDatabaseNode({ + nodoSubNodo, + rfc, + nombre, + sucursal, + correo, + serverName, + bdName, + activo + }); return { success: true }; } catch (e: any) { @@ -577,36 +396,22 @@ export const actions: Actions = { const correo = String(formData.get('CorreoNotificacion') || '').trim(); const serverName = String(formData.get('ServerName') || '').trim(); const bdName = String(formData.get('BDName') || '').trim(); - const activo = formData.get('Activo') === 'true' ? 1 : 0; + const activo = parseActivoField(formData); if (!id || !nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) { return { success: false, message: 'Todos los campos son requeridos' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('nodoSubNodo', nodoSubNodo); - req.input('rfc', rfc); - req.input('nombre', nombre); - req.input('sucursal', sucursal); - req.input('correo', correo); - req.input('serverName', serverName); - req.input('bdName', bdName); - req.input('activo', activo); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesDeDatos] - SET NodoSubNodo = @nodoSubNodo, - RFC = @rfc, - Nombre = @nombre, - Sucursal = @sucursal, - CorreoNotificacion = @correo, - ServerName = @serverName, - BDName = @bdName, - Activo = @activo - WHERE ID = @id - `); + await updateDatabaseNode(id, { + nodoSubNodo, + rfc, + nombre, + sucursal, + correo, + serverName, + bdName, + activo + }); return { success: true }; } catch (e: any) { @@ -624,14 +429,7 @@ export const actions: Actions = { return { success: false, message: 'ID requerido' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - - await req.query(` - DELETE FROM [CONTROLDESK].[dbo].[BasesDeDatos] - WHERE ID = @id - `); + await deleteDatabaseNode(id); return { success: true }; } catch (e: any) { @@ -640,21 +438,24 @@ export const actions: Actions = { } }, - // ---- Acciones para CONTROLDESK.dbo.Usuarios ---- + // ---- Acciones para a24c.portal_users (antes CONTROLDESK.dbo.Usuarios) ---- createUsuario: async ({ request }) => { try { const formData = await request.formData(); const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); - const clienteAutoridad = Number(formData.get('ClienteAutoridad')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad') ?? 0); const nombre = String(formData.get('Nombre') || '').trim(); const usuario = String(formData.get('Usuario') || '').trim(); const password = String(formData.get('Password') || ''); const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; - if (!idNodoSubNodo || !clienteAutoridad || !nombre || !usuario || !password) { + if (!Number.isFinite(idNodoSubNodo) || idNodoSubNodo <= 0 || !nombre || !usuario || !password) { return { success: false, message: 'Todos los campos obligatorios deben completarse' }; } + if (!Number.isFinite(clienteAutoridad)) { + return { success: false, message: 'Cliente autoridad inválido' }; + } // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE const passwordHash = crypto @@ -663,21 +464,14 @@ export const actions: Actions = { .digest('hex') .toUpperCase(); - const azure = await db.getAzure(); - const req = azure.request(); - req.input('idNodo', idNodoSubNodo); - req.input('clienteAut', clienteAutoridad); - req.input('nombre', nombre); - req.input('usuario', usuario); - req.input('passwordHash', passwordHash); - req.input('bdShelter', bdShelter); - - await req.query(` - INSERT INTO [CONTROLDESK].[dbo].[Usuarios] - (IDNodoSubNodo, ClienteAutoridad, Nombre, Usuario, [Password], BD_Shelter) - VALUES - (@idNodo, @clienteAut, @nombre, @usuario, @passwordHash, @bdShelter) - `); + await insertPortalUser({ + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + passwordHash, + bdShelter + }); return { success: true }; } catch (e: any) { @@ -691,53 +485,41 @@ export const actions: Actions = { const formData = await request.formData(); const id = Number(formData.get('ID')); const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); - const clienteAutoridad = Number(formData.get('ClienteAutoridad')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad') ?? 0); const nombre = String(formData.get('Nombre') || '').trim(); const usuario = String(formData.get('Usuario') || '').trim(); const password = String(formData.get('Password') || ''); const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; - if (!id || !idNodoSubNodo || !clienteAutoridad || !nombre || !usuario) { + if (!id || !Number.isFinite(idNodoSubNodo) || idNodoSubNodo <= 0 || !nombre || !usuario) { return { success: false, message: 'Todos los campos obligatorios deben completarse' }; } - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('idNodo', idNodoSubNodo); - req.input('clienteAut', clienteAutoridad); - req.input('nombre', nombre); - req.input('usuario', usuario); - req.input('bdShelter', bdShelter); + if (!Number.isFinite(clienteAutoridad)) { + return { success: false, message: 'Cliente autoridad inválido' }; + } if (password) { - // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE const passwordHash = crypto .createHash('sha512') .update(password + 'soluciones', 'utf8') .digest('hex') .toUpperCase(); - req.input('passwordHash', passwordHash); - await req.query(` - UPDATE [CONTROLDESK].[dbo].[Usuarios] - SET IDNodoSubNodo = @idNodo, - ClienteAutoridad = @clienteAut, - Nombre = @nombre, - Usuario = @usuario, - [Password] = @passwordHash, - BD_Shelter = @bdShelter - WHERE ID = @id - `); + await updatePortalUser(id, { + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + bdShelter, + passwordHash + }); } else { - await req.query(` - UPDATE [CONTROLDESK].[dbo].[Usuarios] - SET IDNodoSubNodo = @idNodo, - ClienteAutoridad = @clienteAut, - Nombre = @nombre, - Usuario = @usuario, - BD_Shelter = @bdShelter - WHERE ID = @id - `); + await updatePortalUser(id, { + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + bdShelter + }); } return { success: true }; @@ -756,14 +538,7 @@ export const actions: Actions = { return { success: false, message: 'ID requerido' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - - await req.query(` - DELETE FROM [CONTROLDESK].[dbo].[Usuarios] - WHERE ID = @id - `); + await deletePortalUser(id); return { success: true }; } catch (e: any) { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 30b8f82..5ae9bec 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -83,7 +83,7 @@ // Sub-tab dentro de "Gestión de Bases de Datos" let databasesSubTab = $state<'bases' | 'usuarios'>('bases'); - // ---- Gestión de Usuarios (tabla MSSQL CONTROLDESK.dbo.Usuarios) ---- + // ---- Gestión de usuarios portal (PostgreSQL a24c.portal_users) ---- let usuariosList = $state(untrack(() => (data.usuariosList ?? []) as any[])); let usuariosSearch = $state(''); let pageUsuarios = $state(1); @@ -237,7 +237,7 @@ if (!res.ok) return; const payload = await res.json(); - // No tocar databaseRowsLive aquí para no perder el mapeo a Azure (client_name, NodoSubNodo) + // No tocar databaseRowsLive aquí para no perder el mapeo al catálogo (client_name, NodoSubNodo) if (payload?.summaryMain) { summaryMainLive = payload.summaryMain; const raw = summaryMainLive?.total_size_gb ?? summaryMainLive?.TOTAL_SIZE_GB ?? 0; @@ -1059,7 +1059,9 @@

Almacenamiento Utilizado

{totalSizeGB} GB

-

Tamaño agregado de archivos de datos

+

+ Suma del tamaño asignado en SQL Server (datos, registro y demás archivos de cada base) +

@@ -2360,7 +2362,7 @@ {/if} - + {#if showUsuarioModal}
{/if}
- +
+ + {#if data.user?.es_admin} +
+ + +
+
+
+ groups +
+
+

Clientes (Administrativos)

+

Catálogo completo de clientes con nodo, RFC, correo y estatus

+
+
+
+ +
+
+ + +
+
+
+ badge +
+
+

Nodos y Usuarios (Asesores)

+

Usuarios cliente y autoridad por nodo

+
+
+
+
+ lock + No incluye contraseñas: se almacenan con hash bcrypt (irreversible) y son confidenciales. +
+ +
+
+ +
+ {/if} +
diff --git a/src/routes/reportes/clientes/+server.ts b/src/routes/reportes/clientes/+server.ts new file mode 100644 index 0000000..2442d07 --- /dev/null +++ b/src/routes/reportes/clientes/+server.ts @@ -0,0 +1,73 @@ +/** + * Reporte administrativo: catálogo completo de clientes (a24c.database_nodes). + * Solo admin. Sin datos sensibles (no incluye credenciales). + */ +import { listClientsCatalog } from '$lib/server/controldesk-pg'; +import { getAdminFromCookies, styleHeader, styleDataRow } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +export const GET: RequestHandler = async ({ cookies }) => { + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let clientes: any[]; + try { + clientes = await listClientsCatalog(); + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo catálogo de clientes: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Orden estable por nodo para lectura administrativa. + clientes.sort((a, b) => + String(a.NodoSubNodo ?? '').localeCompare(String(b.NodoSubNodo ?? '')) + ); + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + const ws = workbook.addWorksheet('Clientes', { views: [{ state: 'frozen', ySplit: 1 }] }); + ws.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Nombre', width: 36 }, + { header: 'Correo de Notificación', key: 'CorreoNotificacion', width: 34 }, + { header: 'Base de Datos', key: 'BDName', width: 24 }, + { header: 'Estatus', key: 'Estatus', width: 12 } + ]; + styleHeader(ws.getRow(1)); + + clientes.forEach((c, i) => { + const row = ws.addRow({ + NodoSubNodo: c.NodoSubNodo ?? '', + Nombre: c.Nombre ?? '', + CorreoNotificacion: c.CorreoNotificacion ?? '', + BDName: c.BDName ?? '', + // is_active puede venir como 1/0 o booleano según el origen. + Estatus: Number(c.Activo) === 1 || c.Activo === true ? 'Activo' : 'Inactivo' + }); + styleDataRow(row, i); + }); + + ws.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } }; + + const buffer = await workbook.xlsx.writeBuffer(); + const fechaHoy = new Date().toISOString().slice(0, 10); + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="reporte_clientes_${fechaHoy}.xlsx"`, + 'Cache-Control': 'no-store' + } + }); +}; diff --git a/src/routes/reportes/usuarios/+server.ts b/src/routes/reportes/usuarios/+server.ts new file mode 100644 index 0000000..34d491e --- /dev/null +++ b/src/routes/reportes/usuarios/+server.ts @@ -0,0 +1,121 @@ +/** + * Reporte para asesores: nodos con sus usuarios de portal (cliente y autoridad). + * Solo admin. NO incluye contraseñas: portal_users.password_hash es bcrypt + * (irreversible) y las credenciales de cliente son confidenciales. + * + * Dos hojas: + * - "Usuarios": una fila por usuario (cliente/autoridad) con su nodo. + * - "Nodos": catálogo de nodos con el conteo de usuarios cliente/autoridad. + */ +import { listPortalUsersWithNode, listClientsCatalog } from '$lib/server/controldesk-pg'; +import { getAdminFromCookies, styleHeader, styleDataRow } from '$lib/server/report-excel'; +import type { RequestHandler } from './$types'; +import ExcelJS from 'exceljs'; + +// is_authority_client == 1 → Autoridad; cualquier otro valor → Cliente (paridad con la UI). +function tipoUsuario(clienteAutoridad: unknown): 'Autoridad' | 'Cliente' { + return Number(clienteAutoridad) === 1 ? 'Autoridad' : 'Cliente'; +} + +export const GET: RequestHandler = async ({ cookies }) => { + const admin = await getAdminFromCookies(cookies); + if (!admin) { + return new Response(JSON.stringify({ error: 'No autorizado: requiere sesión de administrador.' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let usuarios: any[]; + let nodos: any[]; + try { + [usuarios, nodos] = await Promise.all([listPortalUsersWithNode(), listClientsCatalog()]); + } catch (e: any) { + return new Response(JSON.stringify({ error: `Error obteniendo usuarios/nodos: ${e.message}` }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'TransmitirAS - Panel CPANEL'; + workbook.created = new Date(); + workbook.modified = new Date(); + + // ── Hoja: Usuarios (cliente/autoridad) ───────────────────────────────── + const wsUsuarios = workbook.addWorksheet('Usuarios', { views: [{ state: 'frozen', ySplit: 1 }] }); + wsUsuarios.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Cliente', width: 32 }, + { header: 'Tipo', key: 'Tipo', width: 12 }, + { header: 'Usuario', key: 'Usuario', width: 24 }, + { header: 'Nombre', key: 'Nombre', width: 30 }, + { header: 'BD Shelter', key: 'BD_Shelter', width: 20 }, + { header: 'Base de Datos', key: 'BDName', width: 22 } + ]; + styleHeader(wsUsuarios.getRow(1)); + + usuarios.forEach((u, i) => { + const row = wsUsuarios.addRow({ + NodoSubNodo: u.NodoSubNodo ?? '—', + Cliente: u.Cliente ?? '', + Tipo: tipoUsuario(u.ClienteAutoridad), + Usuario: u.Usuario ?? '', + Nombre: u.Nombre ?? '', + BD_Shelter: u.BD_Shelter ?? '', + BDName: u.BDName ?? '' + }); + styleDataRow(row, i); + }); + wsUsuarios.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 7 } }; + + // ── Hoja: Nodos (con conteo de usuarios por tipo) ─────────────────────── + const conteo = new Map(); + for (const u of usuarios) { + // Agrupar por nodo usando la clave de nodo (los usuarios sin nodo quedan fuera del conteo por nodo). + const key = String(u.NodoSubNodo ?? ''); + if (!key) continue; + const acc = conteo.get(key) ?? { cliente: 0, autoridad: 0 }; + if (tipoUsuario(u.ClienteAutoridad) === 'Autoridad') acc.autoridad += 1; + else acc.cliente += 1; + conteo.set(key, acc); + } + + nodos.sort((a, b) => String(a.NodoSubNodo ?? '').localeCompare(String(b.NodoSubNodo ?? ''))); + + const wsNodos = workbook.addWorksheet('Nodos', { views: [{ state: 'frozen', ySplit: 1 }] }); + wsNodos.columns = [ + { header: 'Nodo/SubNodo', key: 'NodoSubNodo', width: 22 }, + { header: 'Cliente', key: 'Nombre', width: 36 }, + { header: 'Base de Datos', key: 'BDName', width: 24 }, + { header: 'Estatus', key: 'Estatus', width: 12 }, + { header: 'Usuarios Cliente', key: 'UsuariosCliente', width: 16 }, + { header: 'Usuarios Autoridad', key: 'UsuariosAutoridad', width: 18 } + ]; + styleHeader(wsNodos.getRow(1)); + + nodos.forEach((n, i) => { + const c = conteo.get(String(n.NodoSubNodo ?? '')) ?? { cliente: 0, autoridad: 0 }; + const row = wsNodos.addRow({ + NodoSubNodo: n.NodoSubNodo ?? '', + Nombre: n.Nombre ?? '', + BDName: n.BDName ?? '', + Estatus: Number(n.Activo) === 1 || n.Activo === true ? 'Activo' : 'Inactivo', + UsuariosCliente: c.cliente, + UsuariosAutoridad: c.autoridad + }); + styleDataRow(row, i); + }); + wsNodos.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 6 } }; + + const buffer = await workbook.xlsx.writeBuffer(); + const fechaHoy = new Date().toISOString().slice(0, 10); + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="reporte_nodos_usuarios_${fechaHoy}.xlsx"`, + 'Cache-Control': 'no-store' + } + }); +}; -- 2.49.1 From 1b198953d4161b1aaca2743bd2367976dfe9da0d Mon Sep 17 00:00:00 2001 From: hreyes Date: Thu, 2 Jul 2026 14:57:45 +0000 Subject: [PATCH 18/22] feature/asignacion-masiva-restauradores (#13) integraciones de panel de respaldos fallidos, asignacion masiva de restaurador, trackeo de baks para nuevo sistema de cloudrestore Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/PANEL_BASES_ANEXO24/pulls/13 Co-authored-by: hreyes Co-committed-by: hreyes --- .claude/settings.local.json | 3 +- .env.example | 6 +- database/schema.sql | 10 + package-lock.json | 170 +- package.json | 4 +- src/lib/components/AppShell.svelte | 243 +++ src/lib/components/Navbar.svelte | 52 - src/lib/components/Sidebar.svelte | 123 -- src/lib/restore-suggest.test.ts | 171 ++ src/lib/restore-suggest.ts | 165 ++ src/lib/server/api-error.ts | 2 +- src/lib/server/backup-files.test.ts | 117 ++ src/lib/server/backup-files.ts | Bin 0 -> 7861 bytes src/lib/server/controldesk-pg.ts | 487 ++++- src/lib/server/email-service.test.ts | 66 + src/lib/server/email-service.ts | 158 +- src/lib/server/restore-fetch.ts | 103 ++ src/lib/server/restore-inventory.ts | 131 ++ src/routes/+page.server.ts | 121 +- src/routes/+page.svelte | 453 ++--- src/routes/api/alerts/send/+server.ts | 45 +- src/routes/api/alerts/send/server.test.ts | 173 ++ src/routes/backup/+server.ts | 58 +- src/routes/reportes/+page.svelte | 9 +- .../servidores-restauracion/+page.server.ts | 129 +- .../servidores-restauracion/+page.svelte | 1634 +++++++++++++++-- .../node-sizes/+server.ts | 45 + .../restore-logs/+server.ts | 42 + src/routes/usuarios/+page.svelte | 100 +- src/test/env-private-stub.ts | 8 + vitest.config.ts | 17 +- 31 files changed, 3956 insertions(+), 889 deletions(-) create mode 100644 src/lib/components/AppShell.svelte delete mode 100644 src/lib/components/Navbar.svelte delete mode 100644 src/lib/components/Sidebar.svelte create mode 100644 src/lib/restore-suggest.test.ts create mode 100644 src/lib/restore-suggest.ts create mode 100644 src/lib/server/backup-files.test.ts create mode 100644 src/lib/server/backup-files.ts create mode 100644 src/lib/server/email-service.test.ts create mode 100644 src/lib/server/restore-fetch.ts create mode 100644 src/lib/server/restore-inventory.ts create mode 100644 src/routes/api/alerts/send/server.test.ts create mode 100644 src/routes/servidores-restauracion/node-sizes/+server.ts create mode 100644 src/routes/servidores-restauracion/restore-logs/+server.ts create mode 100644 src/test/env-private-stub.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0264318..5a2a05a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(xargs ls:*)", "Bash(npm install:*)", "Bash(node -e \"require\\(''''c:/CPANEL/PANEL_BASES_ANEXO24/node_modules/exceljs''''\\)\")", - "Bash(docker-compose up:*)" + "Bash(docker-compose up:*)", + "Bash(npx svelte-check *)" ] } } diff --git a/.env.example b/.env.example index 74c1405..c4933c4 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,11 @@ DB_POSTGRES_DB=CONTROLDESK # JWT Secret (cambiar en producción) JWT_SECRET=change-this-secret-in-production-please-use-a-long-random-string -# Ruta de backups +# Ruta de respaldos (LEGACY / fallback): carpeta única leída por la vista "Respaldos +# Almacenados" y por /backup?file=... cuando no se especifica restaurador. Las vistas nuevas +# "Respaldos Restaurados" y "Restores Fallidos" resuelven la carpeta de CADA restaurador de +# forma relativa (derivada de la Entrada que reporta cada uno), sin usar esta variable. +# Apúntala a una carpeta de procesados accesible por filesystem (local o share) si usas el modo legacy. BACKUP_PATH=D:/BackupSFTP/ # SMTP para envío de avisos de alertas críticas diff --git a/database/schema.sql b/database/schema.sql index bc5c6c3..241fd90 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -85,6 +85,13 @@ CREATE TABLE IF NOT EXISTS a24c.restore_targets ( ssh_password_encrypted TEXT, -- credencial SSH cifrada (AES-256-GCM) remote_inbox_path VARCHAR(500), -- ruta en el server donde se sube el .bak y se restaura (C:\RestoreInbox) notes TEXT, + -- Características de hardware (opcionales, capturadas a mano). Se usan para distribuir bases + -- por capacidad; el disco es la capacidad que manda. NULL = sin capturar. + os VARCHAR(50), -- Sistema operativo (Linux/Windows/…) + ram_gb INTEGER, -- RAM en GB + disk_gb INTEGER, -- Disco en GB + location VARCHAR(255), -- Ubicación (ej: Kansas City, United States) + size_category VARCHAR(20), -- Etiqueta: Chico | Mediano | Grande created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -117,6 +124,9 @@ CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_restored_at ON a24c.restore_job_logs (restored_at); CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_status ON a24c.restore_job_logs (status); +-- Bitácora por servidor en el panel: últimas N restauraciones de un target. +CREATE INDEX IF NOT EXISTS idx_a24c_restore_job_logs_target_date + ON a24c.restore_job_logs (restore_target_id, restored_at DESC); COMMENT ON TABLE a24c.restore_targets IS 'Servidores SQL Server destino de restauración (CloudRestoreAS). Contraseña cifrada AES-256-GCM.'; COMMENT ON TABLE a24c.restore_job_logs IS 'Bitácora de restauraciones reportadas por CloudRestoreAS.'; diff --git a/package-lock.json b/package-lock.json index e77ac31..0c4f975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", "nodemailer": "^8.0.9", - "pg": "^8.18.0" + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.3.1", @@ -29,6 +30,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -1604,6 +1606,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", @@ -1967,6 +2006,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2104,6 +2152,15 @@ "node": ">= 18" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -2324,6 +2381,12 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", @@ -2341,6 +2404,15 @@ "node": ">=0.2.0" } }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -2588,6 +2660,35 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2631,6 +2732,20 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -5114,6 +5229,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -6434,6 +6556,40 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz", + "integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "ssh2": "^1.16.0" + }, + "engines": { + "node": ">=18.20.4" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7040,6 +7196,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -7127,6 +7289,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 8f44491..d1eb68e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^8.0.0", "@types/pg": "^8.16.0", + "@types/ssh2-sftp-client": "^9.0.6", "autoprefixer": "^10.4.24", "postcss": "^8.5.6", "svelte": "^5.0.0-next.1", @@ -41,7 +42,8 @@ "jsonwebtoken": "^9.0.3", "mssql": "^10.0.2", "nodemailer": "^8.0.9", - "pg": "^8.18.0" + "pg": "^8.18.0", + "ssh2-sftp-client": "^12.1.1" }, "type": "module" } diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..10f21ef --- /dev/null +++ b/src/lib/components/AppShell.svelte @@ -0,0 +1,243 @@ + + +{#snippet navLink(item: NavItem, isCollapsed: boolean)} + + {item.icon} + {#if !isCollapsed}{item.label}{/if} + +{/snippet} + +{#snippet sidebarInner(isCollapsed: boolean, isMobile: boolean)} +
+ +
+
+
+ Aduanasoft +
+ {#if !isCollapsed || isMobile} +
+

Aduanasoft

+

Bases de datos

+
+ {/if} +
+ {#if isMobile} + + {:else} + + {/if} +
+ + + + + +
+ {#if !isCollapsed || isMobile} +
+
+
+ person +
+
+

{displayName}

+ {#if currentUser?.email} +

{currentUser.email}

+ {/if} +
+
+
+ +
+
Aduanasoft · v1.0.0
+
+ {:else} +
+
+ person +
+
+ +
+
+ {/if} +
+
+{/snippet} + +
+ + + + + {#if mobileOpen} + + + {/if} + + +
+ +
+
+ +

{title}

+
+
+ {#if headerActions} + {@render headerActions()} + {/if} + +
+
+ + +
+ {@render children()} +
+
+
diff --git a/src/lib/components/Navbar.svelte b/src/lib/components/Navbar.svelte deleted file mode 100644 index d3d007a..0000000 --- a/src/lib/components/Navbar.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte deleted file mode 100644 index a40971f..0000000 --- a/src/lib/components/Sidebar.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - - diff --git a/src/lib/restore-suggest.test.ts b/src/lib/restore-suggest.test.ts new file mode 100644 index 0000000..eb30248 --- /dev/null +++ b/src/lib/restore-suggest.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { + categorize, + suggestForServer, + distributeGlobal, + type Candidate +} from './restore-suggest'; + +describe('categorize', () => { + it('clasifica por límites con bordes inclusivos hacia la menor', () => { + expect(categorize(1)).toBe('Chica'); + expect(categorize(100)).toBe('Chica'); // borde chico + expect(categorize(101)).toBe('Mediana'); + expect(categorize(1024)).toBe('Mediana'); // borde mediano + expect(categorize(1025)).toBe('Grande'); + expect(categorize(500000)).toBe('Grande'); + }); + + it('respeta límites personalizados', () => { + expect(categorize(200, { chicoMb: 200, medianoMb: 2000 })).toBe('Chica'); + expect(categorize(201, { chicoMb: 200, medianoMb: 2000 })).toBe('Mediana'); + expect(categorize(2001, { chicoMb: 200, medianoMb: 2000 })).toBe('Grande'); + }); +}); + +const cand = (nodeId: number, sizeMb: number, categoria: Candidate['categoria']): Candidate => ({ + nodeId, + sizeMb, + categoria +}); + +describe('suggestForServer', () => { + it('no selecciona nada si el disco es nulo o cero', () => { + const candidates = [cand(1, 50, 'Chica')]; + expect(suggestForServer({ candidates, diskGb: 0 })).toEqual([]); + expect(suggestForServer({ candidates, diskGb: null })).toEqual([]); + }); + + it('respeta el % del disco (no excede el presupuesto)', () => { + // disco 1 GB = 1024 MB, pct 80% => 819.2 MB de presupuesto. + const candidates = [ + cand(1, 500, 'Mediana'), + cand(2, 400, 'Mediana'), + cand(3, 400, 'Mediana') + ]; + const picked = suggestForServer({ candidates, diskGb: 1, pct: 80 }); + const sum = candidates + .filter((c) => picked.includes(c.nodeId)) + .reduce((a, c) => a + c.sizeMb, 0); + expect(sum).toBeLessThanOrEqual(1024 * 0.8); + // Debe caber 500 + una de 400 (900 > 819) -> en realidad 500+400=900 excede; solo 500+... + // 500 cabe; luego 400 -> 900 > 819 no cabe; otra 400 tampoco. Selecciona solo la de 500. + expect(picked).toEqual([1]); + }); + + it('balancea entre categorías (un poco de todo) antes de agotar una sola', () => { + const candidates = [ + cand(1, 900, 'Grande'), + cand(2, 800, 'Grande'), + cand(3, 300, 'Mediana'), + cand(4, 200, 'Mediana'), + cand(5, 50, 'Chica'), + cand(6, 40, 'Chica') + ]; + // disco 10 GB, pct 100 => 10240 MB, cabe todo. + const picked = suggestForServer({ candidates, diskGb: 10, pct: 100 }); + expect(new Set(picked)).toEqual(new Set([1, 2, 3, 4, 5, 6])); + // Primera ronda toma la mayor de cada categoría: Grande(900), Mediana(300), Chica(50). + expect(picked.slice(0, 3)).toEqual([1, 3, 5]); + }); + + it('toma una base menor de la categoría si la mayor no cabe', () => { + const candidates = [cand(1, 1000, 'Grande'), cand(2, 100, 'Grande')]; + // presupuesto 819 MB: la de 1000 no cabe, la de 100 sí. + const picked = suggestForServer({ candidates, diskGb: 1, pct: 80 }); + expect(picked).toEqual([2]); + }); + + it('excluye tamaños no usables (NaN, 0, negativos)', () => { + const candidates = [ + cand(1, Number.NaN, 'Grande'), + cand(2, 0, 'Chica'), + cand(3, -50, 'Mediana'), + cand(4, 200, 'Mediana') + ]; + const picked = suggestForServer({ candidates, diskGb: 10, pct: 100 }); + expect(picked).toEqual([4]); + }); + + it('devuelve [] si el disco es inválido (NaN/Infinity)', () => { + const candidates = [cand(1, 50, 'Chica')]; + expect(suggestForServer({ candidates, diskGb: Number.NaN })).toEqual([]); + expect(suggestForServer({ candidates, diskGb: Infinity })).toEqual([]); + }); +}); + +describe('distributeGlobal', () => { + it('ignora servidores sin disco y deja todo sin asignar', () => { + const candidates = [cand(1, 100, 'Chica')]; + const res = distributeGlobal({ servers: [{ id: 1, diskGb: null }], candidates }); + expect(res).toEqual({ 1: null }); + }); + + it('nunca excede el presupuesto de cada servidor ni asigna dos veces', () => { + const candidates = [ + cand(1, 700, 'Grande'), + cand(2, 700, 'Grande'), + cand(3, 700, 'Grande'), + cand(4, 700, 'Grande') + ]; + // Dos servidores de 1 GB (819 MB @80%): cada uno solo admite una de 700. + const servers = [ + { id: 10, diskGb: 1 }, + { id: 20, diskGb: 1 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 80 }); + const perServer: Record = {}; + for (const c of candidates) { + const s = res[c.nodeId]; + if (s != null) perServer[s] = (perServer[s] ?? 0) + c.sizeMb; + } + for (const total of Object.values(perServer)) { + expect(total).toBeLessThanOrEqual(1024 * 0.8); + } + // Solo caben 2 bases (una por servidor); las otras 2 quedan null. + const assigned = candidates.filter((c) => res[c.nodeId] != null); + expect(assigned.length).toBe(2); + }); + + it('reparte proporcional al disco (el más grande recibe más)', () => { + const candidates = Array.from({ length: 6 }, (_, i) => cand(i + 1, 100, 'Chica')); + // Server A tiene 3x el disco de B => debe recibir más bases. + const servers = [ + { id: 1, diskGb: 3 }, + { id: 2, diskGb: 1 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 100 }); + const countA = candidates.filter((c) => res[c.nodeId] === 1).length; + const countB = candidates.filter((c) => res[c.nodeId] === 2).length; + expect(countA).toBeGreaterThan(countB); + expect(countA + countB).toBe(6); + }); + + it('nunca asigna a un servidor sin disco usable', () => { + const candidates = [cand(1, 100, 'Chica'), cand(2, 100, 'Chica')]; + const servers = [ + { id: 1, diskGb: null }, + { id: 2, diskGb: Number.NaN }, + { id: 3, diskGb: 10 } + ]; + const res = distributeGlobal({ servers, candidates, pct: 100 }); + expect(res[1]).toBe(3); + expect(res[2]).toBe(3); + // ningún nodo terminó en 1 ni 2 + expect(Object.values(res).every((v) => v === 3)).toBe(true); + }); + + it('ignora candidatos sin tamaño usable y deja el resto coherente', () => { + const candidates = [cand(1, 0, 'Chica'), cand(2, Number.NaN, 'Grande'), cand(3, 100, 'Chica')]; + const res = distributeGlobal({ servers: [{ id: 9, diskGb: 10 }], candidates, pct: 100 }); + expect(res[1]).toBeNull(); + expect(res[2]).toBeNull(); + expect(res[3]).toBe(9); + }); + + it('base más grande que cualquier disco queda sin ubicar (null)', () => { + const candidates = [cand(1, 5000, 'Grande')]; + const res = distributeGlobal({ servers: [{ id: 1, diskGb: 1 }], candidates, pct: 80 }); + expect(res[1]).toBeNull(); + }); +}); diff --git a/src/lib/restore-suggest.ts b/src/lib/restore-suggest.ts new file mode 100644 index 0000000..3836cca --- /dev/null +++ b/src/lib/restore-suggest.ts @@ -0,0 +1,165 @@ +/** + * Lógica pura de distribución de bases entre servidores de restauración por capacidad. + * Sin dependencias de SvelteKit ni de servidor: se usa en el cliente y se prueba con Vitest. + * + * Regla de tamaños (misma clasificación que el query provisto por el negocio): + * Chica <= chicoMb (default 100 MB) + * Mediana <= medianoMb (default 1024 MB) + * Grande en otro caso + * + * El disco es la capacidad que manda: cada servidor se llena hasta `pct`% de su disco. + */ + +export type SizeCategory = 'Chica' | 'Mediana' | 'Grande'; + +/** Orden de reparto para el balanceo "un poco de todo" (mayor impacto primero). */ +export const CATEGORY_ORDER: readonly SizeCategory[] = ['Grande', 'Mediana', 'Chica'] as const; + +export const DEFAULT_LIMITS = { chicoMb: 100, medianoMb: 1024 } as const; +export const DEFAULT_PCT = 80; +const MB_PER_GB = 1024; + +export interface Candidate { + nodeId: number; + sizeMb: number; + categoria: SizeCategory; +} + +export interface ServerCapacity { + id: number; + diskGb: number | null | undefined; +} + +/** Clasifica un tamaño en MB según los límites (bordes inclusivos hacia la categoría menor). */ +export function categorize( + sizeMb: number, + limits: { chicoMb?: number; medianoMb?: number } = {} +): SizeCategory { + const chicoMb = limits.chicoMb ?? DEFAULT_LIMITS.chicoMb; + const medianoMb = limits.medianoMb ?? DEFAULT_LIMITS.medianoMb; + if (sizeMb <= chicoMb) return 'Chica'; + if (sizeMb <= medianoMb) return 'Mediana'; + return 'Grande'; +} + +function budgetMbForDisk(diskGb: number, pct: number): number { + return diskGb * MB_PER_GB * (pct / 100); +} + +/** Disco utilizable: número finito y positivo. */ +export function hasUsableDisk(diskGb: number | null | undefined): diskGb is number { + return typeof diskGb === 'number' && Number.isFinite(diskGb) && diskGb > 0; +} + +/** Tamaño utilizable: número finito y positivo (excluye NaN, 0 y negativos). */ +export function isUsableSize(sizeMb: number | null | undefined): sizeMb is number { + return typeof sizeMb === 'number' && Number.isFinite(sizeMb) && sizeMb > 0; +} + +function bucketsByCategory(candidates: Candidate[]): Record { + const buckets: Record = { Grande: [], Mediana: [], Chica: [] }; + for (const c of candidates) { + // Ignora candidatos sin tamaño usable (base no medible: SQL caído, sin métrica, 0). + if (!isUsableSize(c.sizeMb)) continue; + if (buckets[c.categoria]) buckets[c.categoria].push(c); + } + // Mayor a menor dentro de cada categoría (empaca primero lo grande). + for (const cat of CATEGORY_ORDER) buckets[cat].sort((a, b) => b.sizeMb - a.sizeMb); + return buckets; +} + +/** + * Selecciona, para UN servidor, un subconjunto balanceado de `candidates` que quepa en + * `pct`% del disco. Reparte por rondas entre categorías (Grande→Mediana→Chica) tomando en + * cada ronda la mayor de cada categoría que aún quepa. Devuelve los nodeId seleccionados. + */ +export function suggestForServer(opts: { + candidates: Candidate[]; + diskGb: number | null | undefined; + pct?: number; +}): number[] { + const { candidates } = opts; + const pct = opts.pct ?? DEFAULT_PCT; + if (!hasUsableDisk(opts.diskGb)) return []; + const budgetMb = budgetMbForDisk(opts.diskGb, pct); + + const buckets = bucketsByCategory(candidates); + const selected: number[] = []; + let usedMb = 0; + let progressed = true; + while (progressed) { + progressed = false; + for (const cat of CATEGORY_ORDER) { + const bucket = buckets[cat]; + // La mayor que aún quepa (sorted desc → primera que cumpla). + const idx = bucket.findIndex((c) => usedMb + c.sizeMb <= budgetMb); + if (idx >= 0) { + const [item] = bucket.splice(idx, 1); + selected.push(item.nodeId); + usedMb += item.sizeMb; + progressed = true; + } + } + } + return selected; +} + +/** + * Reparte `candidates` entre varios `servers` de forma balanceada (un poco de cada categoría) + * y proporcional al disco (el servidor con más capacidad libre recibe primero). Respeta el + * `pct`% del disco de cada servidor y nunca asigna una base a más de un servidor. + * Devuelve un mapa nodeId → serverId (o null si no cupo en ningún servidor). + */ +export function distributeGlobal(opts: { + servers: ServerCapacity[]; + candidates: Candidate[]; + pct?: number; +}): Record { + const pct = opts.pct ?? DEFAULT_PCT; + const state = opts.servers + .filter((s) => hasUsableDisk(s.diskGb)) + .map((s) => ({ id: s.id, budgetMb: budgetMbForDisk(s.diskGb as number, pct), usedMb: 0 })); + + const assignment: Record = {}; + for (const c of opts.candidates) assignment[c.nodeId] = null; + if (state.length === 0) return assignment; + + const buckets = bucketsByCategory(opts.candidates); + let progressed = true; + while (progressed) { + progressed = false; + for (const cat of CATEGORY_ORDER) { + const bucket = buckets[cat]; + if (bucket.length === 0) continue; + // Mayor base de la categoría que quepa en algún servidor + su mejor servidor (más libre). + let pickIdx = -1; + let pickServer: (typeof state)[number] | null = null; + for (let i = 0; i < bucket.length; i++) { + let best: (typeof state)[number] | null = null; + let bestFree = -1; + for (const st of state) { + const free = st.budgetMb - st.usedMb; + if (bucket[i].sizeMb <= free && free > bestFree) { + best = st; + bestFree = free; + } + } + if (best) { + pickIdx = i; + pickServer = best; + break; + } + } + if (pickIdx >= 0 && pickServer) { + const [item] = bucket.splice(pickIdx, 1); + pickServer.usedMb += item.sizeMb; + assignment[item.nodeId] = pickServer.id; + progressed = true; + } else { + // Ninguna base de esta categoría cabe en ningún servidor: quedan sin asignar. + bucket.length = 0; + } + } + } + return assignment; +} diff --git a/src/lib/server/api-error.ts b/src/lib/server/api-error.ts index d5978e5..ff8000c 100644 --- a/src/lib/server/api-error.ts +++ b/src/lib/server/api-error.ts @@ -10,7 +10,7 @@ export function newTraceId(): string { } export function errorJson( - code: 400 | 401 | 403 | 404 | 409 | 422 | 500, + code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503, message: string, traceId: string ) { diff --git a/src/lib/server/backup-files.test.ts b/src/lib/server/backup-files.test.ts new file mode 100644 index 0000000..8b9988d --- /dev/null +++ b/src/lib/server/backup-files.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { listBackupFiles, deriveSiblingFolder } from './backup-files'; + +let root: string; + +async function writeFile(rel: string, bytes: number, mtime: Date): Promise { + const full = path.join(root, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, Buffer.alloc(bytes, 'x')); + await fs.utimes(full, mtime, mtime); +} + +const NEW = new Date('2026-07-01T12:00:00Z'); +const MID = new Date('2026-07-01T10:00:00Z'); +const OLD = new Date('2026-06-30T09:00:00Z'); + +beforeAll(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-files-')); + // Carpeta de fecha reciente: un ZIP simple y un multipart de 3 partes. + await writeFile('2026-07-01/NEW.ZIP', 200, NEW); + await writeFile('2026-07-01/SPLIT.zip.001', 100, MID); + await writeFile('2026-07-01/SPLIT.zip.002', 100, MID); + await writeFile('2026-07-01/SPLIT.zip.003', 50, MID); + // Carpeta de fecha anterior: un ZIP y un archivo aún más profundo (nivel 3). + await writeFile('2026-06-30/OLD.ZIP', 300, OLD); + await writeFile('2026-06-30/nested/TOODEEP.ZIP', 10, OLD); +}); + +afterAll(async () => { + await fs.rm(root, { recursive: true, force: true }); +}); + +describe('listBackupFiles', () => { + it('recorre subcarpetas de fecha y devuelve relPath en formato POSIX', async () => { + const { files } = await listBackupFiles(root); + const byName = new Map(files.map((f) => [f.name, f])); + expect(byName.get('NEW.ZIP')?.relPath).toBe('2026-07-01/NEW.ZIP'); + expect(byName.get('OLD.ZIP')?.relPath).toBe('2026-06-30/OLD.ZIP'); + }); + + it('colapsa multipart en una sola entrada sumando tamaños y apuntando a la parte .001', async () => { + const { files } = await listBackupFiles(root); + const split = files.find((f) => f.name === 'SPLIT.zip'); + expect(split).toBeDefined(); + expect(split?.parts).toBe(3); + expect(split?.sizeBytes).toBe(250); + expect(split?.relPath).toBe('2026-07-01/SPLIT.zip.001'); + }); + + it('ordena por fecha de modificación descendente (más reciente primero)', async () => { + const { files } = await listBackupFiles(root); + const order = files.map((f) => f.name); + // NEW (12:00) > SPLIT (10:00) > OLD (09:00). TOODEEP excluido por profundidad. + expect(order).toEqual(['NEW.ZIP', 'SPLIT.zip', 'OLD.ZIP']); + }); + + it('respeta maxDepth: excluye archivos más profundos que la subcarpeta de fecha', async () => { + const shallow = await listBackupFiles(root, { maxDepth: 2 }); + expect(shallow.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(false); + + const deep = await listBackupFiles(root, { maxDepth: 3 }); + expect(deep.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(true); + }); + + it('recorta con maxFiles conservando la carpeta de fecha más reciente y marca truncated', async () => { + const { files, truncated } = await listBackupFiles(root, { maxFiles: 1 }); + expect(truncated).toBe(true); + expect(files.length).toBeGreaterThanOrEqual(1); + expect(files.every((f) => f.relPath.startsWith('2026-07-01/'))).toBe(true); + }); + + it('no recorta cuando el tope es holgado', async () => { + const { truncated } = await listBackupFiles(root, { maxFiles: 1000 }); + expect(truncated).toBe(false); + }); + + it('lanza si la carpeta raíz no existe', async () => { + await expect(listBackupFiles(path.join(root, 'no-existe'))).rejects.toThrow(); + }); +}); + +describe('deriveSiblingFolder', () => { + it('deriva la hermana en rutas Windows (backslash)', () => { + expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Procesados')).toBe( + 'D:\\CloudRestore\\Procesados' + ); + expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Fallados')).toBe( + 'D:\\CloudRestore\\Fallados' + ); + }); + + it('deriva la hermana en rutas POSIX', () => { + expect(deriveSiblingFolder('/mnt/x/Entrada', 'Procesados')).toBe('/mnt/x/Procesados'); + }); + + it('tolera separador final', () => { + expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada\\', 'Procesados')).toBe( + 'D:\\CloudRestore\\Procesados' + ); + expect(deriveSiblingFolder('/mnt/x/Entrada/', 'Fallados')).toBe('/mnt/x/Fallados'); + }); + + it('funciona con rutas en la raíz del disco y UNC', () => { + expect(deriveSiblingFolder('D:\\sftp', 'Procesados')).toBe('D:\\Procesados'); + expect(deriveSiblingFolder('\\\\srv\\share\\Entrada', 'Procesados')).toBe( + '\\\\srv\\share\\Procesados' + ); + }); + + it('sin separador devuelve el nombre hermano como fallback', () => { + expect(deriveSiblingFolder('Entrada', 'Procesados')).toBe('Procesados'); + expect(deriveSiblingFolder('', 'Procesados')).toBe('Procesados'); + }); +}); diff --git a/src/lib/server/backup-files.ts b/src/lib/server/backup-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..09e622cd13db6176a1743fbc8f2e14f6855b3c0d GIT binary patch literal 7861 zcma)B-EP~+74Eg4Vrm31<;awsqPu{c#BgF8Yt(m}nJPV1t~6F{pNYIuH>N8}D;;fBO31N2t5klYr4>~!#ZWrc6%#B} zB;fn~6Cp|L;KZ6lIq2+qFaLy}wZp@;ot@Xcfrwl+tko=x6(g05WhWN9nJ8oqA*mXY zX^F8`Spgx!%7@P%ah)WOjGahvy8HIq)3g0QY~vX##f`~suvUtRaZuNa3=XQj*GKWGNYvRFewfZ2RW` zx(2_o(!4<}dGhch8JuW^a5@OB9hi&4>WQ+(JL-oD;c~iJP)dAN1QVw$Vwf4hqB!*A zDE7x8EY)yyDZXC&UKb)7QYW5qT(R)??U}h=^L2=|6=eiFbo7yp2E4o#Lp zpA;}i;bxD;AF79ciLp!&F?di6N{h{8snS6#_GF?njZ`Fz6}D1H6}SRdtj4G4KmYs+ z*e)M_%u@+)itV(LxpZb!a&GpP^aRl_#mI?~H50Ltn^g5ju2m}rZ_9FU@ zyG!bhC1^mxj-aJ@Ba;tRK?vMMDDK6lC4^V>R#(Ll$CJ|;_GLB8h^UCw`&cX=9q$|? z&@mEx8M%5wNe6^_RCMdq_klIBSnaXoTuxM9AR~2tz1i$_TEQz;WwJy>5eOi|Cph`g zDN1N^^U3kq{&$T6FVTGnG_l7CSYqHk#F( zyS3vR`8_Wm5j ztCu9Ia~VgM>8Iaxr=4d#4J~F=bt-Qu6v4GmCij!M_e93PH=sDGHmK{=REU8!rW_C& zsLbDhr2r6=>QvoS3Y`SE4-KjqH@?y8g6FxeQGIGW#dY!k;Y3F32JKW(kf1U_AEbww z+GMH9S~ORn9#CD=2XVm$tCw^YR>OzJd96t8h&rvhWr8+b`32TplU&+|Tc#2e1h9il zuyF&VP)$NDknTQD1adW@6lSo^ih_%nqscinjduBpDTo{v3E-9ZG+pZS1=62N(#zQj zpBS%0_&hZ0Oa=rkqLQ4Sv;YZ#|U5ZtV{WPcX6P<-2V1ZEUd{{*|I z;mbB=Z;VcBoazxyC*;zZ13F*{a$@i(h?tuJO-%1Qw5aaPRyy&Hwg`I-vRR8o;l&M$ z#{f4U=#h)9k}3QZ;eQv_-0E^n9yq@*NIXSSOc6_ZdI|z4ld*_YCnObJC573vjwr|0 z+zPM^aOz8}&KvF_x^Cf(U}(|&STtfNFQ5xzCF(?m+#x#Nk~Rp&lh7GmHPnMZE5W?w zb!?{|Lk`9UvtxWm8yZDvN4$D1(pWn(HR=TWu)$f24;z*PBO06HcdA@MEjhBPPx{X{ zXwZXeJIFnpcYIETJg=^Tx)X-jww?Ro2FdfKy-z1DsGGTH2_wb!Igs z%&%QJLmw3izfZFoj1w1?8%OgBH9bgaPK?uc5j!oMV^pAQHm&11hpeZcrcc4X;q&~0 zR?mVZ^sUjQPJfc)07amf~n2VOQfQLjT=;03~MH^KM73dKz z@Q6o77m)y~i|68JsuO(JR3|Q@t$6<;%aX2BDmip2ee0X8aOj#fTu5V{Dp1gAI1>g6 z2s?8{Yi`n}4Q}(B*5}Du*s3&WYi6C%E;hPH1K|6svu6u)vY*`#Y=zAB_gM( zSX~4+Fjn7)plA_pWq1&Qi&EA9QxB%YF%Q1}L)iHRcOTND&N1h<^+ zpMVPW1E9D6y?%n#v$962)_QP?0tvVJ2-5@f6Si@Fn^|aE8Y2~`dWQq_1xdYXsy4~+sK}U=hrptdq_17wKSSn9+!$JD!8f_H9vPAQFx1?TXti^v`SQj ziS`5tU=jETRxFssd2-W5^`F-$K&34RN51QFyukVaG_{W&d3jiCi;1BiOouiORzEHLd+$yX*9>wKwK&%`paCy^^@@>!$LnEUNuxX zE+Bj9&5f~0M1E7lJ-oa}apgAycn86=Y#P{N-c?HUa*#Mb2ghiB*KaZCwcg6$%O2L# z3z`8*cbEnQB^^lnJ<|b>tvDQj-?$;xco*cy!GoRtWqX&)dI#~-U;jqZ78YPa5MBpi z=D6)1K9ec_br0+eLzg+fB;F~F56Cip_e1hecMrDD>FrNM2W*RhGA5z=y+k+TnGW@i zyfV;vQI+rfTa|&pOwGRw;-e3Fm4t0y=MP^n>q^j1W~~8q^hb>`&#e=K%K_-o&!gct zAo}Q7RCu|8*C3e5Q==S|$}H zZPGq`_HPTkJ@xJ+Cx8P38~!)W}fsa+A%SOGx9^E`9}__L@O|iW6i` zBig>GZN?aFEboECEkDa|UBzG&t0a-~DrioeKL zq;#|C?<;yE)4XHAX?%pOakiovn?aCo@OFkKHO<6$SWzwYXif3Piw*Q!lgSgVhqOIH zl;Isq)VsL6>|H&7*6WgA{LX&8XPu@Dg~#JL`XSY69>bF7Z-I7O{v}~AQCa{Ep7FF@ z%%^qaMPnx>Dux|e^G+dQOUo26EO-cxtao { reported_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `); + // Carpeta de procesados reportada por el restaurador (opcional). Si no la reporta, el + // panel la deriva como hermana de input_folder (deriveSiblingFolder). + await pgPool.query( + `ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500)` + ); +} + +/** Añade columnas de tamaño/ruta relativa a restore_job_logs si faltan (idempotente). */ +async function ensureRestoreJobLogColumns(): Promise { + for (const stmt of [ + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS size_bytes BIGINT`, + `ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)` + ]) { + try { + await pgPool.query(stmt); + } catch (e) { + if (!isPgUndefinedTable(e)) throw e; // la tabla la crea a24c; si no existe aún, se ignora + } + } +} + +/** + * Estado "último respaldo por nodo": una fila por nodo, con el restaurador donde vive el + * archivo. Llaveada por nodo (NO por la asignación actual) para que el tracking del último + * respaldo persista aunque el nodo se reasigne a otro restaurador. Idempotente. + */ +async function ensureNodeLastRestoreTable(): Promise { + await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c'); + await pgPool.query(` + CREATE TABLE IF NOT EXISTS ${qNodeLastRestore()} ( + database_node_id INTEGER PRIMARY KEY, + restore_target_id INTEGER, + db_name VARCHAR(255), + node_key VARCHAR(255), + filename VARCHAR(500) NOT NULL, + rel_path VARCHAR(600), + size_bytes BIGINT, + restored_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `); + await pgPool.query( + `CREATE INDEX IF NOT EXISTS idx_a24c_node_last_restore_target + ON ${qNodeLastRestore()} (restore_target_id)` + ); } /** Crea restore_targets y filas Alfa/Omega/Gamma si faltan (migración 001, idempotente). */ @@ -91,7 +141,13 @@ async function ensureRestoreTargetsSchema(): Promise { `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_port INTEGER DEFAULT 22`, `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_username VARCHAR(128)`, `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_password_encrypted TEXT`, - `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)` + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)`, + // Características de hardware opcionales (para distribuir bases por capacidad). + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS os VARCHAR(50)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ram_gb INTEGER`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS disk_gb INTEGER`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS location VARCHAR(255)`, + `ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS size_category VARCHAR(20)` ]) { await pgPool.query(stmt); } @@ -492,6 +548,12 @@ export interface RestoreTarget { ssh_username: string; remote_inbox_path: string; notes: string | null; + // Características de hardware opcionales (NULL = sin capturar). + os: string | null; + ram_gb: number | null; + disk_gb: number | null; + location: string | null; + size_category: string | null; } /** Datos de alta/edición. Las contraseñas en texto plano; se cifran aquí. */ @@ -507,11 +569,18 @@ export interface RestoreTargetInput { ssh_password?: string; // opcional en edición: si se omite, no se cambia remote_inbox_path: string; notes?: string | null; + // Características de hardware opcionales. + os?: string | null; + ram_gb?: number | null; + disk_gb?: number | null; + location?: string | null; + size_category?: string | null; } const ROW_RESTORE_TARGET = ` id, name, server_ip, sql_username, data_folder, - ssh_host, ssh_port, ssh_username, remote_inbox_path, notes + ssh_host, ssh_port, ssh_username, remote_inbox_path, notes, + os, ram_gb, disk_gb, location, size_category `; async function queryRestoreTargets(): Promise { @@ -666,12 +735,16 @@ export async function createRestoreTarget(input: RestoreTargetInput): Promise { await pgPool.query(`DELETE FROM ${qRestoreTargets()} WHERE id = $1`, [id]); } +// ============================================================================ +// Asignación masiva de nodos a servidores de restauración. +// ============================================================================ + +/** Nodo con su asignación de restaurador, para el checklist/distribución (sin secretos). */ +export interface AssignmentNode { + ID: number; + NodoSubNodo: string; + Nombre: string; + BDName: string | null; + ServerName: string | null; + Activo: number; + RestoreTargetId: number | null; +} + +/** Todos los nodos con su asignación actual (base del checklist y de la distribución). */ +export async function listNodesForAssignment(): Promise { + const r = await pgPool.query( + ` + SELECT + id AS "ID", + node_subnode_key AS "NodoSubNodo", + legal_name AS "Nombre", + database_name AS "BDName", + server_name AS "ServerName", + is_active AS "Activo", + restore_target_id AS "RestoreTargetId" + FROM ${qNodes()} + ORDER BY node_subnode_key + ` + ); + return r.rows as AssignmentNode[]; +} + +/** + * Guarda el checklist manual de un restaurador: los `checkedNodeIds` quedan asignados a + * `targetId` (reasignando desde donde estuvieran y derivando server_name de su IP); los nodos + * que estaban en este restaurador y ya NO vienen marcados quedan sin asignar (NULL). Los nodos + * de OTROS restauradores no marcados no se tocan. Transacción con prepared statements. + */ +export async function assignNodesToRestoreTarget( + targetId: number, + checkedNodeIds: number[] +): Promise { + const ids = Array.from(new Set(checkedNodeIds.filter((n) => Number.isInteger(n) && n > 0))); + const client = await pgPool.connect(); + try { + await client.query('BEGIN'); + // 1) Asignar/reasignar los marcados a este restaurador (server_name = IP del target si existe). + await client.query( + ` + UPDATE ${qNodes()} + SET restore_target_id = $1, + server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name) + WHERE id = ANY($2::int[]) + `, + [targetId, ids] + ); + // 2) Quitar de este restaurador los que quedaron desmarcados (NULL); no toca otros targets. + await client.query( + ` + UPDATE ${qNodes()} + SET restore_target_id = NULL + WHERE restore_target_id = $1 + AND NOT (id = ANY($2::int[])) + `, + [targetId, ids] + ); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + +/** + * Aplica un conjunto de asignaciones (distribución global o automática): cada par fija el + * restore_target_id del nodo (y deriva server_name de la IP del restaurador si no es NULL). + * Pares con targetId NULL dejan el nodo sin asignar (conservando server_name). Transacción. + */ +export async function applyNodeAssignments( + pairs: { nodeId: number; targetId: number | null }[] +): Promise { + const clean = pairs.filter((p) => Number.isInteger(p.nodeId) && p.nodeId > 0); + if (clean.length === 0) return; + const client = await pgPool.connect(); + try { + await client.query('BEGIN'); + for (const { nodeId, targetId } of clean) { + await client.query( + ` + UPDATE ${qNodes()} + SET restore_target_id = $2, + server_name = CASE + WHEN $2::int IS NULL THEN server_name + ELSE COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $2), server_name) + END + WHERE id = $1 + `, + [nodeId, targetId] + ); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +} + // ============================================================================ // Estado de CloudRestoreAS (carpeta de entrada reportada por el servicio). // ============================================================================ @@ -725,6 +914,7 @@ export interface CloudRestoreStatus { id: number; instance_key: string; input_folder: string; + processed_folder: string | null; host_name: string | null; app_version: string | null; reported_at: Date; @@ -736,7 +926,7 @@ export async function listCloudRestoreStatuses(): Promise await ensureCloudRestoreStatusTable(); const r = await pgPool.query( ` - SELECT id, instance_key, input_folder, host_name, app_version, reported_at + SELECT id, instance_key, input_folder, processed_folder, host_name, app_version, reported_at FROM ${qCloudRestoreStatus()} ORDER BY instance_key ` @@ -757,6 +947,7 @@ export async function getCloudRestoreStatus(): Promise { + await ensureRestoreJobLogColumns(); await pgPool.query( ` INSERT INTO ${qRestoreJobLogs()} ( - filename, restore_target_id, db_name, status, duration_ms, error_message - ) VALUES ($1, $2, $3, $4, $5, $6) + filename, restore_target_id, db_name, status, duration_ms, error_message, + size_bytes, rel_path + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `, [ row.filename, @@ -799,7 +995,274 @@ export async function insertRestoreJobLog(row: { row.dbName, row.status, row.durationMs, - row.errorMessage + row.errorMessage, + row.sizeBytes ?? null, + row.relPath ?? null ] ); } + +/** Resumen de bitácora por servidor: conteos por status (últimos 30 días) y última exitosa. */ +export interface RestoreJobLogSummary { + restore_target_id: number; + completed: number; + failed: number; + forwarded: number; + last_completed_at: Date | null; +} + +export async function listRestoreJobLogSummaries(): Promise { + try { + const r = await pgPool.query( + ` + SELECT + restore_target_id, + COUNT(*) FILTER ( + WHERE status = 'completed' AND restored_at >= now() - INTERVAL '30 days' + )::int AS completed, + COUNT(*) FILTER ( + WHERE status = 'failed' AND restored_at >= now() - INTERVAL '30 days' + )::int AS failed, + COUNT(*) FILTER ( + WHERE status = 'forwarded' AND restored_at >= now() - INTERVAL '30 days' + )::int AS forwarded, + MAX(restored_at) FILTER (WHERE status = 'completed') AS last_completed_at + FROM ${qRestoreJobLogs()} + WHERE restore_target_id IS NOT NULL + GROUP BY restore_target_id + ` + ); + return r.rows as RestoreJobLogSummary[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** Últimas restauraciones de un servidor (para el modal de bitácora del panel). */ +export interface RestoreJobLogRow { + id: number; + filename: string; + db_name: string | null; + status: string; + duration_ms: number | null; + error_message: string | null; + restored_at: Date; +} + +export async function listRecentRestoreJobLogs( + targetId: number, + limit = 20 +): Promise { + const capped = Math.min(Math.max(1, Math.trunc(limit)), 100); + try { + const r = await pgPool.query( + ` + SELECT id, filename, db_name, status, duration_ms, error_message, restored_at + FROM ${qRestoreJobLogs()} + WHERE restore_target_id = $1 + ORDER BY restored_at DESC + LIMIT $2 + `, + [targetId, capped] + ); + return r.rows as RestoreJobLogRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +// ============================================================================ +// Inventario "último respaldo por nodo" (node_last_restore) y restores fallidos. +// ============================================================================ + +export interface NodeLastRestoreRow { + database_node_id: number; + restore_target_id: number | null; + server_name: string | null; // restaurador (Alfa/Omega/Gamma) donde vive el archivo + node_key: string | null; // NodoSubNodo + client_name: string | null; // legal_name + db_name: string | null; + filename: string; + rel_path: string | null; + size_bytes: number | null; + restored_at: Date; +} + +/** Último respaldo restaurado por nodo. Sobrevive a la reasignación del restaurador. */ +export async function listNodeLastRestore(): Promise { + try { + await ensureNodeLastRestoreTable(); + const r = await pgPool.query( + ` + SELECT + nlr.database_node_id, + nlr.restore_target_id, + rt.name AS server_name, + COALESCE(nlr.node_key, dn.node_subnode_key) AS node_key, + dn.legal_name AS client_name, + nlr.db_name, + nlr.filename, + nlr.rel_path, + nlr.size_bytes, + nlr.restored_at + FROM ${qNodeLastRestore()} nlr + LEFT JOIN ${qRestoreTargets()} rt ON rt.id = nlr.restore_target_id + LEFT JOIN ${qNodes()} dn ON dn.id = nlr.database_node_id + ORDER BY nlr.restored_at DESC + ` + ); + return r.rows as NodeLastRestoreRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** + * UPSERT del último respaldo de un nodo. Solo actualiza si el nuevo `restored_at` es igual o + * más reciente, para no retroceder el tracking. Llaveada por nodo → persiste ante reasignación. + */ +export async function upsertNodeLastRestore(row: { + databaseNodeId: number; + restoreTargetId: number | null; + dbName: string | null; + nodeKey: string | null; + filename: string; + relPath: string | null; + sizeBytes: number | null; + restoredAt?: Date | null; +}): Promise { + await ensureNodeLastRestoreTable(); + await pgPool.query( + ` + INSERT INTO ${qNodeLastRestore()} AS nlr ( + database_node_id, restore_target_id, db_name, node_key, + filename, rel_path, size_bytes, restored_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, now()), now()) + ON CONFLICT (database_node_id) DO UPDATE SET + restore_target_id = EXCLUDED.restore_target_id, + db_name = EXCLUDED.db_name, + node_key = EXCLUDED.node_key, + filename = EXCLUDED.filename, + rel_path = EXCLUDED.rel_path, + size_bytes = EXCLUDED.size_bytes, + restored_at = EXCLUDED.restored_at, + updated_at = now() + WHERE EXCLUDED.restored_at >= nlr.restored_at + `, + [ + row.databaseNodeId, + row.restoreTargetId, + row.dbName, + row.nodeKey, + row.filename, + row.relPath, + row.sizeBytes, + row.restoredAt ?? null + ] + ); +} + +/** + * Resuelve el database_node_id de un respaldo por db_name o por el nombre de archivo. + * Reutiliza matchNodeRowFromBackupStem para el fallback por stem del filename. + */ +export async function findNodeIdForBackup( + filename: string, + dbName: string | null +): Promise<{ id: number; nodeKey: string | null } | null> { + if (dbName) { + const r = await pgPool.query( + `SELECT id, node_subnode_key FROM ${qNodes()} + WHERE LOWER(TRIM(database_name)) = LOWER(TRIM($1)) + OR LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1)) + LIMIT 1`, + [dbName] + ); + if (r.rows.length) { + return { id: Number(r.rows[0].id), nodeKey: r.rows[0].node_subnode_key ?? null }; + } + } + const stem = path.parse(filename).name; + const nodes = await pgPool.query( + `SELECT id AS "ID", node_subnode_key AS "NodoSubNodo", database_name AS "BDName" + FROM ${qNodes()}` + ); + const match = matchNodeRowFromBackupStem(stem, nodes.rows); + if (match && match.ID != null) { + return { id: Number(match.ID), nodeKey: (match.NodoSubNodo as string) ?? null }; + } + return null; +} + +/** Restores fallidos (todos los servidores) para el panel de fallidos. */ +export interface FailedRestoreRow { + id: number; + restore_target_id: number | null; + server_name: string | null; + filename: string; + db_name: string | null; + error_message: string | null; + rel_path: string | null; + size_bytes: number | null; + restored_at: Date; +} + +export async function listFailedRestoreJobLogs(limit = 100): Promise { + const capped = Math.min(Math.max(1, Math.trunc(limit)), 500); + try { + await ensureRestoreJobLogColumns(); + const r = await pgPool.query( + ` + SELECT + jl.id, jl.restore_target_id, rt.name AS server_name, + jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes, jl.restored_at + FROM ${qRestoreJobLogs()} jl + LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id + WHERE jl.status = 'failed' + ORDER BY jl.restored_at DESC + LIMIT $1 + `, + [capped] + ); + return r.rows as FailedRestoreRow[]; + } catch (e) { + if (isPgUndefinedTable(e)) return []; + throw e; + } +} + +/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */ +export interface RestoreTargetDownload { + id: number; + name: string; + input_folder: string | null; + processed_folder: string | null; +} + +export async function getRestoreTargetForDownload( + targetId: number +): Promise { + await ensureRestoreTargetsSchema(); + await ensureCloudRestoreStatusTable(); + const r = await pgPool.query( + ` + SELECT rt.id, rt.name, cs.input_folder, cs.processed_folder + FROM ${qRestoreTargets()} rt + LEFT JOIN ${qCloudRestoreStatus()} cs + ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name)) + WHERE rt.id = $1 + `, + [targetId] + ); + if (!r.rows.length) return null; + const row = r.rows[0]; + return { + id: Number(row.id), + name: row.name, + input_folder: row.input_folder ?? null, + processed_folder: row.processed_folder ?? null + }; +} diff --git a/src/lib/server/email-service.test.ts b/src/lib/server/email-service.test.ts new file mode 100644 index 0000000..e1cb446 --- /dev/null +++ b/src/lib/server/email-service.test.ts @@ -0,0 +1,66 @@ +/** + * Pruebas de los builders HTML de correo (réplica 1:1 del legacy) y del formateo de fecha. + * Importan las funciones REALES: se valida estructura legacy + escape de campos de texto. + */ +import { describe, it, expect } from 'vitest'; +import { buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from './email-service'; + +const baseRow = { + visible_name: 'CLIENTE_DB', + clientName: 'ACME S.A.', + last_restore_date: '2026-06-28T17:26:15.290000', + daysWithout: 3 +}; + +describe('buildBackupAlertHtml — 1:1 legacy (overdue)', () => { + it('incluye logo, título, link SCAIIWeb y pie TransmitirAS', () => { + const html = buildBackupAlertHtml([baseRow]); + expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'); + expect(html).toContain('Notificación de Sincronización'); + expect(html).toContain('no se ha sincronizado correctamente en las últimas 24 horas'); + expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb'); + expect(html).toContain('© 2024 TransmitirAS'); + expect(html).toContain('ACME S.A.'); // cliente + expect(html).toContain('CLIENTE_DB'); // base de datos + expect(html).toContain('28 de junio de 2026, 17:26'); // fecha formateada + }); + + it('escapa visible_name y clientName (XSS)', () => { + const html = buildBackupAlertHtml([ + { visible_name: '', clientName: 'x', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + expect(html).not.toContain('x'); + expect(html).toContain('No disponible'); // last_restore null + }); +}); + +describe('buildBackupResolvedHtml — misma plantilla, mensaje positivo', () => { + it('incluye título restablecido, logo, SCAIIWeb y pie', () => { + const html = buildBackupResolvedHtml([baseRow]); + expect(html).toContain('Sincronización Restablecida'); + expect(html).toContain('volvió a sincronizarse correctamente'); + expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'); + expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb'); + expect(html).toContain('© 2024 TransmitirAS'); + }); + + it('escapa clientName', () => { + const html = buildBackupResolvedHtml([ + { visible_name: 'DB', clientName: '', last_restore_date: null, daysWithout: null } + ]); + expect(html).not.toContain(''); + expect(html).toContain('<img'); + }); +}); + +describe('formatFechaEs', () => { + it('formatea fecha ISO (con microsegundos) al estilo legacy', () => { + expect(formatFechaEs('2026-06-28T17:26:15.290000')).toBe('28 de junio de 2026, 17:26'); + }); + it('null/invalid -> No disponible', () => { + expect(formatFechaEs(null)).toBe('No disponible'); + expect(formatFechaEs('no-fecha')).toBe('No disponible'); + }); +}); diff --git a/src/lib/server/email-service.ts b/src/lib/server/email-service.ts index 85d8f19..6689821 100644 --- a/src/lib/server/email-service.ts +++ b/src/lib/server/email-service.ts @@ -65,95 +65,91 @@ export async function sendSmtpEmail(opts: SendEmailOptions): Promise { }); } -/** Genera HTML de alerta de respaldo compatible con Gmail/Outlook (tablas, sin flex). */ -export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string { - const rows = alerts - .map((a) => { - const badgeColor = - a.daysWithout === null - ? '#6b7280' - : a.daysWithout >= 7 - ? '#b91c1c' - : a.daysWithout >= 3 - ? '#b45309' - : '#047857'; - const badgeBg = - a.daysWithout === null - ? '#f3f4f6' - : a.daysWithout >= 7 - ? '#fef2f2' - : a.daysWithout >= 3 - ? '#fffbeb' - : '#ecfdf5'; - const diasLabel = - a.daysWithout === null ? 'Sin datos' : `${a.daysWithout} día${a.daysWithout !== 1 ? 's' : ''}`; - const ultimaRest = a.last_restore_date - ? new Date(a.last_restore_date).toLocaleString('es-MX') - : 'Nunca'; - return ` - - ${escHtml(a.visible_name)} - ${escHtml(a.clientName ?? 'N/D')} - ${ultimaRest} - - - ${diasLabel} - - - `; - }) - .join(''); +// ============================================================================ +// Plantillas de correo — réplica 1:1 del legacy index.php (logo, azul, narrativa +// por cliente, link SCAIIWeb, pie © TransmitirAS). +// ============================================================================ +const LOGO_URL = 'https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png'; + +const SCAIIWEB_H4 = + `

` + + `Consulta la última sincronización de datos fácilmente desde ` + + `SCAIIWeb. ` + + `Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.

`; + +const FOOTER = + `
` + + `© 2024 TransmitirAS. Todos los derechos reservados.
`; + +/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */ +function renderSyncEmail(inner: string): string { return ` - - - -
- - - - - - - - - - - - - -
-

- Alerta de respaldo — Aduanasoft -

-

- ${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente -

-
- - - - - - - - - - ${rows} -
Base de datosClienteÚltima restauraciónDías sin sync
-
-

- Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft. - Por favor no responda a este correo. -

-
-
+ +
+
+ Logo +
+ ${inner} + ${FOOTER} +
`; } +/** Fecha en español "28 de junio de 2026, 17:26" (o "No disponible"), como el strftime del legacy. */ +export function formatFechaEs(iso: string | null | undefined): string { + if (!iso) return 'No disponible'; + // Normaliza microsegundos (a24c manda isoformat con 6 dígitos) a milisegundos. + const cleaned = String(iso).replace(/(\.\d{3})\d+/, '$1'); + const d = new Date(cleaned); + if (isNaN(d.getTime())) return 'No disponible'; + const meses = [ + 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', + 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre' + ]; + const dd = String(d.getDate()).padStart(2, '0'); + const mm = meses[d.getMonth()]; + const yyyy = d.getFullYear(); + const HH = String(d.getHours()).padStart(2, '0'); + const MM = String(d.getMinutes()).padStart(2, '0'); + return `${dd} de ${mm} de ${yyyy}, ${HH}:${MM}`; +} + +/** Correo de alerta de sincronización (kind=overdue), 1:1 con el legacy index.php. */ +export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +

Notificación de Sincronización

+

Estimado ${nombre},

+

Detectamos que la base de datos ${bd} no se ha sincronizado correctamente en las últimas 24 horas.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.

`; + return renderSyncEmail(inner); +} + +/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */ +export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string { + const a = alerts[0]; + const nombre = escHtml(a?.clientName ?? a?.visible_name ?? ''); + const bd = escHtml(a?.visible_name ?? ''); + const fecha = escHtml(formatFechaEs(a?.last_restore_date)); + const inner = ` +

Sincronización Restablecida

+

Estimado ${nombre},

+

La base de datos ${bd} volvió a sincronizarse correctamente.

+

La última restauración registrada fue: ${fecha}.

+ ${SCAIIWEB_H4} +

No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.

`; + return renderSyncEmail(inner); +} + export interface BackupAlertRow { visible_name: string; clientName: string | null; diff --git a/src/lib/server/restore-fetch.ts b/src/lib/server/restore-fetch.ts new file mode 100644 index 0000000..a95a3d3 --- /dev/null +++ b/src/lib/server/restore-fetch.ts @@ -0,0 +1,103 @@ +/** + * Descarga de respaldos por restaurador, con ruta RELATIVA y SIN rutas fijas. + * + * La base de cada restaurador se DERIVA al vuelo de su Entrada reportada + * (`cloudrestore_status.input_folder`): hermana `Procesados`/`Fallados` (layout por defecto del + * install). El cliente solo envía `target` + `kind` + `relPath`; el servidor arma `base + relPath` + * y lo sirve leyendo la carpeta por filesystem (local o montada como share). CloudRestoreAS no + * reporta ni configura nada nuevo. + */ +import fs from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; +import { deriveSiblingFolder } from './backup-files'; +import { getRestoreTargetForDownload, type RestoreTargetDownload } from './controldesk-pg'; + +export type BackupKind = 'procesados' | 'fallados'; + +export class BackupDownloadError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + this.name = 'BackupDownloadError'; + } +} + +function baseSeparator(p: string): '\\' | '/' { + return p.includes('\\') ? '\\' : '/'; +} + +/** Carpeta base (procesados/fallados) del restaurador, SIEMPRE derivada de su Entrada. */ +function resolveBaseFolder(t: RestoreTargetDownload, kind: BackupKind): string | null { + const input = t.input_folder; + if (!input) return null; + return deriveSiblingFolder(input, kind === 'procesados' ? 'Procesados' : 'Fallados'); +} + +/** Valida la ruta relativa: sin '..', sin absoluto, sin componentes vacíos. */ +export function sanitizeRelPath(relPath: string): string | null { + const rel = String(relPath ?? '') + .replace(/\\/g, '/') + .trim(); + if (!rel) return null; + if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) return null; // absoluto + const parts = rel.split('/'); + if (parts.some((p) => p === '' || p === '.' || p === '..')) return null; + return parts.join('/'); +} + +function localJoin(base: string, rel: string): string { + const sep = baseSeparator(base); + const b = base.replace(/[\\/]+$/, ''); + const relNative = rel.split('/').join(sep); + return `${b}${sep}${relNative}`; +} + +export interface BackupDownload { + body: ReadableStream; + size: number | null; + filename: string; + cleanup: () => void; +} + +/** + * Abre el stream de descarga de un respaldo desde la carpeta de procesados/fallados del + * restaurador (por filesystem). Lanza BackupDownloadError con el código HTTP apropiado. + */ +export async function openBackupDownload( + targetId: number, + kind: BackupKind, + relPathRaw: string +): Promise { + const rel = sanitizeRelPath(relPathRaw); + if (!rel) throw new BackupDownloadError(400, 'Ruta de archivo inválida'); + + const target = await getRestoreTargetForDownload(targetId); + if (!target) throw new BackupDownloadError(404, 'Restaurador no encontrado'); + + const base = resolveBaseFolder(target, kind); + if (!base) throw new BackupDownloadError(409, 'El restaurador aún no reportó su carpeta de entrada'); + + const filename = rel.split('/').pop() as string; + const localPath = localJoin(base, rel); + + let st; + try { + st = await fs.stat(localPath); + } catch { + throw new BackupDownloadError(404, 'Archivo de respaldo no encontrado o carpeta inaccesible'); + } + if (!st.isFile()) { + throw new BackupDownloadError(404, 'La ruta indicada no es un archivo'); + } + + const node = createReadStream(localPath, { highWaterMark: 1024 * 1024 }); + return { + body: Readable.toWeb(node) as unknown as ReadableStream, + size: st.size, + filename, + cleanup: () => node.destroy() + }; +} diff --git a/src/lib/server/restore-inventory.ts b/src/lib/server/restore-inventory.ts new file mode 100644 index 0000000..e9a2a07 --- /dev/null +++ b/src/lib/server/restore-inventory.ts @@ -0,0 +1,131 @@ +/** + * Escaneo del inventario de restauraciones LEYENDO las carpetas que usa CloudRestoreAS. + * + * Por cada restaurador toma la Entrada (`input_folder`) que ya reporta en `cloudrestore_status`, + * DERIVA sus hermanas `Procesados`/`Fallados` (deriveSiblingFolder) y las LEE por filesystem + * (listBackupFiles). Mapea cada archivo a su nodo (matchNodeRowFromBackupStem) y arma: + * - restaurados (de Procesados) → además hace upsert en `node_last_restore` (cache durable que + * preserva el "último respaldo por nodo" aunque se reasigne el restaurador o una carpeta quede + * temporalmente ilegible). + * - fallidos (de Fallados) → enriquecidos con `error_message` de `restore_job_logs` por filename. + * + * No requiere que CloudRestoreAS reporte nada nuevo: solo usa el `input_folder` ya reportado. + */ +import path from 'node:path'; +import { deriveSiblingFolder, listBackupFiles } from './backup-files'; +import { + listRestoreTargets, + listCloudRestoreStatuses, + listNodesForAssignment, + matchNodeRowFromBackupStem, + upsertNodeLastRestore, + listNodeLastRestore, + listFailedRestoreJobLogs, + type NodeLastRestoreRow +} from './controldesk-pg'; + +export interface FailedItem { + id: string; // clave estable para la UI: `${restore_target_id}:${rel_path}` + restore_target_id: number | null; + server_name: string | null; + node_key: string | null; + client_name: string | null; + db_name: string | null; + filename: string; + rel_path: string; + size_bytes: number; + error_message: string | null; + restored_at: Date; +} + +export interface RestoreInventory { + restored: NodeLastRestoreRow[]; + failed: FailedItem[]; +} + +const SCAN_OPTS = { maxDepth: 3, maxFiles: 5000 } as const; + +/** Escanea las carpetas de todos los restauradores y devuelve restaurados + fallidos. */ +export async function scanRestoreInventory(): Promise { + const [targets, statuses, nodes] = await Promise.all([ + listRestoreTargets(), + listCloudRestoreStatuses(), + listNodesForAssignment() + ]); + + // instance_key (case-insensitive) → input_folder reportado. + const inputByInstance = new Map(); + for (const s of statuses) { + if (s.input_folder) inputByInstance.set(s.instance_key.trim().toLowerCase(), s.input_folder); + } + + const failed: FailedItem[] = []; + + for (const t of targets) { + const input = inputByInstance.get(String(t.name).trim().toLowerCase()); + if (!input) continue; // este restaurador aún no reportó su Entrada + + // Procesados → upsert del último respaldo por nodo. + try { + const procesados = deriveSiblingFolder(input, 'Procesados'); + const { files } = await listBackupFiles(procesados, SCAN_OPTS); + for (const f of files) { + const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes); + if (match && match.ID != null) { + await upsertNodeLastRestore({ + databaseNodeId: Number(match.ID), + restoreTargetId: t.id, + dbName: (match.BDName as string) ?? null, + nodeKey: (match.NodoSubNodo as string) ?? null, + filename: f.name, + relPath: f.relPath, + sizeBytes: f.sizeBytes, + restoredAt: new Date(f.mtimeMs) + }); + } + } + } catch { + /* Carpeta Procesados ilegible: se conserva lo cacheado en node_last_restore. */ + } + + // Fallados → lista viva (con enriquecimiento de error más abajo). + try { + const fallados = deriveSiblingFolder(input, 'Fallados'); + const { files } = await listBackupFiles(fallados, SCAN_OPTS); + for (const f of files) { + const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes); + failed.push({ + id: `${t.id}:${f.relPath}`, + restore_target_id: t.id, + server_name: t.name, + node_key: (match?.NodoSubNodo as string) ?? null, + client_name: (match?.Nombre as string) ?? null, + db_name: (match?.BDName as string) ?? null, + filename: f.name, + rel_path: f.relPath, + size_bytes: f.sizeBytes, + error_message: null, + restored_at: new Date(f.mtimeMs) + }); + } + } catch { + /* Carpeta Fallados ilegible: se omite este restaurador en fallidos. */ + } + } + + // Enriquecer fallidos con el error reportado (restore_job_logs) por filename. + try { + const logs = await listFailedRestoreJobLogs(500); + const errByFile = new Map(); + for (const l of logs) errByFile.set(l.filename.trim().toLowerCase(), l.error_message); + for (const f of failed) { + f.error_message = errByFile.get(f.filename.trim().toLowerCase()) ?? null; + } + } catch { + /* sin bitácora: los fallidos se listan sin mensaje de error */ + } + + // Restaurados = estado durable por nodo (recién refrescado por el escaneo). + const restored = await listNodeLastRestore(); + return { restored, failed }; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 3ccf7f0..76d0a2c 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,5 +1,4 @@ import { env } from '$env/dynamic/private'; -import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { redirect } from '@sveltejs/kit'; @@ -7,6 +6,9 @@ import type { PageServerLoad, Actions } from './$types'; import { verifyToken } from '$lib/server/auth'; import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { listBackupFiles } from '$lib/server/backup-files'; +import { scanRestoreInventory } from '$lib/server/restore-inventory'; +import { logger } from '$lib/server/logger'; import { listClientsCatalog, listDatabaseNodes, @@ -22,7 +24,9 @@ import { insertPortalUser, updatePortalUser, deletePortalUser, - listRestoreTargets + listRestoreTargets, + listNodeLastRestore, + listFailedRestoreJobLogs } from '$lib/server/controldesk-pg'; import { listAdditionalEmails, @@ -112,6 +116,8 @@ export const load: PageServerLoad = async ({ cookies }) => { let summaryAZ: any = null; let backupFiles: any[] = []; + let restoredBackups: any[] = []; + let failedRestores: any[] = []; let clientsData: any[] = []; let alertsData: any[] = []; let basesDeDatosList: any[] = []; @@ -187,54 +193,55 @@ export const load: PageServerLoad = async ({ cookies }) => { // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- try { - let files: string[] = []; - try { - files = await fs.readdir(env.BACKUP_PATH as string); - } catch (e) { - files = []; - errors.backups = "No se pudo acceder a la carpeta de respaldos."; - } - - for (const file of files) { - if (file === '.' || file === '..') continue; - - const filePath = path.join(env.BACKUP_PATH as string, file); - let stats; + // El CloudRestoreAS nuevo reubica los respaldos en `Procesados//`; se + // recorre la raíz de forma recursiva (ver listBackupFiles). BACKUP_PATH debe apuntar + // a esa raíz. Las entradas ya vienen ordenadas por fecha descendente y con multipart + // colapsado. + const backupRoot = env.BACKUP_PATH as string | undefined; + if (!backupRoot) { + errors.backups = 'La ruta de respaldos (BACKUP_PATH) no está configurada.'; + } else { + let listing: { files: Awaited>['files']; truncated: boolean }; try { - stats = await fs.stat(filePath); - } catch { continue; } - - if (!stats.isFile()) continue; - - const nodoName = path.parse(file).name; - let clientData: any = null; - - if (controlDeskOk && basesDeDatosList.length) { - try { - clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); - } catch { - /* ignore */ - } + listing = await listBackupFiles(backupRoot); + } catch { + listing = { files: [], truncated: false }; + errors.backups = 'No se pudo acceder a la carpeta de respaldos.'; } - backupFiles.push({ - name: file, - nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, - client_name: clientData?.Nombre ?? 'Cliente no identificado', - client_authority: clientData?.RFC ?? 'N/A', - bd_shelter: 'N/A', - date: stats.mtime, - size: (stats.size / 1024 / 1024).toFixed(2) + " MB" - }); + if (listing.truncated) { + logger.warn({ + message: 'Listado de respaldos recortado por límite de archivos', + context: { backupRoot } + }); + } + + for (const entry of listing.files) { + const nodoName = path.parse(entry.name).name; + let clientData: any = null; + + if (controlDeskOk && basesDeDatosList.length) { + try { + clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); + } catch { + /* ignore */ + } + } + + backupFiles.push({ + name: entry.name, + relPath: entry.relPath, + parts: entry.parts, + nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, + client_name: clientData?.Nombre ?? 'Cliente no identificado', + client_authority: clientData?.RFC ?? 'N/A', + bd_shelter: 'N/A', + date: new Date(entry.mtimeMs), + size: (entry.sizeBytes / 1024 / 1024).toFixed(2) + ' MB' + }); + } } - // Ordenar respaldos de más reciente a más antiguo por fecha de modificación - backupFiles.sort((a, b) => { - const da = new Date(a.date).getTime(); - const db = new Date(b.date).getTime(); - return db - da; - }); - if (controlDeskOk && alertsData.length > 0) { alertsData = await Promise.all( alertsData.map(async (alert) => { @@ -293,6 +300,28 @@ export const load: PageServerLoad = async ({ cookies }) => { console.error("Error processing backups/alerts hydration:", e); } + // Inventario "respaldos restaurados por nodo" (sobrevive a reasignación) y "restores + // fallidos", desde la BD (poblados vía job-result). Tolerante a fallos: si falla, se + // muestran vacíos sin romper el dashboard. + try { + // Escanea las carpetas Procesados/Fallados (derivadas de la Entrada reportada por cada + // restaurador) y refresca el cache node_last_restore. Refleja la realidad en cada carga. + const inv = await scanRestoreInventory(); + restoredBackups = inv.restored; + failedRestores = inv.failed; + } catch (e: any) { + console.error('Error escaneando inventario de restauraciones:', e); + // Fallback al cache/BD si el escaneo falla por completo (p.ej. sin acceso a carpetas). + try { + [restoredBackups, failedRestores] = await Promise.all([ + listNodeLastRestore(), + listFailedRestoreJobLogs(200) + ]); + } catch (e2: any) { + console.error('Fallback de inventario de restauraciones también falló:', e2); + } + } + // CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en last_restore_date (ANTES del filtro) restoredCount = 0; notRestoredCount = 0; @@ -357,6 +386,8 @@ export const load: PageServerLoad = async ({ cookies }) => { summaryAZ, backupFiles, + restoredBackups, + failedRestores, clientsData, alertsData, basesDeDatosList, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4e97b22..b69aaf3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,7 +2,7 @@ import { untrack, tick } from 'svelte'; import { deserialize } from '$app/forms'; import { invalidateAll, goto } from '$app/navigation'; - import aduanasoftLogo from '$lib/assets/logo.png'; + import AppShell from '$lib/components/AppShell.svelte'; type KitActionPayload = { success?: boolean; message?: string }; @@ -42,6 +42,10 @@ const restoredCount = $derived(data.restoredCount); const notRestoredCount = $derived(data.notRestoredCount); const backupFiles = $derived(data.backupFiles); + const restoredBackups = $derived(data.restoredBackups ?? []); + const failedRestores = $derived(data.failedRestores ?? []); + const formatMb = (bytes: number | null | undefined) => + bytes == null ? '—' : (Number(bytes) / 1024 / 1024).toFixed(2) + ' MB'; const clientsData = $derived(data.clientsData); const alertsData = $derived(data.alertsData); const basesDeDatosList = $derived(data.basesDeDatosList); @@ -74,7 +78,9 @@ })()); // Estado de vista activa y paginación - let activeView = $state<'dashboard' | 'backups' | 'clients' | 'alerts' | 'databases'>('dashboard'); + let activeView = $state< + 'dashboard' | 'backups' | 'restored' | 'failed' | 'clients' | 'alerts' | 'databases' + >('dashboard'); // Persistencia de la vista activa del sidebar en localStorage const ACTIVE_VIEW_KEY = 'aduanasoft-dashboard-active-view'; @@ -86,6 +92,8 @@ if ( stored === 'dashboard' || stored === 'backups' || + stored === 'restored' || + stored === 'failed' || stored === 'clients' || stored === 'alerts' || stored === 'databases' @@ -333,23 +341,6 @@ }); }; - // Estado del sidebar colapsado - let sidebarCollapsed = $state(false); - - // Persistir estado del sidebar - if (typeof window !== 'undefined') { - const storedCollapsed = window.localStorage.getItem('sidebar-collapsed'); - if (storedCollapsed) { - sidebarCollapsed = storedCollapsed === 'true'; - } - } - - const toggleSidebar = () => { - sidebarCollapsed = !sidebarCollapsed; - if (typeof window !== 'undefined') { - window.localStorage.setItem('sidebar-collapsed', String(sidebarCollapsed)); - } - }; // Auto-refresh del panel principal (cada 40 segundos) if (typeof window !== 'undefined') { @@ -1120,250 +1111,70 @@ }; -
- - - - -
- -
+ {#snippet navExtra(collapsed)} + -
-

Panel de Control

-
-
+ insights + {#if !collapsed}Resumen{/if} + + + + + {#if data.currentUser?.es_admin} + + {/if} + + {#if data.currentUser?.es_admin} + + {/if} + {/snippet} -
- -
- - - -
{#if activeView === 'dashboard'} @@ -1760,14 +1571,16 @@ {#each sliceVisible(backupRowsLive, visibleBackups) as file} - {file.name} + + {file.name}{#if file.parts > 1}({file.parts} partes){/if} + {file.client_name} {new Date(file.date).toLocaleString()} {file.size} Descargar @@ -1790,6 +1603,116 @@ {/if} + {#if activeView === 'restored'} +
+
+

Respaldos restaurados

+

+ Último respaldo restaurado por nodo, por servidor de restauración. Se + conserva aunque el nodo se reasigne a otro restaurador. +

+
+
+ + + + + + + + + + + + + {#each restoredBackups as r (r.database_node_id)} + + + + + + + + + {:else} + + + + {/each} + +
ServidorNodo / ClienteArchivoFechaTamañoAcciones
{r.server_name ?? '—'} + {r.node_key ?? r.db_name ?? '—'} + {#if r.client_name}{r.client_name}{/if} + {r.filename}{new Date(r.restored_at).toLocaleString()}{formatMb(r.size_bytes)} + {#if r.restore_target_id && r.rel_path} + Descargar + {:else} + + {/if} +
+ No hay respaldos restaurados registrados. +
+
+
+ {/if} + + {#if activeView === 'failed'} +
+
+

Restores fallidos

+

+ Restauraciones que fallaron, por servidor. Puedes descargar el respaldo + para diagnóstico. +

+
+
+ + + + + + + + + + + + {#each failedRestores as f (f.id)} + + + + + + + + {:else} + + + + {/each} + +
ServidorBase / ArchivoFechaErrorAcciones
{f.server_name ?? '—'} + {f.db_name ?? '—'} + {f.filename} + {new Date(f.restored_at).toLocaleString()}{f.error_message ?? '—'} + {#if f.restore_target_id && f.rel_path} + Descargar + {:else} + + {/if} +
+ No hay restores fallidos registrados. +
+
+
+ {/if} + {#if activeView === 'clients' && data.currentUser?.es_admin}
@@ -2403,9 +2326,7 @@
{/if}
-
-
-
+ {#if showDbCrudModal} diff --git a/src/routes/api/alerts/send/+server.ts b/src/routes/api/alerts/send/+server.ts index 7447048..0ef63ae 100644 --- a/src/routes/api/alerts/send/+server.ts +++ b/src/routes/api/alerts/send/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { pgPool } from '$lib/server/db'; -import { sendSmtpEmail, buildBackupAlertHtml } from '$lib/server/email-service'; +import { sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from '$lib/server/email-service'; interface AlertPayload { + /** ID del nodo en a24c.database_nodes; se hace echo en SendResult para reconciliación del cooldown en a24c. */ + database_node_id?: number; /** Clave del nodo (de clientData.NodoSubNodo). Puede estar vacía si el cliente no fue identificado. */ node_subnode_key: string; visible_name: string; @@ -11,11 +13,17 @@ interface AlertPayload { mainEmail: string | null; last_restore_date: string | null; daysWithout: number | null; + /** Tipo de notificación. Ausente => 'overdue' por compatibilidad. */ + kind?: 'overdue' | 'resolved'; } interface SendResult { + /** Echo del id recibido (o null si no vino) para que a24c reconcilie el cooldown. */ + database_node_id: number | null; node_subnode_key: string; visible_name: string; + /** Echo del kind efectivo aplicado. */ + kind: 'overdue' | 'resolved'; recipients: number; status: 'sent' | 'no_recipients' | 'error'; error?: string; @@ -83,40 +91,55 @@ export const POST: RequestHandler = async ({ request }) => { let failed = 0; for (const alert of alerts) { + const kind: 'overdue' | 'resolved' = alert.kind === 'resolved' ? 'resolved' : 'overdue'; + const nodeId = Number.isInteger(alert.database_node_id) ? (alert.database_node_id as number) : null; try { const additional = await getAdditionalEmails(alert.node_subnode_key, alert.visible_name); const toAddrs = dedupeEmails([alert.mainEmail, ...additional]); if (toAddrs.length === 0) { - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'no_recipients' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'no_recipients' }); failed++; continue; } - const htmlBody = buildBackupAlertHtml([{ + const rowData = { visible_name: alert.visible_name, clientName: alert.clientName, last_restore_date: alert.last_restore_date, daysWithout: alert.daysWithout - }]); + }; + const nombre = alert.clientName ?? alert.visible_name; + const fecha = formatFechaEs(alert.last_restore_date); - const diasLabel = alert.daysWithout !== null ? `${alert.daysWithout} días` : 'sin datos'; - const plainBody = `Alerta de respaldo — Aduanasoft\n\nBase de datos: ${alert.visible_name}\nCliente: ${alert.clientName ?? 'N/D'}\nÚltima restauración: ${alert.last_restore_date ? new Date(alert.last_restore_date).toLocaleString('es-MX') : 'Nunca'}\nDías sin sincronizar: ${diasLabel}\n\nEste mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.`; + let subject: string; + let htmlBody: string; + let plainBody: string; + + if (kind === 'resolved') { + subject = `Sincronización Restablecida - ${nombre}`; + htmlBody = buildBackupResolvedHtml([rowData]); + plainBody = `Sincronización Restablecida\n\nEstimado ${nombre},\n\nLa base de datos ${alert.visible_name} volvió a sincronizarse correctamente.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nNo se requiere ninguna acción de su parte.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } else { + subject = `Alerta de Sincronización - ${nombre}`; + htmlBody = buildBackupAlertHtml([rowData]); + plainBody = `Notificación de Sincronización\n\nEstimado ${nombre},\n\nDetectamos que la base de datos ${alert.visible_name} no se ha sincronizado correctamente en las últimas 24 horas.\nLa última restauración registrada fue: ${fecha}.\n\nConsulta la última sincronización desde SCAIIWeb: https://a24.aduanasoft.com/SCAIIWeb\n\nPor favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.\n\n© 2024 TransmitirAS. Todos los derechos reservados.`; + } await sendSmtpEmail({ toAddrs, - subject: `Alerta de respaldo: ${alert.visible_name}`, + subject, plainBody, htmlBody, - fromDisplayName: 'Aduanasoft · Panel de Bases', - highImportance: (alert.daysWithout ?? 0) >= 7 + fromDisplayName: 'TransmitirAS Notificaciones', + highImportance: false }); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: toAddrs.length, status: 'sent' }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: toAddrs.length, status: 'sent' }); sent++; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - details.push({ node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, recipients: 0, status: 'error', error: msg }); + details.push({ database_node_id: nodeId, node_subnode_key: alert.node_subnode_key, visible_name: alert.visible_name, kind, recipients: 0, status: 'error', error: msg }); failed++; } } diff --git a/src/routes/api/alerts/send/server.test.ts b/src/routes/api/alerts/send/server.test.ts new file mode 100644 index 0000000..f61582f --- /dev/null +++ b/src/routes/api/alerts/send/server.test.ts @@ -0,0 +1,173 @@ +/** + * Pruebas del endpoint POST /api/alerts/send (sin autenticación — el control es de red). + * + * - Ruteo overdue vs resolved (email-service mockeado). + * - Dedupe principal + adicionales (pgPool mockeado). + * - Echo de database_node_id / kind en details (reconciliación de a24c). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock se iza al tope del archivo; las factories no pueden ver variables de +// módulo, así que los mocks se crean con vi.hoisted. +const { queryMock, sendSmtpEmail, buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } = vi.hoisted(() => ({ + queryMock: vi.fn(), + sendSmtpEmail: vi.fn(), + buildBackupAlertHtml: vi.fn(() => ''), + buildBackupResolvedHtml: vi.fn(() => ''), + formatFechaEs: vi.fn(() => '28 de junio de 2026, 10:00') +})); + +vi.mock('$lib/server/db', () => ({ pgPool: { query: queryMock } })); +vi.mock('$lib/server/email-service', () => ({ + sendSmtpEmail, + buildBackupAlertHtml, + buildBackupResolvedHtml, + formatFechaEs +})); + +import { POST } from './+server'; + +function makeRequest(bodyObj: unknown): Request { + return new Request('http://localhost/api/alerts/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj) + }); +} + +async function callPost(req: Request): Promise<{ status: number; body: any }> { + // El handler solo usa `request` del RequestEvent. + const res = await POST({ request: req } as any); + return { status: res.status, body: await res.json() }; +} + +const baseAlert = { + database_node_id: 42, + node_subnode_key: '08037NATM001', + visible_name: 'CLIENTE_DB', + clientName: 'ACME', + mainEmail: 'dest@x.com', + last_restore_date: '2026-06-28T10:00:00Z', + daysWithout: 3 +}; + +beforeEach(() => { + vi.clearAllMocks(); + queryMock.mockResolvedValue({ rows: [] }); // sin correos adicionales por defecto + sendSmtpEmail.mockResolvedValue(undefined); +}); + +describe('POST /api/alerts/send — validación de body', () => { + it('400 si el body no es JSON', async () => { + const { status } = await callPost(makeRequest('{no-json')); + expect(status).toBe(400); + }); + + it('400 si alerts está vacío', async () => { + const { status } = await callPost(makeRequest({ alerts: [] })); + expect(status).toBe(400); + }); +}); + +describe('POST /api/alerts/send — envío y ruteo', () => { + it('overdue: usa plantilla de alerta y responde sent=1', async () => { + const { status, body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'overdue' }] }) + ); + expect(status).toBe(200); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(buildBackupResolvedHtml).not.toHaveBeenCalled(); + expect(sendSmtpEmail).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(0); + expect(body.details[0]).toMatchObject({ database_node_id: 42, kind: 'overdue', status: 'sent' }); + }); + + it('kind ausente por defecto es overdue', async () => { + await callPost(makeRequest({ alerts: [baseAlert] })); + expect(buildBackupAlertHtml).toHaveBeenCalledTimes(1); + expect(sendSmtpEmail.mock.calls[0][0].subject).toBe('Alerta de Sincronización - ACME'); + }); + + it('resolved: usa plantilla de normalizado, sin alta importancia', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, kind: 'resolved', daysWithout: 0 }] }) + ); + expect(buildBackupResolvedHtml).toHaveBeenCalledTimes(1); + expect(buildBackupAlertHtml).not.toHaveBeenCalled(); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.subject).toBe('Sincronización Restablecida - ACME'); + expect(arg.highImportance).toBe(false); + expect(body.details[0].kind).toBe('resolved'); + }); + + it('deduplica correo principal + adicionales (case-insensitive)', async () => { + // Primera query (por node_subnode_key) devuelve adicionales, uno duplica el principal. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'DEST@x.com' }, { email: 'extra@x.com' }] }); + await callPost(makeRequest({ alerts: [baseAlert] })); + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs).toHaveLength(2); + expect(arg.toAddrs.map((s: string) => s.toLowerCase())).toEqual(['dest@x.com', 'extra@x.com']); + }); + + it('no_recipients: sin correos no envía', async () => { + const { body } = await callPost( + makeRequest({ alerts: [{ ...baseAlert, mainEmail: null }] }) + ); + expect(sendSmtpEmail).not.toHaveBeenCalled(); + expect(body.details[0]).toMatchObject({ database_node_id: 42, status: 'no_recipients' }); + expect(body.failed).toBe(1); + }); + + it('error SMTP: status error y el bucle continúa', async () => { + sendSmtpEmail.mockRejectedValueOnce(new Error('smtp caído')); + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const d1 = body.details.find((d: any) => d.database_node_id === 1); + const d2 = body.details.find((d: any) => d.database_node_id === 2); + expect(d1.status).toBe('error'); + expect(d2.status).toBe('sent'); + expect(body.sent).toBe(1); + expect(body.failed).toBe(1); + }); + + it('multi-nodo: mezcla sent / no_recipients / error emparejada por database_node_id', async () => { + sendSmtpEmail + .mockResolvedValueOnce(undefined) // nodo 1 -> sent + .mockRejectedValueOnce(new Error('x')); // nodo 3 -> error + const alerts = [ + { ...baseAlert, database_node_id: 1, visible_name: 'DB1' }, + { ...baseAlert, database_node_id: 2, visible_name: 'DB2', mainEmail: null }, + { ...baseAlert, database_node_id: 3, visible_name: 'DB3' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + const by = (id: number) => body.details.find((d: any) => d.database_node_id === id); + expect(by(1).status).toBe('sent'); + expect(by(2).status).toBe('no_recipients'); + expect(by(3).status).toBe('error'); + }); + + it('node_subnode_key vacío usa el fallback por database_name (2ª query)', async () => { + // Con key vacío, getAdditionalEmails salta la 1ª query y usa el fallback. + queryMock.mockResolvedValueOnce({ rows: [{ email: 'fallback@x.com' }] }); + await callPost(makeRequest({ alerts: [{ ...baseAlert, node_subnode_key: '' }] })); + expect(queryMock).toHaveBeenCalledTimes(1); + expect(String(queryMock.mock.calls[0][0])).toContain('database_nodes'); // SQL de fallback + const arg = sendSmtpEmail.mock.calls[0][0]; + expect(arg.toAddrs.map((s: string) => s.toLowerCase()).sort()).toEqual(['dest@x.com', 'fallback@x.com']); + }); + + it('database_node_id ausente o no-entero => echo null', async () => { + const alerts = [ + { ...baseAlert, database_node_id: undefined }, + { ...baseAlert, database_node_id: 3.5, visible_name: 'DB2' } + ]; + const { body } = await callPost(makeRequest({ alerts })); + expect(body.details[0].database_node_id).toBeNull(); + expect(body.details[1].database_node_id).toBeNull(); + }); +}); diff --git a/src/routes/backup/+server.ts b/src/routes/backup/+server.ts index bcba839..c4ba12e 100644 --- a/src/routes/backup/+server.ts +++ b/src/routes/backup/+server.ts @@ -3,18 +3,63 @@ import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { Readable } from 'node:stream'; +import { logger } from '$lib/server/logger'; +import { openBackupDownload, BackupDownloadError, type BackupKind } from '$lib/server/restore-fetch'; import type { RequestHandler } from './$types'; -// GET /backup?file=nombre.bak -> descarga el archivo físico desde BACKUP_PATH (streaming; soporta archivos muy grandes) +// GET /backup?target=&kind=&file= +// Descarga por restaurador y RELATIVA (sin rutas fijas): la base la resuelve el servidor +// desde lo que reporta ese restaurador; el archivo se transmite por fs local o SFTP. +// GET /backup?file= (legacy): descarga desde BACKUP_PATH local. export const GET: RequestHandler = async ({ url, request }) => { const fileName = url.searchParams.get('file'); if (!fileName) { return new Response('Missing file parameter', { status: 400 }); } + const targetRaw = url.searchParams.get('target'); + if (targetRaw) { + return handleByTarget(targetRaw, url.searchParams.get('kind'), fileName, request); + } + return handleLegacyBackupPath(fileName, request); +}; + +/** Descarga por restaurador (procesados/fallados) resolviendo la base al vuelo. */ +async function handleByTarget( + targetRaw: string, + kindRaw: string | null, + fileName: string, + request: Request +): Promise { + const targetId = Number(targetRaw); + if (!Number.isInteger(targetId) || targetId <= 0) { + return new Response('Invalid target parameter', { status: 400 }); + } + const kind: BackupKind = kindRaw === 'fallados' ? 'fallados' : 'procesados'; + + try { + const dl = await openBackupDownload(targetId, kind, fileName); + request.signal.addEventListener('abort', () => dl.cleanup()); + const headers = new Headers(); + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Content-Disposition', `attachment; filename="${dl.filename}"`); + if (dl.size != null) headers.set('Content-Length', String(dl.size)); + return new Response(dl.body, { status: 200, headers }); + } catch (e) { + if (e instanceof BackupDownloadError) { + return new Response(e.message, { status: e.status }); + } + const msg = e instanceof Error ? e.message : String(e); + logger.error({ message: 'Error en descarga por restaurador', context: { error: msg, targetId } }); + return new Response('Backup file not found or inaccessible', { status: 404 }); + } +} + +/** Modo legacy: sirve el archivo desde BACKUP_PATH local (carpeta única configurada). */ +async function handleLegacyBackupPath(fileName: string, request: Request): Promise { const basePath = env.BACKUP_PATH; if (!basePath) { - console.error('BACKUP_PATH is not defined in environment'); + logger.error({ message: 'BACKUP_PATH no está definido en el entorno' }); return new Response('Backup path is not configured', { status: 500 }); } @@ -28,10 +73,9 @@ export const GET: RequestHandler = async ({ url, request }) => { try { const st = await fs.stat(filePath); if (!st.isFile()) { - console.error('Backup path is not a regular file:', filePath); return new Response('Backup file not found or inaccessible', { status: 404 }); } - console.log('Serving backup file from', filePath, 'size', st.size); + const downloadName = path.basename(filePath); const nodeStream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); request.signal.addEventListener('abort', () => { nodeStream.destroy(); @@ -39,12 +83,12 @@ export const GET: RequestHandler = async ({ url, request }) => { const body = Readable.toWeb(nodeStream) as unknown as ReadableStream; const headers = new Headers(); headers.set('Content-Type', 'application/octet-stream'); - headers.set('Content-Disposition', `attachment; filename="${fileName}"`); + headers.set('Content-Disposition', `attachment; filename="${downloadName}"`); headers.set('Content-Length', String(st.size)); return new Response(body, { status: 200, headers }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); - console.error('Error serving backup file:', msg); + logger.error({ message: 'Error al servir archivo de respaldo', context: { error: msg } }); return new Response('Backup file not found or inaccessible', { status: 404 }); } -}; +} diff --git a/src/routes/reportes/+page.svelte b/src/routes/reportes/+page.svelte index 315ed77..045a2a5 100644 --- a/src/routes/reportes/+page.svelte +++ b/src/routes/reportes/+page.svelte @@ -1,5 +1,6 @@ -
+ + + +

Servidores de Restauración

- Configura IP, credenciales y rutas de cada servidor. La asignación de cada base a - un servidor se hace en Gestión de Bases de Datos (un radio por base). + Configura IP, credenciales, rutas y capacidad de cada servidor, y asigna qué bases + restaura cada uno (checklist o distribución automática por capacidad).

@@ -118,79 +707,355 @@
{/if} {#if form?.success} -
+
Operación realizada correctamente.
{/if} + {#if successMsg} +
+ check_circle + {successMsg} +
+ {/if} {#if data.dbWarning}
{data.dbWarning}
{/if} +
-

CloudRestoreAS por servidor

-

- Cada servidor de restauración puede tener su propia instalación de CloudRestoreAS. - La carpeta de entrada se configura en cada equipo; el panel solo la muestra. -

+

Servidores

{#if targets.length === 0}

Crea un servidor con + Nuevo servidor.

{:else} -
- {#each targets as t (t.id)} - {@const cra = statusForTarget(t.name)} -
-
- {t.name} - {#if cra && !isStaleStatus(cra)} - - Reportada - - {:else} - - Sin reportar - +
+ {#each targets as t (t.id)} + {@const cra = statusForTarget(t.name)} + {@const summary = logSummaryByTarget.get(t.id)} + {@const stats = assignedStats(t)} +
+
+ {t.name} + {#if cra && !isStaleStatus(cra)} + + Reportada + + {:else} + + Sin reportar + + {/if} +
+ {#if !hasCapacity(t)} +

+ Sin capacidad — captura Disco (GB) para distribuir +

{/if} +
+
+
Sistema
+
{t.os ?? '—'}
+
+
+
RAM
+
{t.ram_gb != null ? `${t.ram_gb} GB` : '—'}
+
+
+
Disco
+
{t.disk_gb != null ? `${t.disk_gb} GB` : '—'}
+
+
+
Ubicación
+
{t.location ?? '—'}
+
+
+
Carpeta entrada
+
{cra?.input_folder ?? '—'}
+
+
+
Actualizado
+
{formatReportedAt(cra?.reported_at)}
+
+
+
CloudRestore
+
+ {cra?.app_version ? `v${cra.app_version}` : '—'}{cra?.host_name ? ` · ${cra.host_name}` : ''} +
+
+
+
Última rest. OK
+
{formatReportedAt(summary?.last_completed_at)}
+
+
+
30 días
+
+ {#if summary} + {summary.completed} ok + · 0 ? 'text-red-600' : 'text-slate-500'}>{summary.failed} err + {:else} + — + {/if} +
+
+
+
Bases asignadas
+
+ {stats.count} + {#if stats.totalMb != null && hasCapacity(t)} + · ≈{formatSize(stats.totalMb)} de {t.disk_gb} GB + ({Math.round((stats.totalMb / ((t.disk_gb ?? 1) * 1024)) * 100)}%) + {:else if stats.count > 0 && !sizesLoaded} + · + {/if} +
+
+
+
+ + + +
-
-
-
Carpeta entrada
-
- {cra?.input_folder ?? '—'} -
-
-
-
Equipo
-
{cra?.host_name ?? '—'}
-
-
-
Última actualización
-
{formatReportedAt(cra?.reported_at)}
-
-
-
- {/each} -
+ {/each} +
{/if}
+ +
+
+
+

Distribución automática (todos los servidores)

+

+ Reparte las bases entre los servidores de forma balanceada y proporcional a su disco. + Revisa la propuesta antes de aplicar. +

+
+ +
+ + {#if showGlobal} +
+
+ + + + + + + {#if sizesLoadedAt} + + Tamaños de las {sizesLoadedAt.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })} + + {/if} +
+ + {#if distributableServers.length === 0} +
+ Ningún servidor tiene Disco (GB) capturado. Captura la capacidad en + Editar → Datos para habilitar la distribución. +
+ {/if} + + {#if globalError} +
{globalError}
+ {/if} + {#if globalSuccess} +
{globalSuccess}
+ {/if} + + {#if globalPreview} + {#if globalPreview.omitted.length > 0} +
+ Servidores omitidos (sin Disco capturado): + {globalPreview.omitted.map((t) => t.name).join(', ')} +
+ {/if} + +
+ {#each globalByServer as s (s.name)} +
+ {s.name}: + {s.count} base(s), {formatSize(s.totalMb)} +
+ {/each} +
+ +
+ + + + + + + + + + + + + {#each globalPreview.rows as r (r.node.ID)} + {@const kind = rowKind(r)} + {@const excluded = excludedRows.has(r.node.ID)} + + + + + + + + + {/each} + +
IncluirBase / NodoServidorTamañoCategoríaCambio
+ toggleExcluded(r.node.ID)} + class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" /> + + {r.node.BDName || r.node.NodoSubNodo} + · {r.node.NodoSubNodo} + {r.serverName}{formatSize(r.sizeMb)}{r.categoria} + {#if kind === 'nueva'} + Nueva + {:else if kind === 'reasignar'} + + Reasignar + + {:else} + Sin cambio + {/if} +
+
+ + {#if globalPreview.skippedNoSize > 0} +
+ {globalPreview.skippedNoSize} base(s) sin tamaño disponible (SQL no accesible o sin métrica) se ignoraron. +
+ {/if} + {#if globalPreview.unplaced.length > 0} +
+ {globalPreview.unplaced.length} base(s) no caben en ningún servidor con la capacidad actual. +
+ {/if} + +
+ + {globalCounts.nuevas} nuevas · + {globalCounts.reasignadas} reasignadas · + {globalCounts.sinCambio} sin cambio + {#if globalCounts.excluidas > 0}· {globalCounts.excluidas} excluida(s){/if} + + {#if !globalConfirming} + + {:else} + + Se asignarán {globalCounts.nuevas} base(s) nuevas y se moverán {globalCounts.reasignadas} de otro servidor. ¿Continuar? + + + + {/if} +
+ {/if} +
+ {/if} +
+ +
- - + + {#each targets as t (t.id)} {@const configured = !!(t.server_ip && t.sql_username)} + {@const assignedCount = nodes.filter((n) => n.RestoreTargetId === t.id).length} - - + + @@ -235,132 +1107,530 @@
Nombre Servidor SQLUsuario SQLHost SSHDiscoBases asignadas Acciones
{t.name} @@ -199,28 +1064,35 @@ sin configurar {/if} + {#if !hasCapacity(t)} + + sin capacidad + + {/if} {t.server_ip ?? '—'}{t.sql_username ?? '—'} - {t.ssh_host ? `${t.ssh_host}:${t.ssh_port ?? 22}` : '—'} - {t.disk_gb != null ? `${t.disk_gb} GB` : '—'}{assignedCount}
+ -
- - -
+
+ {#if showModal} -
-
-

- {editing ? 'Editar servidor' : 'Nuevo servidor'} -

+