diff --git a/bun.lock b/bun.lock index 2c2ba1f..41f8fc1 100644 --- a/bun.lock +++ b/bun.lock @@ -12,10 +12,13 @@ "bcrypt": "^6.0.0", "dotenv": "^17.2.3", "elysia": "^1.4.22", + "elysia-rate-limit": "^4.5.0", "jsonwebtoken": "^9.0.3", + "pdfmake": "^0.3.3", "pg": "^8.18.0", "pino": "^10.3.0", "pino-pretty": "^13.1.3", + "playwright": "^1.58.2", "resend": "6.4.2", "typeorm": "^0.3.28", }, @@ -26,6 +29,7 @@ "@eslint/js": "^9.39.2", "@faker-js/faker": "^10.2.0", "@types/jsonwebtoken": "^9.0.10", + "@types/pdfmake": "^0.3.0", "bun-types": "^1.3.8", "eslint": "^9.39.2", "globals": "^16.5.0", @@ -37,6 +41,8 @@ }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -135,6 +141,8 @@ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -149,6 +157,10 @@ "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], + "@types/pdfkit": ["@types/pdfkit@0.17.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg=="], + + "@types/pdfmake": ["@types/pdfmake@0.3.0", "", { "dependencies": { "@types/node": "*", "@types/pdfkit": "*" } }, "sha512-WjkNTseNkoT7Rpg3bfjV1tM5k4BzgmNX7WJwodw1T02KyKSyf4/vCy/2nThnUcsKglYu8blFXmVTXtht39E5YA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="], @@ -201,7 +213,7 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], "bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], @@ -209,6 +221,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -231,6 +245,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -259,6 +275,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "dargs": ["dargs@8.1.0", "", {}, "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw=="], "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], @@ -277,6 +295,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -289,6 +309,8 @@ "elysia": ["elysia@1.4.22", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.6", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Q90VCb1RVFxnFaRV0FDoSylESQQLWgLHFmWciQJdX9h3b2cSasji9KWEUvaJuy/L9ciAGg4RAhUVfsXHg5K2RQ=="], + "elysia-rate-limit": ["elysia-rate-limit@4.5.0", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "debug": "4.3.4" }, "peerDependencies": { "elysia": ">= 1.0.0" } }, "sha512-nsbl3WLvrGiG/SdTgevAsjCUJhY34Bgf+7bDOYrjTPZyS7Hd4MLuLc4MUr9TFsSJWPvKpzyrX2HW8IWjRbex1Q=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -365,12 +387,16 @@ "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -453,6 +479,8 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -475,6 +503,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], @@ -563,6 +593,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], @@ -575,6 +607,10 @@ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + "pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="], + + "pdfmake": ["pdfmake@0.3.3", "", { "dependencies": { "linebreak": "^1.1.0", "pdfkit": "^0.17.2", "xmldoc": "^2.0.3" } }, "sha512-jSnF8rVLkbLLX37bnXWRFhEDO48quE7OIg7lgWBa6ihAbpCxASaBLWFOXNxSDeLBNt92304SBwpYcPkJnIArlA=="], + "pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="], "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], @@ -605,6 +641,14 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], +<<<<<<< Updated upstream + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], +======= + "png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="], +>>>>>>> Stashed changes + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -645,12 +689,16 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -693,6 +741,8 @@ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -723,6 +773,10 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], @@ -741,6 +795,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -777,12 +833,18 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "cli-truncate/string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], + "elysia-rate-limit/debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="], + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "log-update/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -801,6 +863,8 @@ "typeorm/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -821,6 +885,8 @@ "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "elysia-rate-limit/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "log-update/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/package.json b/package.json index 22e615d..d29a410 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,13 @@ "bcrypt": "^6.0.0", "dotenv": "^17.2.3", "elysia": "^1.4.22", + "elysia-rate-limit": "^4.5.0", "jsonwebtoken": "^9.0.3", + "pdfmake": "^0.3.3", "pg": "^8.18.0", "pino": "^10.3.0", "pino-pretty": "^13.1.3", + "playwright": "^1.58.2", "resend": "6.4.2", "typeorm": "^0.3.28" }, @@ -33,6 +36,7 @@ "@eslint/js": "^9.39.2", "@faker-js/faker": "^10.2.0", "@types/jsonwebtoken": "^9.0.10", + "@types/pdfmake": "^0.3.0", "bun-types": "^1.3.8", "eslint": "^9.39.2", "globals": "^16.5.0", diff --git a/src/modules/reports/application/GenerateWrappedReport.ts b/src/modules/reports/application/GenerateWrappedReport.ts new file mode 100644 index 0000000..125fc51 --- /dev/null +++ b/src/modules/reports/application/GenerateWrappedReport.ts @@ -0,0 +1,61 @@ +import { IReportsRepository } from "../domain/IReportsRepository"; +import { IPdfGenerator, WrappedReportData } from "../domain/IPdfGenerator"; + +export class GenerateWrappedReport { + constructor( + private readonly repository: IReportsRepository, + private readonly pdfGenerator: IPdfGenerator + ) { } + + async execute(userId: string): Promise { + const seasons = [3, 4, 5]; + const playerStats = await this.repository.getPlayerStats(userId, seasons); + const rivals = await this.repository.getTopRivals(userId, seasons); + + // Aggregate stats + const totalWins = playerStats.reduce((sum, s) => sum + s.wins, 0); + const totalLosses = playerStats.reduce((sum, s) => sum + s.losses, 0); + const totalMatches = totalWins + totalLosses; + const totalWinRate = totalMatches > 0 ? ((totalWins / totalMatches) * 100).toFixed(1) + "%" : "0%"; + + const reportData: WrappedReportData = { + totalMatches, + totalWins, + totalLosses, + totalWinRate, + seasons: seasons.map(season => { + const stats = playerStats.find(s => s.season === season); + const rival = rivals.find(r => r.season === season); + + if (!stats) return { + season, + stats: null, + rival: null + }; + + const winRate = (stats.wins + stats.losses) > 0 + ? ((stats.wins / (stats.wins + stats.losses)) * 100).toFixed(1) + "%" + : "0%"; + + return { + season, + stats: { + season: stats.season, + points: stats.points, + wins: stats.wins, + losses: stats.losses, + winRate + }, + rival: rival ? { + name: rival.rivalName, + wins: rival.wins, + matches: rival.matches + } : null + }; + }) + }; + + return this.pdfGenerator.generate(reportData); + } +} + diff --git a/src/modules/reports/domain/IPdfGenerator.ts b/src/modules/reports/domain/IPdfGenerator.ts new file mode 100644 index 0000000..1790f52 --- /dev/null +++ b/src/modules/reports/domain/IPdfGenerator.ts @@ -0,0 +1,31 @@ +export interface SeasonStats { + season: number; + points: number; + wins: number; + losses: number; + winRate: string; +} + +export interface SeasonRival { + name: string; + wins: number; + matches: number; +} + +export interface SeasonReportData { + season: number; + stats: SeasonStats | null; + rival: SeasonRival | null; +} + +export interface WrappedReportData { + totalMatches: number; + totalWins: number; + totalLosses: number; + totalWinRate: string; + seasons: SeasonReportData[]; +} + +export interface IPdfGenerator { + generate(data: WrappedReportData): Promise; +} diff --git a/src/modules/reports/domain/IReportsRepository.ts b/src/modules/reports/domain/IReportsRepository.ts new file mode 100644 index 0000000..afbaa66 --- /dev/null +++ b/src/modules/reports/domain/IReportsRepository.ts @@ -0,0 +1,19 @@ +export interface PlayerSeasonStats { + season: number; + wins: number; + losses: number; + points: number; +} + +export interface PlayerRival { + season: number; + rivalId: string; + rivalName: string; + matches: number; + wins: number; +} + +export interface IReportsRepository { + getPlayerStats(userId: string, seasons: number[]): Promise; + getTopRivals(userId: string, seasons: number[]): Promise; +} diff --git a/src/modules/reports/infrastructure/PdfMakeGenerator.ts b/src/modules/reports/infrastructure/PdfMakeGenerator.ts new file mode 100644 index 0000000..4283aab --- /dev/null +++ b/src/modules/reports/infrastructure/PdfMakeGenerator.ts @@ -0,0 +1,175 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const PdfPrinter = require('pdfmake/js/Printer').default; +import { TDocumentDefinitions } from "pdfmake/interfaces"; +import { IPdfGenerator, WrappedReportData } from "../domain/IPdfGenerator"; + +export class PdfMakeGenerator implements IPdfGenerator { + async generate(data: WrappedReportData): Promise { + const fonts = { + Roboto: { + normal: "node_modules/pdfmake/fonts/Roboto/Roboto-Regular.ttf", + bold: "node_modules/pdfmake/fonts/Roboto/Roboto-Medium.ttf", + italics: "node_modules/pdfmake/fonts/Roboto/Roboto-Italic.ttf", + bolditalics: "node_modules/pdfmake/fonts/Roboto/Roboto-MediumItalic.ttf" + } + }; + + const printer = new PdfPrinter(fonts); + + const docDefinition: TDocumentDefinitions = { + pageMargins: [40, 60, 40, 60], + defaultStyle: { + font: 'Roboto', + fontSize: 12, + color: '#333333' + }, + background: [ + { + canvas: [ + { type: 'rect', x: 0, y: 0, w: 595.28, h: 841.89, color: '#f8f9fa' } // Light gray background + ] + } + ], + content: [ + { + text: "✨ YOUR EVOLUTION WRAPPED ✨", + style: "header", + alignment: "center", + margin: [0, 20, 0, 40] + }, + { + columns: [ + { width: '*', text: '' }, + { + width: 'auto', + stack: [ + { text: "🏆 ALL TIME STATS (S3-S5)", style: "subheader", alignment: "center" }, + { + table: { + body: [ + [ + { text: "🔥 Matches", style: "statLabel" }, + { text: data.totalMatches.toString(), style: "statVal" }, + { text: "✅ Wins", style: "statLabel" }, + { text: data.totalWins.toString(), style: "statVal" } + ], + [ + { text: "❌ Losses", style: "statLabel" }, + { text: data.totalLosses.toString(), style: "statVal" }, + { text: "📈 Win Rate", style: "statLabel" }, + { text: data.totalWinRate, style: "statVal" } + ] + ] + }, + layout: 'noBorders', + style: "statTable" + } + ] + }, + { width: '*', text: '' } + ] + }, + { text: "", margin: [0, 20] }, + // Season Breakdown + ...data.seasons.map(seasonData => { + const stats = seasonData.stats; + const rival = seasonData.rival; + + if (!stats) return null; + + return [ + { + canvas: [ + { type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 1, lineColor: '#e0e0e0' } + ], + margin: [0, 20, 0, 20] + }, + { + text: `📅 SEASON ${seasonData.season}`, + style: "seasonHeader", + margin: [0, 0, 0, 10] + }, + { + columns: [ + { + width: '50%', + stack: [ + { text: `Points: ${stats.points} 💎`, margin: [0, 2, 0, 2] }, + { text: `Record: ${stats.wins}W - ${stats.losses}L`, margin: [0, 2, 0, 2] }, + { text: `Win Rate: ${stats.winRate}`, margin: [0, 2, 0, 2] } + ], + style: "seasonStats" + }, + { + width: '50%', + stack: [ + { text: "💀 Top Rival:", bold: true, color: '#555' }, + rival ? { text: `${rival.name.toUpperCase()}`, fontSize: 14, bold: true, margin: [0, 2, 0, 0] } : { text: "No matches", italics: true, color: '#999' }, + rival ? { text: `${rival.wins} wins in ${rival.matches} games`, fontSize: 10, color: '#777' } : {} + ], + alignment: 'right' + } + ] + } + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }).filter(Boolean) as any[] + ], + styles: { + header: { + fontSize: 26, + bold: true, + color: '#1a1a1a', + characterSpacing: 2 + }, + subheader: { + fontSize: 16, + bold: true, + color: '#444444', + margin: [0, 0, 0, 10] + }, + seasonHeader: { + fontSize: 18, + bold: true, + color: '#2c3e50' + }, + statLabel: { + fontSize: 12, + color: '#666666', + margin: [0, 5, 10, 5] + }, + statVal: { + fontSize: 14, + bold: true, + color: '#000000', + margin: [0, 5, 20, 5] + }, + statTable: { + margin: [0, 10, 0, 10] + }, + seasonStats: { + fontSize: 12, + color: '#444' + } + } + }; + + return new Promise((resolve, reject) => { + try { + const pdfDoc = printer.createPdfKitDocument(docDefinition); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Promise.resolve(pdfDoc).then((doc: any) => { + const chunks: Uint8Array[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + doc.on('data', (chunk: any) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + doc.on('error', (err: any) => reject(err)); + doc.end(); + }).catch(reject); + } catch (err) { + reject(err); + } + }); + } +} diff --git a/src/modules/reports/infrastructure/ReportsController.ts b/src/modules/reports/infrastructure/ReportsController.ts new file mode 100644 index 0000000..a90857b --- /dev/null +++ b/src/modules/reports/infrastructure/ReportsController.ts @@ -0,0 +1,22 @@ +import { GenerateWrappedReport } from "../application/GenerateWrappedReport"; +import { ReportsPostgresRepository } from "../infrastructure/ReportsPostgresRepository"; +import { PdfMakeGenerator } from "../infrastructure/PdfMakeGenerator"; + +export class ReportsController { + async getWrapped(context: { user: { profile: { id: string } } }) { + const userId = context.user.profile.id; + const generateReport = new GenerateWrappedReport( + new ReportsPostgresRepository(), + new PdfMakeGenerator() + ); + + const pdfBuffer = await generateReport.execute(userId); + + return new Response(pdfBuffer as unknown as BodyInit, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="evolution-wrapped-${userId}.pdf"` + } + }); + } +} diff --git a/src/modules/reports/infrastructure/ReportsPostgresRepository.ts b/src/modules/reports/infrastructure/ReportsPostgresRepository.ts new file mode 100644 index 0000000..e553135 --- /dev/null +++ b/src/modules/reports/infrastructure/ReportsPostgresRepository.ts @@ -0,0 +1,101 @@ + + +import { dataSource } from "../../../evolution-types/src/data-source"; +import { PlayerStatsEntity } from "../../../evolution-types/src/entities/PlayerStatsEntity"; +import { MatchResumeEntity } from "../../../evolution-types/src/entities/MatchResumeEntity"; +import { IReportsRepository, PlayerRival, PlayerSeasonStats } from "../domain/IReportsRepository"; + +export class ReportsPostgresRepository implements IReportsRepository { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private playerStatsRepository: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private matchRepository: any; + + constructor() { + this.playerStatsRepository = dataSource.getRepository(PlayerStatsEntity); + this.matchRepository = dataSource.getRepository(MatchResumeEntity); + } + + async getPlayerStats(userId: string, seasons: number[]): Promise { + const stats = await this.playerStatsRepository + .createQueryBuilder("stats") + .where("stats.user_id = :userId", { userId }) + .andWhere("stats.season IN (:...seasons)", { seasons }) + .getMany(); + + return stats.map(s => ({ + season: s.season, + wins: s.wins, + losses: s.losses, + points: s.points + })); + } + + async getTopRivals(userId: string, seasons: number[]): Promise { + const rivals: PlayerRival[] = []; + + for (const season of seasons) { + const matches = await this.matchRepository + .createQueryBuilder("match") + .where("match.userId = :userId", { userId }) + .andWhere("match.season = :season", { season }) + .getMany(); + + const rivalStats = new Map(); + + for (const match of matches) { + if (!match.opponentIds || match.opponentIds.length === 0) continue; + const rivalId = match.opponentIds[0]; // Assuming 1v1 mostly, taking first opponent + const rivalName = match.opponentNames && match.opponentNames.length > 0 ? match.opponentNames[0] : "Unknown"; + + if (!rivalStats.has(rivalId)) { + rivalStats.set(rivalId, { wins: 0, matches: 0, name: rivalName }); + } + + const stats = rivalStats.get(rivalId)!; + stats.matches++; + if (match.winner) { // If user is the winner? + // match.winner is a boolean. match.userId is the user. + // References say 'winner' column. + // Usually means "Did this player win?". + // Let's verify. Match.ts says "winner: boolean". + // MatchResumeEntity says "winner: boolean". + // In Evolution API, usually match is stored from perspective of userId. + // If winner is true, userId won. + // Wait, I need to know if the user won against the rival. + // If match.winner is true, user won. + // So rival lost. + // But "wins" in PlayerRival context usually means "User's wins against Rival". + if (match.winner) { + stats.wins++; + } + } + } + + // Find top rival + let topRivalId = ""; + let maxMatches = -1; + + for (const [id, stats] of rivalStats.entries()) { + if (stats.matches > maxMatches) { + maxMatches = stats.matches; + topRivalId = id; + } + } + + if (topRivalId) { + const stats = rivalStats.get(topRivalId)!; + rivals.push({ + season, + rivalId: topRivalId, + rivalName: stats.name, + matches: stats.matches, + wins: stats.wins + }); + } + } + + return rivals; + } +} + diff --git a/src/modules/wrapped/application/GenerateSeasonWrapped.ts b/src/modules/wrapped/application/GenerateSeasonWrapped.ts new file mode 100644 index 0000000..f264ae4 --- /dev/null +++ b/src/modules/wrapped/application/GenerateSeasonWrapped.ts @@ -0,0 +1,42 @@ +import type { WrappedRepository } from "../domain/WrappedRepository"; +import type { PdfGenerator } from "../infrastructure/PdfGenerator"; +import { config } from "../../../config"; + +export interface GenerateOptions { + locale?: string; + theme?: "dark" | "light"; + includeMatchList?: boolean; + singlePage?: boolean; +} + +export class GenerateSeasonWrapped { + constructor( + private readonly repository: WrappedRepository, + private readonly pdfGenerator: PdfGenerator, + ) { } + + async execute( + seasonId: number, + playerId: string, + options: GenerateOptions = {}, + ): Promise<{ pdf: Buffer; playerName: string }> { + // Prevent generating wrapped for the current/active season + if (seasonId >= config.season) { + throw new Error(`Season ${seasonId} Wrapped is not available yet.`); + } + const data = await this.repository.getSeasonWrappedData(seasonId, playerId); + + if (!data) { + throw new Error(`No data found for player ${playerId} in season ${seasonId}`); + } + + const pdf = await this.pdfGenerator.generate(data, { + locale: options.locale ?? "es", + theme: options.theme ?? "dark", + includeMatchList: options.includeMatchList ?? false, + singlePage: options.singlePage ?? false, + }); + + return { pdf, playerName: data.playerName }; + } +} diff --git a/src/modules/wrapped/application/GetSeasonWrappedData.ts b/src/modules/wrapped/application/GetSeasonWrappedData.ts new file mode 100644 index 0000000..dd604a8 --- /dev/null +++ b/src/modules/wrapped/application/GetSeasonWrappedData.ts @@ -0,0 +1,23 @@ +import type { SeasonWrapped } from "../domain/SeasonWrapped"; +import type { WrappedRepository } from "../domain/WrappedRepository"; + +import { config } from "../../../config"; + +export class GetSeasonWrappedData { + constructor(private readonly repository: WrappedRepository) { } + + async execute(seasonId: number, playerId: string): Promise { + // Prevent accessing wrapped data for the current/active season + if (seasonId >= config.season) { + throw new Error(`Season ${seasonId} Wrapped is not available yet.`); + } + + const data = await this.repository.getSeasonWrappedData(seasonId, playerId); + + if (!data) { + throw new Error(`No data found for player ${playerId} in season ${seasonId}`); + } + + return data; + } +} diff --git a/src/modules/wrapped/domain/Achievement.ts b/src/modules/wrapped/domain/Achievement.ts new file mode 100644 index 0000000..b0e5578 --- /dev/null +++ b/src/modules/wrapped/domain/Achievement.ts @@ -0,0 +1,7 @@ +export interface Achievement { + id: number; + name: string; + description: string; + icon: string; + unlockedAt: Date; +} diff --git a/src/modules/wrapped/domain/BanListStats.ts b/src/modules/wrapped/domain/BanListStats.ts new file mode 100644 index 0000000..731bcd8 --- /dev/null +++ b/src/modules/wrapped/domain/BanListStats.ts @@ -0,0 +1,17 @@ +export class BanListStats { + constructor( + public readonly banListName: string, + public readonly matches: number, + public readonly wins: number, + public readonly losses: number, + public readonly draws: number, + public readonly winrate: number, + public readonly topMatchup: string | null = null, + ) { } + + getFlavor(): string { + if (this.winrate >= 70) return "En esta banlist estabas on fire 🔥"; + if (this.winrate <= 40) return "En esta banlist sufriste un poco 😅"; + return "En esta banlist te mantuviste competitivo 💪"; + } +} diff --git a/src/modules/wrapped/domain/ExtraStats.ts b/src/modules/wrapped/domain/ExtraStats.ts new file mode 100644 index 0000000..8fbc02b --- /dev/null +++ b/src/modules/wrapped/domain/ExtraStats.ts @@ -0,0 +1,5 @@ +export interface ExtraStats { + mostPlayedBanList: string | null; + uniqueOpponents: number; + bestDay: string | null; +} diff --git a/src/modules/wrapped/domain/Nemesis.ts b/src/modules/wrapped/domain/Nemesis.ts new file mode 100644 index 0000000..9069c15 --- /dev/null +++ b/src/modules/wrapped/domain/Nemesis.ts @@ -0,0 +1,13 @@ +export class Nemesis { + constructor( + public readonly playerId: string, + public readonly playerName: string, + public readonly playerAvatar: string | null, + public readonly totalMatches: number, + public readonly wins: number, + public readonly losses: number, + public readonly winrate: number, + ) { } +} + +export type Victim = Nemesis; diff --git a/src/modules/wrapped/domain/PlayerRanking.ts b/src/modules/wrapped/domain/PlayerRanking.ts new file mode 100644 index 0000000..b831ebd --- /dev/null +++ b/src/modules/wrapped/domain/PlayerRanking.ts @@ -0,0 +1,14 @@ +export interface PlayerRanking { + position: number; + totalPlayers: number; + points: number; + rankBadge: string; +} + +export function calculateRankBadge(position: number): string { + if (position === 1) return "Champion"; + if (position <= 10) return "Grandmaster"; + if (position <= 50) return "Master"; + if (position <= 100) return "Diamond"; + return "Challenger"; +} diff --git a/src/modules/wrapped/domain/PlayerSeasonStats.ts b/src/modules/wrapped/domain/PlayerSeasonStats.ts new file mode 100644 index 0000000..79a40bf --- /dev/null +++ b/src/modules/wrapped/domain/PlayerSeasonStats.ts @@ -0,0 +1,20 @@ +export class PlayerSeasonStats { + constructor( + public readonly totalMatches: number, + public readonly wins: number, + public readonly losses: number, + public readonly draws: number, + public readonly winrate: number, + public readonly bestWinStreak: number, + public readonly worstLoseStreak: number, + public readonly avgMatchesPerDay: number, + public readonly avgMatchesPerWeek: number, + public readonly firstMatchDate: Date | null, + public readonly lastMatchDate: Date | null, + public readonly activeDays: number, + ) { } + + static createEmpty(): PlayerSeasonStats { + return new PlayerSeasonStats(0, 0, 0, 0, 0, 0, 0, 0, 0, null, null, 0); + } +} diff --git a/src/modules/wrapped/domain/SeasonWrapped.ts b/src/modules/wrapped/domain/SeasonWrapped.ts new file mode 100644 index 0000000..220c29b --- /dev/null +++ b/src/modules/wrapped/domain/SeasonWrapped.ts @@ -0,0 +1,28 @@ +import type { Achievement } from "./Achievement"; +import type { BanListStats } from "./BanListStats"; +import type { ExtraStats } from "./ExtraStats"; +import type { Nemesis, Victim } from "./Nemesis"; +import type { PlayerRanking } from "./PlayerRanking"; +import type { PlayerSeasonStats } from "./PlayerSeasonStats"; + +export class SeasonWrapped { + constructor( + public readonly playerId: string, + public readonly playerName: string, + public readonly playerAvatar: string | null, + public readonly seasonId: number, + public readonly seasonName: string, + public readonly seasonDates: { start: Date; end: Date }, + public readonly globalStats: PlayerSeasonStats, + public readonly banListStats: BanListStats[], + public readonly nemesis: Nemesis | null, + public readonly victim: Victim | null, + public readonly achievements: Achievement[], + public readonly ranking: PlayerRanking, + public readonly extraStats: ExtraStats, + ) { } + + isEmpty(): boolean { + return this.globalStats.totalMatches === 0; + } +} diff --git a/src/modules/wrapped/domain/WrappedRepository.ts b/src/modules/wrapped/domain/WrappedRepository.ts new file mode 100644 index 0000000..420ef51 --- /dev/null +++ b/src/modules/wrapped/domain/WrappedRepository.ts @@ -0,0 +1,5 @@ +import type { SeasonWrapped } from "./SeasonWrapped"; + +export interface WrappedRepository { + getSeasonWrappedData(seasonId: number, playerId: string): Promise; +} diff --git a/src/modules/wrapped/infrastructure/PdfGenerator.ts b/src/modules/wrapped/infrastructure/PdfGenerator.ts new file mode 100644 index 0000000..6132432 --- /dev/null +++ b/src/modules/wrapped/infrastructure/PdfGenerator.ts @@ -0,0 +1,44 @@ +import { chromium } from "playwright"; +import type { SeasonWrapped } from "../domain/SeasonWrapped"; +import { renderTemplate, renderSinglePageTemplate } from "./templates/templateRenderer"; + +export interface GenerateOptions { + locale: string; + theme: "dark" | "light"; + includeMatchList: boolean; + singlePage?: boolean; +} + +export class PdfGenerator { + async generate(data: SeasonWrapped, options: GenerateOptions): Promise { + const html = options.singlePage + ? renderSinglePageTemplate(data, options) + : renderTemplate(data, options); + + const browser = await chromium.launch({ + headless: true, + }); + + const page = await browser.newPage(); + + await page.setContent(html, { + waitUntil: "networkidle", + }); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + preferCSSPageSize: true, + }); + + await browser.close(); + + return Buffer.from(pdf); + } +} diff --git a/src/modules/wrapped/infrastructure/WrappedController.ts b/src/modules/wrapped/infrastructure/WrappedController.ts new file mode 100644 index 0000000..a8ea3ff --- /dev/null +++ b/src/modules/wrapped/infrastructure/WrappedController.ts @@ -0,0 +1,77 @@ +import { GenerateSeasonWrapped } from "../application/GenerateSeasonWrapped"; +import { GetSeasonWrappedData } from "../application/GetSeasonWrappedData"; +import { PdfGenerator } from "./PdfGenerator"; +import { WrappedPostgresRepository } from "./WrappedPostgresRepository"; + +// Domain errors +export class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +export class WrappedController { + async generatePdf(context: { + params: { seasonId: string; playerId: string }; + query: { locale?: string; theme?: "dark" | "light"; includeMatchList?: string; singlePage?: string }; + }): Promise<{ pdf: Buffer; seasonId: number; playerId: string; playerName: string }> { + const seasonId = parseInt(context.params.seasonId, 10); + const { playerId } = context.params; + + // Validation + if (isNaN(seasonId) || seasonId < 1) { + throw new ValidationError("Season ID must be a valid positive integer"); + } + + if (!playerId || !/^[a-f0-9-]{36}$/i.test(playerId)) { + throw new ValidationError("Player ID must be a valid UUID"); + } + + const repository = new WrappedPostgresRepository(); + const pdfGenerator = new PdfGenerator(); + const useCase = new GenerateSeasonWrapped(repository, pdfGenerator); + + const { pdf, playerName } = await useCase.execute(seasonId, playerId, { + locale: context.query.locale || "es", + theme: context.query.theme || "dark", + includeMatchList: context.query.includeMatchList === "true", + singlePage: context.query.singlePage === "true", + }); + + return { pdf, seasonId, playerId, playerName }; + } + + async getData(context: { params: { seasonId: string; playerId: string } }) { + const seasonId = parseInt(context.params.seasonId, 10); + const { playerId } = context.params; + + // Validation + if (isNaN(seasonId) || seasonId < 1) { + throw new ValidationError("Season ID must be a valid positive integer"); + } + + if (!playerId || !/^[a-f0-9-]{36}$/i.test(playerId)) { + throw new ValidationError("Player ID must be a valid UUID"); + } + + const repository = new WrappedPostgresRepository(); + const useCase = new GetSeasonWrappedData(repository); + + const result = await useCase.execute(seasonId, playerId); + + // Check if data exists + if (!result || result.globalStats.totalMatches === 0) { + throw new NotFoundError(`No wrapped data found for player ${playerId} in season ${seasonId}`); + } + + return JSON.parse(JSON.stringify(result)); + } +} diff --git a/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts b/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts new file mode 100644 index 0000000..8ff7079 --- /dev/null +++ b/src/modules/wrapped/infrastructure/WrappedPostgresRepository.ts @@ -0,0 +1,466 @@ +import { dataSource } from "../../../evolution-types/src/data-source"; +import type { Achievement } from "../domain/Achievement"; +import { BanListStats } from "../domain/BanListStats"; +import type { ExtraStats } from "../domain/ExtraStats"; +import { Nemesis } from "../domain/Nemesis"; +import { calculateRankBadge, type PlayerRanking } from "../domain/PlayerRanking"; +import { PlayerSeasonStats } from "../domain/PlayerSeasonStats"; +import { SeasonWrapped } from "../domain/SeasonWrapped"; +import type { WrappedRepository } from "../domain/WrappedRepository"; + +export class WrappedPostgresRepository implements WrappedRepository { + async getSeasonWrappedData(seasonId: number, playerId: string): Promise { + // Check if player exists + const player = await dataSource.query( + "SELECT id, username, avatar FROM users WHERE id = $1 AND deleted_at IS NULL", + [playerId], + ); + + if (!player || player.length === 0) { + return null; + } + + const playerData = player[0]; + + // Get global stats + const globalStats = await this.getGlobalStats(seasonId, playerId); + + // If no matches, return empty wrapped + if (globalStats.totalMatches === 0) { + return new SeasonWrapped( + playerId, + playerData.username, + playerData.avatar, + seasonId, + `Season ${seasonId}`, + { start: new Date(), end: new Date() }, + globalStats, + [], + null, + null, + [], + { position: 0, totalPlayers: 0, points: 0, rankBadge: "Challenger" }, + { mostPlayedBanList: null, uniqueOpponents: 0, bestDay: null }, + ); + } + + // Get stats per ban list + const banListStats = await this.getBanListStats(seasonId, playerId); + + // Get nemesis and victim + const nemesis = await this.getNemesis(seasonId, playerId); + const victim = await this.getVictim(seasonId, playerId); + + // Get achievements + const achievements = await this.getAchievements(seasonId, playerId); + + // Get ranking + const ranking = await this.getRanking(seasonId, playerId); + + // Get extra stats + const extraStats = await this.getExtraStats(seasonId, playerId, banListStats); + + return new SeasonWrapped( + playerId, + playerData.username, + playerData.avatar, + seasonId, + `Season ${seasonId}`, + { + start: globalStats.firstMatchDate ?? new Date(), + end: globalStats.lastMatchDate ?? new Date(), + }, + globalStats, + banListStats, + nemesis, + victim, + achievements, + ranking, + extraStats, + ); + } + + private async getGlobalStats(seasonId: number, playerId: string): Promise { + + const result = await dataSource.query( + ` + SELECT + COUNT(*)::int AS total_matches, + COUNT(*) FILTER (WHERE winner = true)::int AS wins, + COUNT(*) FILTER (WHERE winner = false)::int AS losses, + COUNT(*) FILTER (WHERE player_score = opponent_score)::int AS draws, + COALESCE( + (COUNT(*) FILTER (WHERE winner = true)::float / + NULLIF(COUNT(*) FILTER (WHERE winner = true OR winner = false), 0)) * 100, + 0 + ) AS winrate, + MIN(date) AS first_match, + MAX(date) AS last_match, + COUNT(DISTINCT DATE(date))::int AS active_days + FROM matches + WHERE season = $1 + AND user_id = $2 + AND anulled = false + AND deleted_at IS NULL + `, + [seasonId, playerId], + ); + + const stats = result[0]; + + // Calculate streaks + const streaks = await this.calculateStreaks(seasonId, playerId); + + // Calculate avg matches per day and week + const avgMatchesPerDay = stats.active_days > 0 ? stats.total_matches / stats.active_days : 0; + const avgMatchesPerWeek = avgMatchesPerDay * 7; + + return new PlayerSeasonStats( + stats.total_matches, + stats.wins, + stats.losses, + stats.draws, + Math.round(stats.winrate * 10) / 10, + streaks.bestWinStreak, + streaks.worstLoseStreak, + Math.round(avgMatchesPerDay * 10) / 10, + Math.round(avgMatchesPerWeek * 10) / 10, + stats.first_match, + stats.last_match, + stats.active_days, + ); + } + + private async calculateStreaks( + seasonId: number, + playerId: string, + ): Promise<{ bestWinStreak: number; worstLoseStreak: number }> { + const matches = await dataSource.query( + ` + SELECT winner + FROM matches + WHERE season = $1 + AND user_id = $2 + AND anulled = false + AND deleted_at IS NULL + ORDER BY date ASC + `, + [seasonId, playerId], + ); + + let bestWinStreak = 0; + let currentWinStreak = 0; + let worstLoseStreak = 0; + let currentLoseStreak = 0; + + for (const match of matches) { + if (match.winner) { + currentWinStreak++; + currentLoseStreak = 0; + bestWinStreak = Math.max(bestWinStreak, currentWinStreak); + } else { + currentLoseStreak++; + currentWinStreak = 0; + worstLoseStreak = Math.max(worstLoseStreak, currentLoseStreak); + } + } + + return { bestWinStreak, worstLoseStreak }; + } + + private async getBanListStats(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` + SELECT + ban_list_name, + COUNT(*)::int AS matches, + COUNT(*) FILTER (WHERE winner = true)::int AS wins, + COUNT(*) FILTER (WHERE winner = false)::int AS losses, + COUNT(*) FILTER (WHERE player_score = opponent_score)::int AS draws, + COALESCE( + (COUNT(*) FILTER (WHERE winner = true)::float / + NULLIF(COUNT(*) FILTER (WHERE winner = true OR winner = false), 0)) * 100, + 0 + ) AS winrate + FROM matches + WHERE season = $1 + AND user_id = $2 + AND anulled = false + AND deleted_at IS NULL + GROUP BY ban_list_name + ORDER BY matches DESC + `, + [seasonId, playerId], + ); + + return results.map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (row: any) => + new BanListStats( + row.ban_list_name, + row.matches, + row.wins, + row.losses, + row.draws, + Math.round(row.winrate * 10) / 10, + null, // topMatchup not implemented yet + ), + ); + } + + private async getNemesis(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` + WITH opponent_stats AS ( + SELECT + UNNEST(string_to_array(opponent_ids, ',')) AS opponent_id, + COUNT(*)::int AS total_matches, + COUNT(*) FILTER (WHERE winner = true)::int AS wins, + COUNT(*) FILTER (WHERE winner = false)::int AS losses + FROM matches + WHERE season = $1 + AND user_id = $2 + AND opponent_ids IS NOT NULL + AND anulled = false + AND deleted_at IS NULL + GROUP BY opponent_id + ) + SELECT + os.opponent_id, + u.username AS opponent_name, + u.avatar AS opponent_avatar, + os.total_matches, + os.wins, + os.losses, + COALESCE( + (os.wins::float / NULLIF(os.total_matches, 0)) * 100, + 0 + ) AS winrate + FROM opponent_stats os + INNER JOIN users u ON u.id = os.opponent_id + WHERE u.deleted_at IS NULL + ORDER BY os.losses DESC, os.total_matches DESC + LIMIT 1 + `, + [seasonId, playerId], + ); + + if (results.length === 0) { + return null; + } + + const nemesis = results[0]; + return new Nemesis( + nemesis.opponent_id, + nemesis.opponent_name, + nemesis.opponent_avatar, + nemesis.total_matches, + nemesis.wins, + nemesis.losses, + Math.round(nemesis.winrate * 10) / 10, + ); + } + + private async getVictim(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` + WITH opponent_stats AS ( + SELECT + UNNEST(string_to_array(opponent_ids, ',')) AS opponent_id, + COUNT(*)::int AS total_matches, + COUNT(*) FILTER (WHERE winner = true)::int AS wins, + COUNT(*) FILTER (WHERE winner = false)::int AS losses + FROM matches + WHERE season = $1 + AND user_id = $2 + AND opponent_ids IS NOT NULL + AND anulled = false + AND deleted_at IS NULL + GROUP BY opponent_id + ) + SELECT + os.opponent_id, + u.username AS opponent_name, + u.avatar AS opponent_avatar, + os.total_matches, + os.wins, + os.losses, + COALESCE( + (os.wins::float / NULLIF(os.total_matches, 0)) * 100, + 0 + ) AS winrate + FROM opponent_stats os + INNER JOIN users u ON u.id = os.opponent_id + WHERE u.deleted_at IS NULL + ORDER BY os.wins DESC, os.total_matches DESC + LIMIT 1 + `, + [seasonId, playerId], + ); + + if (results.length === 0) { + return null; + } + + const victim = results[0]; + return new Nemesis( + victim.opponent_id, + victim.opponent_name, + victim.opponent_avatar, + victim.total_matches, + victim.wins, + victim.losses, + Math.round(victim.winrate * 10) / 10, + ); + } + + private async getAchievements(seasonId: number, playerId: string): Promise { + const results = await dataSource.query( + ` + SELECT + a.id, + a.name, + a.description, + a.icon, + ua.unlocked_at + FROM user_achievements ua + INNER JOIN achievements a ON a.id = ua.achievement_id + WHERE ua.user_id = $1 + AND ua.season = $2 + ORDER BY ua.unlocked_at DESC + `, + [playerId, seasonId], + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return results.map((row: any) => ({ + id: row.id, + name: row.name, + description: row.description, + icon: row.icon, + unlockedAt: row.unlocked_at, + })); + } + + private async getRanking(seasonId: number, playerId: string): Promise { + // Get total points for this player + const playerPoints = await dataSource.query( + ` + SELECT points as total_points + FROM player_stats + WHERE user_id = $1 + AND season = $2 + AND ban_list_name = 'Global' + `, + [playerId, seasonId], + ); + + const points = playerPoints[0]?.total_points ?? 0; + + // Get ranking position + const ranking = await dataSource.query( + ` + WITH player_totals AS ( + SELECT + user_id, + points AS total_points + FROM player_stats + WHERE season = $1 + AND ban_list_name = 'Global' + ), + ranked AS ( + SELECT + user_id, + total_points, + RANK() OVER (ORDER BY total_points DESC) AS position + FROM player_totals + ) + SELECT + position::int, + (SELECT COUNT(DISTINCT user_id)::int FROM player_totals) AS total_players + FROM ranked + WHERE user_id = $2 + `, + [seasonId, playerId], + ); + + if (ranking.length === 0) { + return { + position: 0, + totalPlayers: 0, + points, + rankBadge: "Challenger", + }; + } + + const position = ranking[0].position; + const totalPlayers = ranking[0].total_players; + + return { + position, + totalPlayers, + points, + rankBadge: calculateRankBadge(position), + }; + } + + private async getExtraStats( + seasonId: number, + playerId: string, + banListStats: BanListStats[], + ): Promise { + // Most played ban list + const mostPlayedBanList = + banListStats.length > 0 ? banListStats[0].banListName : null; + + // Unique opponents + const uniqueOpponents = await dataSource.query( + ` + WITH unnested_opponents AS ( + SELECT UNNEST(string_to_array(opponent_ids, ',')) AS opponent_id + FROM matches + WHERE season = $1 + AND user_id = $2 + AND opponent_ids IS NOT NULL + AND anulled = false + AND deleted_at IS NULL + ) + SELECT COUNT(DISTINCT opponent_id)::int AS unique_opponents + FROM unnested_opponents + `, + [seasonId, playerId], + ); + + // Best day of the week + const bestDayResult = await dataSource.query( + ` + SELECT + TO_CHAR(date, 'Day') AS day_name, + COUNT(*) FILTER (WHERE winner = true)::int AS wins, + COUNT(*)::int AS total, + COALESCE( + (COUNT(*) FILTER (WHERE winner = true)::float / NULLIF(COUNT(*), 0)) * 100, + 0 + ) AS winrate + FROM matches + WHERE season = $1 + AND user_id = $2 + AND anulled = false + AND deleted_at IS NULL + GROUP BY day_name + HAVING COUNT(*) >= 3 + ORDER BY winrate DESC, total DESC + LIMIT 1 + `, + [seasonId, playerId], + ); + + const bestDay = bestDayResult.length > 0 ? bestDayResult[0].day_name.trim() : null; + + return { + mostPlayedBanList, + uniqueOpponents: uniqueOpponents[0]?.unique_opponents ?? 0, + bestDay, + }; + } +} diff --git a/src/modules/wrapped/infrastructure/WrappedStorageService.ts b/src/modules/wrapped/infrastructure/WrappedStorageService.ts new file mode 100644 index 0000000..f03b90f --- /dev/null +++ b/src/modules/wrapped/infrastructure/WrappedStorageService.ts @@ -0,0 +1,31 @@ +import { existsSync, mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; + +export class WrappedStorageService { + private readonly storagePath: string; + + constructor() { + this.storagePath = join(process.cwd(), "storage", "wrapped"); + this.ensureDirectoryExists(); + } + + private ensureDirectoryExists(): void { + if (!existsSync(this.storagePath)) { + mkdirSync(this.storagePath, { recursive: true }); + } + } + + getFilePath(seasonId: number, playerId: string, locale: string, theme: string): string { + return join(this.storagePath, `season_${seasonId}_${playerId}_${locale}_${theme}.pdf`); + } + + exists(seasonId: number, playerId: string, locale: string, theme: string): boolean { + const filePath = this.getFilePath(seasonId, playerId, locale, theme); + return existsSync(filePath); + } + + save(seasonId: number, playerId: string, locale: string, theme: string, buffer: Buffer): void { + const filePath = this.getFilePath(seasonId, playerId, locale, theme); + writeFileSync(filePath, buffer); + } +} diff --git a/src/modules/wrapped/infrastructure/templates/optimized/README.md b/src/modules/wrapped/infrastructure/templates/optimized/README.md new file mode 100644 index 0000000..0c8eb94 --- /dev/null +++ b/src/modules/wrapped/infrastructure/templates/optimized/README.md @@ -0,0 +1,27 @@ +# Optimized Yu-Gi-Oh! Themed Images + +These images are used as decorative backgrounds and icons in the Season Wrapped PDF. + +## Image Files + +| Filename | Purpose | Size | Optimization | +|----------|---------|------|--------------| +| `yugioh_dragon_background.png` | Cover page & summary background | 252 KB | Resized to 400px, 30% quality | +| `yugioh_monster_background.png` | Stats pages background | 252 KB | Resized to 400px, 30% quality | +| `yugioh_battlefield_background.png` | Format pages background | 231 KB | Resized to 400px, 30% quality | +| `yugioh_cards_background.png` | Rivals page background | 191 KB | Resized to 400px, 30% quality | +| `yugioh_chapter_icon.png` | Chapter section icons | 4.6 KB | Resized to 60px, 70% quality | + +## Total Size +- **Original images**: ~3 MB +- **Optimized images**: ~900 KB +- **Space saved**: ~70% reduction + +## Usage + +Images are loaded as base64 in `templateRenderer.ts` and applied as: +- Page backgrounds at 6% opacity with blue tint filter +- Chapter icons at 40px size next to section headings + +## Original Source +Original PNG files were sourced from Yu-Gi-Oh! artwork and optimized using ImageMagick. diff --git a/src/modules/wrapped/infrastructure/templates/optimized/bg1.png b/src/modules/wrapped/infrastructure/templates/optimized/bg1.png new file mode 100644 index 0000000..158e8a3 Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/bg1.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/bg2.png b/src/modules/wrapped/infrastructure/templates/optimized/bg2.png new file mode 100644 index 0000000..17bcd53 Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/bg2.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/bg3.png b/src/modules/wrapped/infrastructure/templates/optimized/bg3.png new file mode 100644 index 0000000..47f93fc Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/bg3.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/bg4.png b/src/modules/wrapped/infrastructure/templates/optimized/bg4.png new file mode 100644 index 0000000..f41d91c Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/bg4.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/yugioh_battlefield_background.png b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_battlefield_background.png new file mode 100644 index 0000000..3f19831 Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_battlefield_background.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/yugioh_cards_background.png b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_cards_background.png new file mode 100644 index 0000000..e16ffb7 Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_cards_background.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/yugioh_chapter_icon.png b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_chapter_icon.png new file mode 100644 index 0000000..032b918 Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_chapter_icon.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/yugioh_dragon_background.png b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_dragon_background.png new file mode 100644 index 0000000..40a055e Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_dragon_background.png differ diff --git a/src/modules/wrapped/infrastructure/templates/optimized/yugioh_monster_background.png b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_monster_background.png new file mode 100644 index 0000000..7f04c85 Binary files /dev/null and b/src/modules/wrapped/infrastructure/templates/optimized/yugioh_monster_background.png differ diff --git a/src/modules/wrapped/infrastructure/templates/styles.css b/src/modules/wrapped/infrastructure/templates/styles.css new file mode 100644 index 0000000..ac8e248 --- /dev/null +++ b/src/modules/wrapped/infrastructure/templates/styles.css @@ -0,0 +1,874 @@ +:root { + /* Spacing System */ + --space-unit: 8px; + --space-xs: calc(var(--space-unit) * 1); + --space-sm: calc(var(--space-unit) * 2); + --space-md: calc(var(--space-unit) * 3); + --space-lg: calc(var(--space-unit) * 4); + --space-xl: calc(var(--space-unit) * 6); + --space-2xl: calc(var(--space-unit) * 8); + --space-3xl: calc(var(--space-unit) * 10); + + /* Typography */ + --font-primary: 'Inter', system-ui, -apple-system, sans-serif; + + /* Font Sizes */ + --text-xs: 12px; + --text-sm: 14px; + --text-base: 16px; + --text-lg: 18px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 32px; + --text-4xl: 48px; + --text-5xl: 64px; + --text-6xl: 96px; + + /* Borders */ + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 24px; + --radius-xl: 32px; + --radius-full: 9999px; + + /* Modern Dark Theme */ + --bg-base: #0B1120; + /* Very dark blue/slate */ + --bg-card: #151e32; + /* Rich dark blue */ + --bg-card-hover: #1e2942; + --bg-highlight: #1E293B; + + --border-subtle: rgba(255, 255, 255, 0.05); + --border-medium: rgba(59, 130, 246, 0.2); + + --text-primary: #FFFFFF; + --text-secondary: #94A3B8; + --text-muted: #64748B; + + /* Semantic Colors */ + --color-win: #3B82F6; + /* Blue for wins as per image */ + --color-loss: #64748B; + /* Muted slate for losses */ + --color-accent: #3B82F6; + /* Primary Blue */ + --color-accent-glow: rgba(59, 130, 246, 0.5); + + --gradient-primary: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%); + --gradient-dark: linear-gradient(180deg, rgba(15, 23, 42, 0) 0%, rgba(15, 23, 42, 0.8) 100%); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-primary); + background: #1a1a1a; + /* Dark background for HTML view */ + color: var(--text-primary); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + /* Centering logic for HTML view */ + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + padding-top: 20px; + padding-bottom: 20px; +} + +/* Page Layout */ +.page { + width: 210mm; + height: 297mm; + /* Exact A4 height */ + background: var(--bg-base); + position: relative; + page-break-after: always; + page-break-inside: avoid; + padding: 60px 40px; + box-sizing: border-box; + overflow: hidden; + /* Prevent content overflow causing gaps */ + display: flex; + flex-direction: column; + /* Centering and shadow */ + margin-bottom: 20px; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); +} + +.page:last-child { + page-break-after: avoid; +} + +/* Decorative Background Images */ +.page-bg-decoration { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + opacity: 0.06; + z-index: 0; + pointer-events: none; + filter: hue-rotate(200deg) brightness(0.7) saturate(1.5); + mix-blend-mode: screen; + overflow: hidden; +} + +.page-bg-decoration img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* Chapter Icon */ +.chapter-icon { + width: 40px; + height: 40px; + display: inline-block; + vertical-align: middle; + margin-right: var(--space-sm); + filter: hue-rotate(200deg) brightness(0.8) saturate(2); + opacity: 0.7; +} + +/* Ensure content is above backgrounds */ +.page>*:not(.page-bg-decoration) { + position: relative; + z-index: 1; +} + +/* Header/Top Bar */ +.header-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-xl); + border-bottom: 1px solid var(--border-subtle); + padding-bottom: var(--space-md); +} + +.brand { + font-weight: 700; + font-size: var(--text-lg); + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.brand-icon { + color: var(--color-accent); +} + +.season-tag { + font-size: var(--text-sm); + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Chapter & Titles */ +.chapter-super { + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 2px; + font-weight: 700; + font-size: var(--text-sm); + margin-bottom: var(--space-xs); + text-align: center; +} + +.page-title { + font-size: var(--text-4xl); + font-weight: 800; + text-align: center; + margin-bottom: var(--space-2xl); + letter-spacing: -1px; + text-transform: uppercase; + position: relative; + display: inline-block; + width: 100%; +} + +.page-title::after { + content: ''; + display: block; + width: 60px; + height: 4px; + background: var(--color-accent); + margin: var(--space-sm) auto 0; + border-radius: var(--radius-full); +} + +/* Cards */ +.card-container { + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: var(--space-xl); + position: relative; + overflow: hidden; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border: 1px solid var(--border-subtle); +} + +/* Main Stats Display (Wins/Losses) */ +.main-stats-card { + background: --bg-card; + border-radius: var(--radius-lg); + padding: var(--space-2xl); + text-align: center; + border: 1px solid var(--border-medium); + box-shadow: 0 0 40px rgba(59, 130, 246, 0.05); + margin-bottom: var(--space-lg); + background: linear-gradient(180deg, var(--bg-card) 0%, rgba(30, 41, 59, 0.5) 100%); +} + +.tag-badge { + display: inline-flex; + align-items: center; + background: rgba(59, 130, 246, 0.1); + color: var(--color-accent); + padding: var(--space-xs) var(--space-md); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 700; + margin-bottom: var(--space-xl); + border: 1px solid rgba(59, 130, 246, 0.2); +} + +.big-stats-row { + display: flex; + justify-content: center; + gap: var(--space-3xl); + margin-bottom: var(--space-xl); +} + +.stat-group { + display: flex; + flex-direction: column; + align-items: center; +} + +.stat-group-label { + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: var(--space-xs); +} + +.stat-group-value { + font-size: var(--text-6xl); + font-weight: 800; + line-height: 1; +} + +.stat-group-value.wins { + color: var(--color-win); + text-shadow: 0 0 30px rgba(59, 130, 246, 0.3); +} + +.stat-group-value.losses { + color: var(--text-muted); +} + +/* Winrate Bar */ +.winrate-section { + max-width: 400px; + margin: 0 auto; +} + +.winrate-header { + display: flex; + justify-content: space-between; + margin-bottom: var(--space-xs); + font-size: var(--text-sm); + font-weight: 700; +} + +.progress-track { + height: 8px; + background: var(--bg-base); + border-radius: var(--radius-full); + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--color-accent); + border-radius: var(--radius-full); +} + +/* Grid Layouts */ +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-lg); +} + +.grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: var(--space-lg); +} + +/* Stat Box (Small Cards) */ +.stat-box { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: var(--space-lg); + border: 1px solid var(--border-subtle); +} + +.stat-box-icon { + color: var(--color-accent); + font-size: var(--text-xl); + margin-bottom: var(--space-sm); +} + +.stat-box-label { + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: 600; +} + +.stat-box-value { + font-size: var(--text-4xl); + font-weight: 800; + color: white; + margin: var(--space-xs) 0; +} + +.stat-box-sub { + font-size: var(--text-xs); + color: var(--text-muted); +} + +/* Nemesis / Avatar Card */ +.profile-card { + display: flex; + align-items: center; + gap: var(--space-lg); + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: var(--space-lg); + border: 1px solid var(--border-subtle); +} + +.profile-avatar { + width: 80px; + height: 80px; + border-radius: var(--radius-full); + border: 2px solid var(--border-medium); +} + +.profile-info h3 { + font-size: var(--text-xl); + font-weight: 700; + color: var(--color-accent); + margin-bottom: var(--space-xs); +} + +.profile-stats-row { + display: flex; + gap: var(--space-md); +} + +.pill { + background: rgba(255, 255, 255, 0.05); + padding: 4px 12px; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; +} + +.pill.highlight { + background: rgba(59, 130, 246, 0.1); + color: var(--color-accent); +} + +/* Charts */ +.chart-bar-item { + margin-bottom: var(--space-md); +} + +.chart-bar-header { + display: flex; + justify-content: space-between; + margin-bottom: var(--space-xs); + font-size: var(--text-sm); + font-weight: 600; +} + +.chart-track { + background: var(--bg-base); + height: 12px; + border-radius: var(--radius-full); + overflow: hidden; +} + +.chart-fill { + background: var(--bg-highlight); + height: 100%; + border-radius: var(--radius-full); +} + +.chart-fill.active { + background: var(--color-accent); +} + +/* Cover Page */ +.cover-page { + justify-content: center; + align-items: center; + text-align: center; +} + +.cover-avatar { + width: 150px; + height: 150px; + border-radius: 50%; + border: 4px solid var(--color-accent); + margin-bottom: var(--space-xl); + box-shadow: 0 0 30px rgba(59, 130, 246, 0.4); + flex-shrink: 0; + object-fit: cover; +} + +.cover-hero-container { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: var(--space-lg); +} + +.cover-hero-image { + width: 300px; + height: 200px; + display: flex; + justify-content: center; + align-items: center; +} + +.cover-title { + font-size: 64px; + font-weight: 900; + line-height: 1.1; + margin: 0 0 var(--space-md) 0; + color: #FFFFFF !important; + /* Force white, no gradients */ + text-transform: uppercase; + letter-spacing: -0.02em; + text-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + /* Add shadow for depth instead of gradient */ + background: transparent !important; + -webkit-text-fill-color: #FFFFFF !important; + padding: 10px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 24px; + margin: var(--space-lg) 0; + font-weight: 400; +} + +.footer { + position: absolute; + bottom: var(--space-xl); + width: 100%; + text-align: center; + color: var(--text-muted); + font-size: var(--text-sm); +} + +/* Summary Page - Shareable Card Styles */ + +.summary-page { + width: 210mm; + height: 297mm; + /* Exact A4 height */ + background: var(--bg-base); + position: relative; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + padding: 60px 40px; + box-sizing: border-box; + overflow: hidden; + /* Prevent overflow */ + page-break-after: avoid; + /* Last page, no break after */ +} + +.summary-container { + max-width: 600px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-lg); +} + +.summary-avatar { + width: 120px; + height: 120px; + border-radius: 50%; + /* Explicit roundness */ + border: 4px solid var(--color-accent); + box-shadow: 0 0 30px rgba(59, 130, 246, 0.4); + /* Reduced shadow spread */ + margin-bottom: var(--space-md); + /* Ensure strictly no overflow issues */ + flex-shrink: 0; +} + +.summary-title { + display: none; + /* Hide completely as requested for last page */ +} + +.summary-subtitle { + font-size: 20px; + color: var(--text-secondary); + margin: 0; + font-weight: 500; +} + +.summary-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-lg); + width: 100%; + margin-top: var(--space-lg); +} + +.summary-stat-box { + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-lg); + padding: var(--space-lg) var(--space-md); + text-align: center; + transition: all 0.3s ease; +} + +.summary-stat-box.primary { + border-color: var(--color-accent); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, var(--bg-secondary) 100%); + box-shadow: 0 0 30px rgba(59, 130, 246, 0.2); +} + +.summary-stat-box.success { + border-color: #10b981; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, var(--bg-secondary) 100%); + box-shadow: 0 0 30px rgba(16, 185, 129, 0.2); +} + +.summary-stat-value { + font-size: 56px; + font-weight: 900; + color: var(--text-primary); + line-height: 1; + margin-bottom: var(--space-xs); +} + +.summary-stat-label { + font-size: 14px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.summary-highlights { + display: flex; + flex-direction: column; + gap: var(--space-md); + width: 100%; + margin-top: var(--space-md); +} + +.summary-highlight { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + padding: var(--space-md) var(--space-lg); + font-size: 16px; + color: var(--text-secondary); + text-align: left; +} + +.summary-highlight strong { + color: var(--text-primary); + font-weight: 700; +} + +.summary-rivals { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-lg); + width: 100%; + margin-top: var(--space-lg); +} + +.summary-rival { + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-lg); + padding: var(--space-lg); + display: flex; + align-items: center; + gap: var(--space-md); + text-align: left; +} + +.summary-rival.nemesis { + border-color: rgba(239, 68, 68, 0.5); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, var(--bg-secondary) 100%); +} + +.summary-rival.victim { + border-color: rgba(16, 185, 129, 0.5); + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, var(--bg-secondary) 100%); +} + +.summary-rival-badge { + font-size: 36px; + line-height: 1; + flex-shrink: 0; +} + +.summary-rival-info { + flex: 1; +} + +.summary-rival-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; + font-weight: 600; +} + +.summary-rival-name { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; +} + +.summary-rival-stat { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.summary-footer { + margin-top: var(--space-xxl); + padding-top: var(--space-lg); +} + +/* Achievement Page Styles */ +.achievements-list { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 32px; + padding: 0; +} + +.achievement-card { + background: rgba(17, 24, 39, 0.6); + border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 20px; + padding: 24px; + display: flex; + align-items: center; + gap: 24px; + position: relative; + overflow: hidden; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.achievement-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, rgba(234, 179, 8, 0.05) 0%, transparent 100%); + pointer-events: none; +} + +.achievement-icon-container { + width: 80px; + height: 80px; + background: rgba(17, 24, 39, 0.8); + border: 2px solid #eab308; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + color: #eab308; + flex-shrink: 0; + box-shadow: 0 0 15px rgba(234, 179, 8, 0.2); + z-index: 1; +} + +.achievement-content { + flex-grow: 1; + z-index: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.achievement-badge { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + width: fit-content; + color: #eab308 !important; + background: rgba(234, 179, 8, 0.1) !important; + border: 1px solid rgba(234, 179, 8, 0.2) !important; + margin-bottom: 4px; +} + +.achievement-title { + font-size: 20px; + font-weight: 800; + color: #ffffff; + margin: 0; + line-height: 1.2; +} + +.achievement-description { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.4; + margin: 4px 0 0 0; +} + +.achievement-date { + background: rgba(255, 255, 255, 0.1); + padding: 6px 14px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + z-index: 1; + align-self: center; +} + +/* Force refresh: v3 */ +@media print { + body { + display: block; + margin: 0; + padding: 0; + background: transparent; + } + + .page { + margin: 0; + box-shadow: none; + page-break-after: always; + width: 210mm; + height: 297mm; + } +} + +/* Mobile Optimization */ +@media screen and (max-width: 768px) { + body { + padding: 0; + background: #111; + } + + .page { + width: 100% !important; + height: auto !important; + min-height: 100vh; + margin-bottom: 0; + border-radius: 0; + box-shadow: none; + padding: 24px 16px; + } + + .cover-title { + font-size: 48px !important; + } + + .cover-avatar { + width: 120px !important; + height: 120px !important; + } + + .summary-stats-grid { + grid-template-columns: 1fr !important; + gap: 16px !important; + } + + .grid-2 { + grid-template-columns: 1fr !important; + } + + .charts-container { + grid-template-columns: 1fr !important; + } +} +/* Download Floating Action Button */ +.download-fab { + position: fixed; + bottom: 32px; + right: 32px; + width: 64px; + height: 64px; + background: var(--color-accent); + color: white; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + cursor: pointer; + z-index: 1000; + transition: transform 0.2s, background 0.2s; + text-decoration: none; +} + +.download-fab:hover { + transform: scale(1.1); + background: #2563EB; +} + +.download-fab svg { + width: 32px; + height: 32px; +} + +@media print { + .download-fab { + display: none !important; + } +} diff --git a/src/modules/wrapped/infrastructure/templates/templateRenderer.ts b/src/modules/wrapped/infrastructure/templates/templateRenderer.ts new file mode 100644 index 0000000..16343a2 --- /dev/null +++ b/src/modules/wrapped/infrastructure/templates/templateRenderer.ts @@ -0,0 +1,612 @@ +import type { GenerateOptions } from "../PdfGenerator"; +import type { SeasonWrapped } from "../../domain/SeasonWrapped"; +import { readFileSync, existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Helper function to load optimized images as base64 +function getImageAsBase64(filename: string): string { + try { + const imagePath = join(__dirname, 'optimized', filename); + if (!existsSync(imagePath)) return ''; + const imageBuffer = readFileSync(imagePath); + const base64 = imageBuffer.toString('base64'); + return `data:image/png;base64,${base64}`; + } catch (error) { + console.error(`Failed to load image ${filename}:`, error); + return ''; + } +} + +// Pre-load optimized Yu-Gi-Oh! themed images +const images = { + decorative1: getImageAsBase64('yugioh_dragon_background.png'), // Dragon artwork + decorative2: getImageAsBase64('yugioh_monster_background.png'), // Monster artwork + decorative3: getImageAsBase64('yugioh_battlefield_background.png'), // Battlefield scene + decorative4: getImageAsBase64('yugioh_cards_background.png'), // Cards artwork + icon: getImageAsBase64('yugioh_chapter_icon.png'), // Small chapter icon +}; + +export function renderTemplate(data: SeasonWrapped, options: GenerateOptions): string { + const styles = readFileSync(join(__dirname, "styles.css"), "utf-8"); + const themeCss = getSeasonTheme(data.seasonId); + + // Select one random monster image to use as background for ALL pages + const monsterImages = [images.decorative1, images.decorative2].filter(Boolean); + const randomMonster = monsterImages[Math.floor(Math.random() * monsterImages.length)] || images.decorative1; + + return ` + + + + + + Season ${data.seasonId} Wrapped - ${data.playerName} + + + + ${renderCoverPage(data, options, randomMonster)} + ${renderGlobalStatsPage(data, options, randomMonster)} + ${renderBanListPages(data, options, randomMonster)} + ${renderChartsPage(data, options, randomMonster)} + ${(data.nemesis || data.victim) ? renderRivalsPage(data, options, randomMonster) : ""} + ${data.achievements.length > 0 ? renderAchievementsPage(data, options, randomMonster) : ""} + ${renderRankingPage(data, options, randomMonster)} + ${renderSummaryPage(data, options, randomMonster)} + + + + + + + + + + + `.trim(); +} + +// Single-page compact version for evaluation +export function renderSinglePageTemplate(data: SeasonWrapped, options: GenerateOptions): string { + const styles = readFileSync(join(__dirname, "styles.css"), "utf-8"); + const singlePageStyles = readFileSync(join(__dirname, "styles_single_page.css"), "utf-8"); + const themeCss = getSeasonTheme(data.seasonId); + + // Select one random monster image to use as background for ALL pages + const monsterImages = [images.decorative1, images.decorative2].filter(Boolean); + const randomMonster = monsterImages[Math.floor(Math.random() * monsterImages.length)] || images.decorative1; + + return ` + + + + + + Season ${data.seasonId} Wrapped - ${data.playerName} (Single Page) + + + + ${renderCoverPage(data, options, randomMonster)} + ${renderGlobalStatsPage(data, options, randomMonster)} + ${renderBanListPages(data, options, randomMonster)} + ${renderChartsPage(data, options, randomMonster)} + ${(data.nemesis || data.victim) ? renderRivalsPage(data, options, randomMonster) : ""} + ${data.achievements.length > 0 ? renderAchievementsPage(data, options, randomMonster) : ""} + ${renderRankingPage(data, options, randomMonster)} + + + `.trim(); +} + +function renderHeader(title: string, seasonName: string): string { + return ` +
+
+ + DUELIST WRAPPED +
+
${seasonName}
+
+ `; +} + +function renderCoverPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + + + return ` +
+ ${randomMonster ? `
` : ''} + +
+
+ Cover Monster +
+
+ +

${options.locale === "es" ? "TU TEMPORADA EN DUELOS" : "YOUR DUELING SEASON"}

+ +
+

${escapeHtml(data.playerName)}

+

+ ${data.seasonName} +

+
+ + +
+ `; +} + +function renderGlobalStatsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + const stats = data.globalStats; + + return ` +
+ ${randomMonster ? `
` : ''} + ${renderHeader("Season Overview", data.seasonName)} + +
+ ${images.icon ? `` : ''} + ${options.locale === "es" ? "CAPÍTULO 1" : "CHAPTER 1"} +
+

${options.locale === "es" ? "Resumen de Temporada" : "Season Overview"}

+ +
+
+ ${stats.totalMatches} ${options.locale === "es" ? "PARTIDAS TOTALES" : "TOTAL MATCHES"} +
+ +
+
+ ${options.locale === "es" ? "VICTORIAS" : "WINS"} + ${stats.wins} +
+
+ ${options.locale === "es" ? "DERROTAS" : "LOSSES"} + ${stats.losses} +
+
+ +
+
+ WINRATE + ${stats.winrate}% +
+
+
+
+
+
+ +
+
+
🔥
+
${options.locale === "es" ? "Mejor Racha" : "Best Streak"}
+
${stats.bestWinStreak}
+
${options.locale === "es" ? "Victorias seguidas" : "Wins in a row"}
+
+
+
📅
+
${options.locale === "es" ? "Días Activos" : "Active Days"}
+
${stats.activeDays}
+
${options.locale === "es" ? "Días jugados" : "Days played"}
+
+
+
+ `; +} + +function renderBanListPages(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + if (data.banListStats.length === 0) return ""; + + // Take top 3 banlists to fit on one page if possible, or paginate + const topBanlist = data.banListStats[0]; + + return ` +
+ ${randomMonster ? `
` : ''} + ${renderHeader("Formats", data.seasonName)} + +
+ ${images.icon ? `` : ''} + ${options.locale === "es" ? "CAPÍTULO 2" : "CHAPTER 2"} +
+

${options.locale === "es" ? "Formatos Dominados" : "Mastered Formats"}

+ +
+
MAIN FORMAT
+ +

${escapeHtml(topBanlist.banListName)}

+

${getBanListFlavor(topBanlist.winrate)}

+ +
+
+ WINRATE (${topBanlist.matches} matches) + ${topBanlist.winrate}% +
+
+
+
+
+
+ +
+ ${data.banListStats.slice(1, 3).map(bl => ` +
+
${escapeHtml(bl.banListName)}
+
${bl.winrate}%
+
${bl.matches} matches
+
+ `).join('')} +
+
+ `; +} + +function renderRivalsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + return ` +
+ ${randomMonster ? `
` : ''} + ${renderHeader("Rivals", data.seasonName)} + +
+ ${images.icon ? `` : ''} + ${options.locale === "es" ? "CAPÍTULO 3" : "CHAPTER 3"} +
+

${options.locale === "es" ? "Tus Rivales" : "Your Rivals"}

+ + +
+ ${data.nemesis ? ` +
+
+ +
+ +
+
+ 👻 SEASON NEMESIS +
+

${escapeHtml(data.nemesis.playerName)}

+
+ +
+ ${data.nemesis.totalMatches} matches + ${data.nemesis.losses} losses +
+
+ ` : ""} + + ${data.victim ? ` +
+
+ +
+ +
+
+ 🎯 SEASON VICTIM +
+

${escapeHtml(data.victim.playerName)}

+
+ +
+ ${data.victim.totalMatches} matches + ${data.victim.wins} wins +
+
+ ` : ""} + + ${(() => { + // Determine arch-rival (most frequent opponent) + const archRival = data.nemesis && data.victim + ? (data.nemesis.totalMatches >= data.victim.totalMatches ? data.nemesis : data.victim) + : data.nemesis || data.victim; + + if (!archRival) return ""; + + return ` +
+
+ +
+ +
+
+ ⚔️ ${options.locale === "es" ? "ARCHIENEMIGO" : "ARCH-RIVAL"} +
+

${escapeHtml(archRival.playerName)}

+

+ ${options.locale === "es" ? "Máximo rival de la temporada" : "Top rival of the season"} +

+
+ +
+ ${archRival.totalMatches} ${options.locale === "es" ? "partidas" : "matches"} +
+
+ `; + })()} +
+
+ `; +} + +function renderChartsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + if (data.banListStats.length === 0) return ""; + + // Calculate max matches for scaling + const maxMatches = Math.max(...data.banListStats.map(bl => bl.matches)); + + return ` +
+ ${randomMonster ? `
` : ''} + ${renderHeader("Evolution", data.seasonName)} + +
${options.locale === "es" ? "CAPÍTULO 4" : "CHAPTER 4"}
+

${options.locale === "es" ? "Evolución" : "Evolution"}

+ +
+

Matches Distribution

+ + ${data.banListStats.map(bl => ` +
+
+ ${escapeHtml(bl.banListName)} + ${bl.matches} +
+
+
+
+
+ `).join('')} +
+
+ `; +} + +function renderAchievementsPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + if (data.achievements.length === 0) return ""; + + return ` +
+ ${randomMonster ? `
` : ''} + ${renderHeader("Logros", data.seasonName)} + +
${options.locale === "es" ? "CAPÍTULO 5" : "CHAPTER 5"}
+

${options.locale === "es" ? "Logros" : "Achievements"}

+ +
+ ${data.achievements.map(ach => ` +
+
+ ${ach.icon && ach.icon.startsWith('http') + ? `` + : '🏆'} +
+
+
+ 🏆 ${options.locale === "es" ? "LOGRO DESBLOQUEADO" : "ACHIEVEMENT UNLOCKED"} +
+

+ ${escapeHtml(ach.name)} +

+

${escapeHtml(ach.description)}

+
+ ${ach.unlockedAt ? ` +
+ ${new Date(ach.unlockedAt).toLocaleDateString(options.locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+ ` : ''} +
+ `).join('')} +
+
+ `; +} + +function renderRankingPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + return ` +
+ ${randomMonster ? `
` : ''} + ${renderHeader("Ranking", data.seasonName)} + +
${options.locale === "es" ? "FINAL" : "FINALE"}
+

${options.locale === "es" ? "Posición" : "Ranking"}

+ +
+
+ ${data.ranking.rankBadge} TIER +
+ +
+ #${data.ranking.position} +
+ +

+ Top ${(data.ranking.position / data.ranking.totalPlayers * 100).toFixed(0)}% of ${data.ranking.totalPlayers} players +

+ +
+
${data.ranking.points}
+
TOTAL POINTS
+
+
+ + ${data.extraStats.uniqueOpponents > 0 ? ` +
+
+
Unique Opponents
+
${data.extraStats.uniqueOpponents}
+
+ ${data.extraStats.bestDay ? ` +
+
Lucky Day
+
${escapeHtml(data.extraStats.bestDay)}
+
+ ` : ""} +
+ ` : ""} +
+ `; +} + +function renderSummaryPage(data: SeasonWrapped, options: GenerateOptions, randomMonster: string): string { + return ` +
+ ${randomMonster ? `
` : ''} + + +
+

${escapeHtml(data.playerName)}

+
${data.seasonName}
+
+ +
+
+
${data.globalStats.totalMatches}
+
${options.locale === "es" ? "Duelos" : "Duels"}
+
+ +
+
${data.globalStats.wins}
+
${options.locale === "es" ? "Victorias" : "Wins"}
+
+ +
+
${data.globalStats.winrate}%
+
Win Rate
+
+
+ +
+ ${data.globalStats.bestWinStreak > 0 ? ` +
+ 🔥 ${data.globalStats.bestWinStreak} ${options.locale === "es" ? "racha de victorias" : "win streak"} +
+ ` : ''} + + ${data.ranking ? ` +
+ 🏆 #${data.ranking.position} ${options.locale === "es" ? "en el ranking" : "in rankings"} · ${data.ranking.rankBadge} +
+ ` : ''} + + ${data.extraStats?.mostPlayedBanList ? ` +
+ 🎮 ${options.locale === "es" ? "Formato favorito:" : "Favorite format:"} ${data.extraStats.mostPlayedBanList} +
+ ` : ''} +
+ + ${(data.nemesis || data.victim) ? ` +
+ ${data.nemesis ? ` +
+
👻
+
+
${options.locale === "es" ? "Némesis" : "Nemesis"}
+
${escapeHtml(data.nemesis.playerName)}
+
${data.nemesis.wins}W / ${data.nemesis.losses}L
+
+
+ ` : ''} + + ${data.victim ? ` +
+
🎯
+
+
${options.locale === "es" ? "Víctima" : "Victim"}
+
${escapeHtml(data.victim.playerName)}
+
${data.victim.wins}W / ${data.victim.losses}L
+
+
+ ` : ''} +
+ ` : ''} + + +
+ `; +} + +function getInitialsAvatar(name: string): string { + return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%231e293b'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='white' font-size='40' font-family='sans-serif' font-weight='bold'%3E${name.charAt(0).toUpperCase()}%3C/text%3E%3C/svg%3E`; +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (char) => map[char] || char); +} + +function getBanListFlavor(winrate: number): string { + if (winrate >= 70) return "En esta banlist estabas on fire 🔥"; + if (winrate <= 40) return "En esta banlist sufriste un poco 😅"; + return "En esta banlist te mantuviste competitivo 💪"; +} + +function getSeasonTheme(seasonId: number): string { + const themes: Record = { + // Season 3: Nature/Wind - Emerald/Green (Old S1 Theme) + 3: { + accent: '#10B981', + bgBase: '#064E3B', + bgCard: '#065F46' + }, + // Season 4: Fire/Invasion - Red/Orange (Old S2 Theme) + 4: { + accent: '#EF4444', + bgBase: '#450A0A', + bgCard: '#7F1D1D' + }, + // Season 5: Water/Abyss - Cyan/Blue + 5: { + accent: '#06B6D4', + bgBase: '#083344', + bgCard: '#164E63' + }, + // Season 6: Current/Tech - Blue (Default) + 6: { + accent: '#3B82F6', + bgBase: '#0B1120', + bgCard: '#151e32' + } + }; + + const theme = themes[seasonId] || themes[6]; // Default to Season 6 style + + return ` + :root { + --color-accent: ${theme.accent}; + --color-accent-glow: ${theme.accent}80; + --bg-base: ${theme.bgBase}; + --bg-card: ${theme.bgCard}; + --bg-highlight: ${theme.bgCard}; + --gradient-primary: linear-gradient(135deg, ${theme.accent} 0%, ${theme.bgCard} 100%); + } + `; +} diff --git a/src/server/routes/reports-router.ts b/src/server/routes/reports-router.ts new file mode 100644 index 0000000..2480695 --- /dev/null +++ b/src/server/routes/reports-router.ts @@ -0,0 +1,42 @@ +import { bearer } from "@elysiajs/bearer"; +import { Elysia } from "elysia"; +import { ReportsController } from "../../modules/reports/infrastructure/ReportsController"; +import { JWT } from "../../shared/JWT"; +import { config } from "../../config"; +import { AuthenticationError } from "../../shared/errors/AuthenticationError"; + +const jwt = new JWT(config.jwt); + +export const reportsRouter = new Elysia() + .group("/reports", (app) => + app + .use(bearer()) + .get("/wrapped", async (context) => { + const token = context.bearer; + if (!token) { + throw new AuthenticationError("No token provided"); + } + const decoded = jwt.decode(token) as { id: string } | null; + if (!decoded || !decoded.id) { + throw new AuthenticationError("Invalid token"); + } + + return new ReportsController().getWrapped({ user: { profile: { id: decoded.id } } }); + }, { + detail: { + tags: ['Reports'], + summary: 'Get Player Wrapped Report', + description: 'Generates a PDF report of player statistics for seasons 3, 4, and 5.', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'PDF Report retrieved successfully', + content: { + 'application/pdf': {} + } + }, + 401: { description: 'Unauthorized' } + } + } + }) + ); diff --git a/src/server/routes/wrapped-router.ts b/src/server/routes/wrapped-router.ts new file mode 100644 index 0000000..388e450 --- /dev/null +++ b/src/server/routes/wrapped-router.ts @@ -0,0 +1,323 @@ +import { Elysia, t } from "elysia"; +import { bearer } from "@elysiajs/bearer"; +import { rateLimit } from "elysia-rate-limit"; +import { WrappedController, NotFoundError, ValidationError } from "../../modules/wrapped/infrastructure/WrappedController"; +import { JWT } from "../../shared/JWT"; +import { config } from "../../config"; +import { UnauthorizedError } from "../../shared/errors/UnauthorizedError"; +import { UserProfileRole } from "../../evolution-types/src/types/UserProfileRole"; + +const controller = new WrappedController(); +const jwt = new JWT(config.jwt); + +/** + * Authorizes access to wrapped data + * Allows access if user is the owner OR has admin role + */ +function authorizeWrappedAccess( + bearerToken: string | undefined, + playerId: string +): void { + if (!bearerToken) { + throw new UnauthorizedError("Authentication required to access wrapped data"); + } + + const decoded = jwt.decode(bearerToken) as { id: string; role: string }; + const isOwner = decoded.id === playerId; + const isAdmin = decoded.role === UserProfileRole.ADMIN; + + if (!isOwner && !isAdmin) { + throw new UnauthorizedError( + "You can only access your own wrapped data or must be an admin" + ); + } +} + +export const wrappedRouter = new Elysia() + .use(bearer()) + .group("/seasons", (app) => + app + .use( + rateLimit({ + duration: 60000, + max: 10, + }) + ) + // HTML endpoint + .get( + "/:seasonId/wrapped/:playerId/html", + async ({ params, query, bearer, set }) => { + try { + // Authorization: Only owner or admin can access + authorizeWrappedAccess(bearer, params.playerId); + + const result = await controller.getData({ + params: { + seasonId: params.seasonId, + playerId: params.playerId, + }, + }); + + const { renderTemplate } = await import("../../modules/wrapped/infrastructure/templates/templateRenderer"); + const locale = (query.locale || "es") as string; + const theme = (query.theme === "light" ? "light" : "dark") as "dark" | "light"; + const html = renderTemplate(result, { + locale, + theme, + includeMatchList: false, + }); + + set.headers["Content-Type"] = "text/html"; + return html; + } catch (error) { + if (error instanceof UnauthorizedError) { + set.status = 401; + return { + error: "UnauthorizedError", + message: error.message + }; + } + + if (error instanceof ValidationError) { + set.status = 422; + return { + error: "ValidationError", + message: error.message + }; + } + + if (error instanceof NotFoundError) { + set.status = 404; + return { + error: "NotFoundError", + message: error.message + }; + } + + set.status = 500; + return { + error: "Failed to generate HTML", + details: error instanceof Error ? error.message : "Unknown error" + }; + } + }, + { + params: t.Object({ + seasonId: t.String({ description: "ID of the season (e.g., 6)" }), + playerId: t.String({ description: "UUID of the player" }), + }), + query: t.Object({ + locale: t.Optional(t.String({ description: "Language code (es, en)", default: "es" })), + theme: t.Optional(t.String({ description: "Color theme (dark, light)", default: "dark" })), + }), + detail: { + tags: ["Wrapped"], + summary: "Get Season Wrapped HTML", + description: "Returns the HTML view of a player's season wrapped. Protected: Owner or Admin only.", + } + } + ) + // PDF endpoint + .get( + "/:seasonId/wrapped/:playerId/pdf", + async ({ params, query, bearer, set }) => { + try { + // Authorization: Only owner or admin can access + authorizeWrappedAccess(bearer, params.playerId); + + const result = await controller.generatePdf({ + params: { + seasonId: params.seasonId, + playerId: params.playerId, + }, + query: { + locale: query.locale, + theme: query.theme as "dark" | "light" | undefined, + includeMatchList: query.includeMatchList, + singlePage: query.singlePage, + }, + }); + + // Set proper headers for PDF response + set.status = 200; + set.headers["Content-Type"] = "application/pdf"; + + // Sanitize filename to prevent header injection or filesystem issues + const safePlayerName = result.playerName.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const filename = `${safePlayerName}-season-${result.seasonId}-wrapped.pdf`; + + set.headers["Content-Disposition"] = `inline; filename="${filename}"`; + + // Caching headers (1 hour cache) + set.headers["Cache-Control"] = "public, max-age=3600"; + set.headers["Last-Modified"] = new Date().toUTCString(); + + // ETag for conditional requests + const etag = `"${result.seasonId}-${result.playerId}-${query.locale || 'es'}-${query.theme || 'dark'}"`; + set.headers["ETag"] = etag; + + return result.pdf; + } catch (error) { + if (error instanceof UnauthorizedError) { + set.status = 401; + return { + error: "UnauthorizedError", + message: error.message + }; + } + + if (error instanceof ValidationError) { + set.status = 422; + return { + error: "ValidationError", + message: error.message, + details: { + seasonId: params.seasonId, + playerId: params.playerId + } + }; + } + + if (error instanceof NotFoundError) { + set.status = 404; + return { + error: "NotFoundError", + message: error.message + }; + } + + // Internal server error + console.error("PDF generation error:", error); + set.status = 500; + return { + error: "InternalServerError", + message: "Failed to generate PDF" + }; + } + }, + { + params: t.Object({ + seasonId: t.String({ description: "ID of the season" }), + playerId: t.String({ description: "UUID of the player" }), + }), + query: t.Object({ + locale: t.Optional(t.String({ description: "Language code (es, en)", default: "es" })), + theme: t.Optional(t.String({ description: "Color theme (dark, light)", default: "dark" })), + includeMatchList: t.Optional(t.String({ description: "Include match history? (true/false)", default: "false" })), + singlePage: t.Optional(t.String({ description: "Render as single page for debugging?", default: "false" })), + }), + detail: { + tags: ["Wrapped"], + summary: "Download Season Wrapped PDF", + description: "Generates and returns a PDF report of the player's season stats. Protected: Owner or Admin only.", + } + } + ) + + // JSON endpoint (for debugging) + .get( + "/:seasonId/wrapped/:playerId", + async ({ params, bearer, set }) => { + try { + // Authorization: Only owner or admin can access + authorizeWrappedAccess(bearer, params.playerId); + + const data = await controller.getData({ + params: { + seasonId: params.seasonId, + playerId: params.playerId, + }, + }); + + // Success response with caching + set.status = 200; + set.headers["Content-Type"] = "application/json"; + set.headers["Cache-Control"] = "public, max-age=3600"; + + return data; + } catch (error) { + if (error instanceof UnauthorizedError) { + set.status = 401; + return { + error: "UnauthorizedError", + message: error.message + }; + } + + if (error instanceof ValidationError) { + set.status = 422; + return { + error: "ValidationError", + message: error.message + }; + } + + if (error instanceof NotFoundError) { + set.status = 404; + return { + error: "NotFoundError", + message: error.message + }; + } + + console.error("Wrapped data fetch error:", error); + set.status = 500; + return { + error: "InternalServerError", + message: "Failed to fetch wrapped data", + details: error instanceof Error ? error.message : "Unknown error" + }; + } + }, + { + detail: { + tags: ["Season Wrapped"], + summary: "Get season wrapped data (JSON)", + description: + "Returns the raw season wrapped data as JSON for debugging and validation", + responses: { + 200: { + description: "Data retrieved successfully", + content: { + "application/json": { + example: { + playerId: "e3b02258-4c7c-41d6-b317-bc78f52a7e84", + playerName: "PlayerOne", + seasonId: 5, + globalStats: { + totalMatches: 150, + wins: 85, + losses: 65, + winrate: 56.7, + }, + banListStats: [], + achievements: [], + }, + }, + }, + }, + 401: { + description: "Unauthorized - Authentication required or insufficient permissions", + }, + 404: { + description: "Player or season not found", + }, + 422: { + description: "Validation error - Invalid season ID or player ID format", + }, + }, + security: [{ bearerAuth: [] }], + }, + params: t.Object({ + seasonId: t.String({ + description: "Season ID", + examples: ["5"], + }), + playerId: t.String({ + description: "Player UUID", + examples: ["e3b02258-4c7c-41d6-b317-bc78f52a7e84"], + }), + }), + }, + ), + ); diff --git a/src/server/server.ts b/src/server/server.ts index 61512b3..ec5f42e 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -13,6 +13,8 @@ import { leaderboardRouter } from "./routes/leaderboard-router"; import { statsRouter } from "./routes/stats-router"; import { tournamentRouter } from "./routes/tournament-router"; import { userRouter } from "./routes/user-router"; +import { wrappedRouter } from "./routes/wrapped-router"; +import { reportsRouter } from "./routes/reports-router"; export class Server { private readonly app: Elysia; @@ -64,6 +66,14 @@ export class Server { { name: 'Match Management', description: 'Endpoints for managing match results and match data' + }, + { + name: 'Season Wrapped', + description: 'Season summary reports and statistics visualization' + }, + { + name: 'Statistics', + description: 'Global statistics and historical data' } ], components: { @@ -104,6 +114,8 @@ export class Server { .use(banListRouter) .use(tournamentRouter) .use(statsRouter) + .use(wrappedRouter) + .use(reportsRouter); }); this.logger = logger; }