From 24dc4090c0aa3cd4fefa32353dfded07161fe4d1 Mon Sep 17 00:00:00 2001 From: KeshavAnandCode Date: Mon, 22 Dec 2025 00:38:30 -0600 Subject: [PATCH] Working Database setupgit add . --- .gitignore | 105 +++++ README.md | 15 + bun.lock | 416 ++++++++++++++++++ package.json | 33 ++ prisma.config.ts | 14 + .../20251222052913_init/migration.sql | 71 +++ .../migration.sql | 11 + .../migration.sql | 25 ++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 80 ++++ src/db.ts | 26 ++ src/index.ts | 75 ++++ src/jobs/sync-scheduler.ts | 52 +++ src/routes/grades.routes.ts | 195 ++++++++ src/routes/stats.routes.ts | 59 +++ src/routes/users.routes.ts | 90 ++++ src/services/grades.service.ts | 277 ++++++++++++ src/services/scraper.service.ts | 65 +++ src/services/stats.service.ts | 204 +++++++++ src/services/sync.service.ts | 110 +++++ tsconfig.json | 29 ++ 21 files changed, 1955 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20251222052913_init/migration.sql create mode 100644 prisma/migrations/20251222055851_fix_class_uniqueness/migration.sql create mode 100644 prisma/migrations/20251222060957_add_final_grades/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/db.ts create mode 100644 src/index.ts create mode 100644 src/jobs/sync-scheduler.ts create mode 100644 src/routes/grades.routes.ts create mode 100644 src/routes/stats.routes.ts create mode 100644 src/routes/users.routes.ts create mode 100644 src/services/grades.service.ts create mode 100644 src/services/scraper.service.ts create mode 100644 src/services/stats.service.ts create mode 100644 src/services/sync.service.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17efd95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Bun +.bun/ +bun.lockb + +# Environment variables +.env +.env.local +.env.development +.env.development.local +.env.test +.env.test.local +.env.production +.env.production.local + +# Prisma +prisma/.env +# Keep migrations but ignore the generated client +node_modules/.prisma/ +node_modules/@prisma/ + +# Build outputs +out/ +dist/ +build/ +*.tgz +*.tsbuildinfo + +# Code coverage +coverage/ +*.lcov +*.coverage + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +bun-debug.log* +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Output directory (scraped data - contains sensitive student info!) +output/ +*.json +!package.json +!tsconfig.json +!prisma/schema.prisma + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace +*.sublime-project + +# Testing +test-results/ +playwright-report/ +playwright/.cache/ + +# Temporary files +*.tmp +*.temp +.cache/ + +# Database files (if using SQLite for testing) +*.db +*.db-journal +*.sqlite +*.sqlite3 + +# Debug +.debug/ + +# Caches +.eslintcache +.next/ +.nuxt/ +.turbo/ + +# Generated files +/generated/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ac47b7 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# spaceward-database + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ac9521e --- /dev/null +++ b/bun.lock @@ -0,0 +1,416 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "spaceward-database", + "dependencies": { + "@prisma/adapter-pg": "^7.2.0", + "@prisma/client": "^7.2.0", + "cors": "^2.8.5", + "express": "^5.2.1", + "node-cron": "^4.2.1", + "pg": "^8.16.3", + "prisma": "^7.2.0", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^25.0.3", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.16.0", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], + + "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], + + "@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="], + + "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], + + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.2", "", {}, "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.6", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.2" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.7", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.2" } }, "sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg=="], + + "@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="], + + "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.12.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w=="], + + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.2.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.2.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-euIdQ13cRB2wZ3jPsnDnFhINquo1PYFPCg6yVL8b2rp3EdinQHsX9EDdCtRr489D5uhphcRk463OdQAFlsCr0w=="], + + "@prisma/client": ["@prisma/client@7.2.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.2.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-JdLF8lWZ+LjKGKpBqyAlenxd/kXjd1Abf/xK+6vUA7R7L2Suo6AFTHFRpPSdAKCan9wzdFApsUpSa/F6+t1AtA=="], + + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.2.0", "", {}, "sha512-dn7oB53v0tqkB0wBdMuTNFNPdEbfICEUe82Tn9FoKAhJCUkDH+fmyEp0ClciGh+9Hp2Tuu2K52kth2MTLstvmA=="], + + "@prisma/config": ["@prisma/config@7.2.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-qmvSnfQ6l/srBW1S7RZGfjTQhc44Yl3ldvU6y3pgmuLM+83SBDs6UQVgMtQuMRe9J3gGqB0RF8wER6RlXEr6jQ=="], + + "@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + + "@prisma/dev": ["@prisma/dev@0.17.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.2", "@electric-sql/pglite-socket": "0.0.6", "@electric-sql/pglite-tools": "0.2.7", "@hono/node-server": "1.19.6", "@mrleebo/prisma-ast": "0.12.1", "@prisma/get-platform": "6.8.2", "@prisma/query-plan-executor": "6.18.0", "foreground-child": "3.3.1", "get-port-please": "3.1.2", "hono": "4.10.6", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.21.3", "std-env": "3.9.0", "valibot": "1.2.0", "zeptomatch": "2.0.2" } }, "sha512-6sGebe5jxX+FEsQTpjHLzvOGPn6ypFQprcs3jcuIWv1Xp/5v6P/rjfdvAwTkP2iF6pDx2tCd8vGLNWcsWzImTA=="], + + "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-gzrUcbI9VmHS24Uf+0+7DNzdIw7keglJsD5m/MHxQOU68OhGVzlphQRobLiDMn8CHNA2XN8uugwKjudVtnfMVQ=="], + + "@prisma/engines": ["@prisma/engines@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0", "@prisma/engines-version": "7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", "@prisma/fetch-engine": "7.2.0", "@prisma/get-platform": "7.2.0" } }, "sha512-HUeOI/SvCDsHrR9QZn24cxxZcujOjcS3w1oW/XVhnSATAli5SRMOfp/WkG3TtT5rCxDA4xOnlJkW7xkho4nURA=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", "", {}, "sha512-KezsjCZDsbjNR7SzIiVlUsn9PnLePI7r5uxABlwL+xoerurZTfgQVbIjvjF2sVr3Uc0ZcsnREw3F84HvbggGdA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0", "@prisma/engines-version": "7.2.0-4.0c8ef2ce45c83248ab3df073180d5eda9e8be7a3", "@prisma/get-platform": "7.2.0" } }, "sha512-Z5XZztJ8Ap+wovpjPD2lQKnB8nWFGNouCrglaNFjxIWAGWz0oeHXwUJRiclIoSSXN/ptcs9/behptSk8d0Yy6w=="], + + "@prisma/get-platform": ["@prisma/get-platform@6.8.2", "", { "dependencies": { "@prisma/debug": "6.8.2" } }, "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@6.18.0", "", {}, "sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.9.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/node-cron": ["@types/node-cron@3.0.11", "", {}, "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg=="], + + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-port-please": ["get-port-please@3.1.2", "", {}, "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + + "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-cron": ["node-cron@4.2.1", "", {}, "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + + "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], + + "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], + + "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prisma": ["prisma@7.2.0", "", { "dependencies": { "@prisma/config": "7.2.0", "@prisma/dev": "0.17.0", "@prisma/engines": "7.2.0", "@prisma/studio-core": "0.9.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-jSdHWgWOgFF24+nRyyNRVBIgGDQEsMEF8KPHvhBBg3jWyR9fUAK0Nq9ThUmiGlNgq2FA7vSk/ZoCvefod+a8qg=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], + + "remeda": ["remeda@2.21.3", "", { "dependencies": { "type-fest": "^4.39.1" } }, "sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "zeptomatch": ["zeptomatch@2.0.2", "", { "dependencies": { "grammex": "^3.1.10" } }, "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="], + + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1e72453 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "spaceward-database", + "module": "src/index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^25.0.3", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.16.0" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@prisma/adapter-pg": "^7.2.0", + "@prisma/client": "^7.2.0", + "cors": "^2.8.5", + "express": "^5.2.1", + "node-cron": "^4.2.1", + "pg": "^8.16.3", + "prisma": "^7.2.0" + }, + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "db:generate": "bunx prisma generate", + "db:migrate": "bunx prisma migrate dev", + "db:studio": "bunx prisma studio" + } +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20251222052913_init/migration.sql b/prisma/migrations/20251222052913_init/migration.sql new file mode 100644 index 0000000..e8260b8 --- /dev/null +++ b/prisma/migrations/20251222052913_init/migration.sql @@ -0,0 +1,71 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Class" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "className" TEXT NOT NULL, + "teacher" TEXT NOT NULL, + "period" TEXT NOT NULL, + "category" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Class_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Assignment" ( + "id" TEXT NOT NULL, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "dueDate" TEXT NOT NULL, + "score" TEXT NOT NULL, + "attempts" TEXT NOT NULL, + "isMajorGrade" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Assignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Fetch" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "success" BOOLEAN NOT NULL DEFAULT true, + "classCount" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "Fetch_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE INDEX "Class_userId_idx" ON "Class"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Class_userId_category_key" ON "Class"("userId", "category"); + +-- CreateIndex +CREATE INDEX "Assignment_classId_idx" ON "Assignment"("classId"); + +-- CreateIndex +CREATE INDEX "Fetch_userId_idx" ON "Fetch"("userId"); + +-- AddForeignKey +ALTER TABLE "Class" ADD CONSTRAINT "Class_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Fetch" ADD CONSTRAINT "Fetch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251222055851_fix_class_uniqueness/migration.sql b/prisma/migrations/20251222055851_fix_class_uniqueness/migration.sql new file mode 100644 index 0000000..212a20e --- /dev/null +++ b/prisma/migrations/20251222055851_fix_class_uniqueness/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,category,className]` on the table `Class` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "Class_userId_category_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "Class_userId_category_className_key" ON "Class"("userId", "category", "className"); diff --git a/prisma/migrations/20251222060957_add_final_grades/migration.sql b/prisma/migrations/20251222060957_add_final_grades/migration.sql new file mode 100644 index 0000000..e25f567 --- /dev/null +++ b/prisma/migrations/20251222060957_add_final_grades/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "FinalGrade" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "classId" TEXT NOT NULL, + "grade" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FinalGrade_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FinalGrade_userId_idx" ON "FinalGrade"("userId"); + +-- CreateIndex +CREATE INDEX "FinalGrade_classId_idx" ON "FinalGrade"("classId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinalGrade_userId_classId_key" ON "FinalGrade"("userId", "classId"); + +-- AddForeignKey +ALTER TABLE "FinalGrade" ADD CONSTRAINT "FinalGrade_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FinalGrade" ADD CONSTRAINT "FinalGrade_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..356061b --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,80 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +model User { + id String @id @default(uuid()) + username String @unique + password String + createdAt DateTime @default(now()) + + classes Class[] + fetches Fetch[] + finalGrades FinalGrade[] +} + +model Class { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + className String + teacher String + period String + category String + + createdAt DateTime @default(now()) + + assignments Assignment[] + finalGrades FinalGrade[] + + @@unique([userId, category, className]) + @@index([userId]) +} + +model Assignment { + id String @id @default(uuid()) + classId String + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + + name String + dueDate String + score String + attempts String + isMajorGrade Boolean + + createdAt DateTime @default(now()) + + @@index([classId]) +} + +model FinalGrade { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + classId String + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + + grade String // The overall grade (e.g., "95.5%", "A", etc.) + updatedAt DateTime @default(now()) @updatedAt + + @@unique([userId, classId]) // One final grade per user per class + @@index([userId]) + @@index([classId]) +} + +model Fetch { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + timestamp DateTime @default(now()) + success Boolean @default(true) + classCount Int @default(0) + + @@index([userId]) +} \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..20f4bda --- /dev/null +++ b/src/db.ts @@ -0,0 +1,26 @@ +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; + +const connectionString = process.env.DATABASE_URL!; + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient({ + adapter, + log: ['error', 'warn'], +}); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma; +} + +export async function disconnectDB() { + await prisma.$disconnect(); + await pool.end(); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5980ac6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,75 @@ +import express from 'express'; +import cors from 'cors'; +import { disconnectDB } from './db'; +import { startSyncScheduler, stopSyncScheduler } from './jobs/sync-scheduler'; + +// Import routes +import usersRoutes from './routes/users.routes'; +import gradesRoutes from './routes/grades.routes'; +import statsRoutes from './routes/stats.routes'; + +const app = express(); +const PORT = process.env.PORT || 4000; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + message: 'Skyward Backend API is running', + timestamp: new Date().toISOString(), + }); +}); + +// Routes +app.use('/api/users', usersRoutes); +app.use('/api', gradesRoutes); +app.use('/api/stats', statsRoutes); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + success: false, + error: 'Route not found', + }); +}); + +// Start server +app.listen(PORT, () => { + console.log(`\nšŸš€ Skyward Backend API running on port ${PORT}`); + console.log(`šŸ“” Health check: http://localhost:${PORT}/health\n`); + + console.log('Available endpoints:'); + console.log(' POST /api/users/register'); + console.log(' GET /api/users'); + console.log(' DELETE /api/users/:username'); + console.log(' GET /api/users/:username/grades'); + console.log(' GET /api/users/:username/classes'); + console.log(' GET /api/users/:username/classes/:category/assignments'); + console.log(' GET /api/users/:username/history'); + console.log(' POST /api/sync/manual'); + console.log(' GET /api/stats/overall'); + console.log(' GET /api/stats/users/:username'); + console.log(' GET /api/stats/users/:username/classes/:category\n'); + + // Start background sync scheduler + startSyncScheduler(); +}); + +// Graceful shutdown +process.on('SIGINT', async () => { + console.log('\n\n[Server] Shutting down gracefully...'); + stopSyncScheduler(); + await disconnectDB(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.log('\n\n[Server] Shutting down gracefully...'); + stopSyncScheduler(); + await disconnectDB(); + process.exit(0); +}); \ No newline at end of file diff --git a/src/jobs/sync-scheduler.ts b/src/jobs/sync-scheduler.ts new file mode 100644 index 0000000..ee743e3 --- /dev/null +++ b/src/jobs/sync-scheduler.ts @@ -0,0 +1,52 @@ +import cron, { type ScheduledTask } from 'node-cron'; +import { syncMultipleUsers } from '../services/sync.service'; + +let schedulerTask: ScheduledTask | null = null; +let isSyncing = false; // ← Add lock + +export function startSyncScheduler() { + const intervalMinutes = parseInt(process.env.SYNC_INTERVAL_MINUTES || '30'); + + // Cron expression: run every N minutes + const cronExpression = `*/${intervalMinutes} * * * *`; + + console.log(`[Scheduler] Starting sync scheduler (every ${intervalMinutes} minutes)`); + + schedulerTask = cron.schedule(cronExpression, async () => { + // Prevent overlapping syncs + if (isSyncing) { + console.log(`[Scheduler] āš ļø Sync already in progress, skipping this run`); + return; + } + + console.log(`[Scheduler] Triggered at ${new Date().toISOString()}`); + + isSyncing = true; + try { + await syncMultipleUsers(); + } finally { + isSyncing = false; + } + }); + + // Run initial sync immediately + console.log('[Scheduler] Running initial sync...'); + isSyncing = true; + syncMultipleUsers() + .then(() => { + console.log('[Scheduler] Initial sync complete'); + }) + .catch((err) => { + console.error('[Scheduler] Initial sync failed:', err); + }) + .finally(() => { + isSyncing = false; + }); +} + +export function stopSyncScheduler() { + if (schedulerTask) { + schedulerTask.stop(); + console.log('[Scheduler] Stopped'); + } +} \ No newline at end of file diff --git a/src/routes/grades.routes.ts b/src/routes/grades.routes.ts new file mode 100644 index 0000000..e4e6b54 --- /dev/null +++ b/src/routes/grades.routes.ts @@ -0,0 +1,195 @@ +import { Router } from 'express'; +import { getGradesForUser, getFetchHistory, getFinalGrades } from '../services/grades.service'; +import { syncUserGrades } from '../services/sync.service'; +import { prisma } from '../db'; + +const router = Router(); + +// Get all grades for a user +router.get('/users/:username/grades', async (req, res) => { + try { + const { username } = req.params; + const user = await getGradesForUser(username); + + res.json({ + success: true, + user: { + username: user.username, + createdAt: user.createdAt, + classes: user.classes, + }, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(404).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Get classes for a user +router.get('/users/:username/classes', async (req, res) => { + try { + const { username } = req.params; + const user = await prisma.user.findUnique({ + where: { username }, + include: { + classes: { + select: { + id: true, + className: true, + teacher: true, + period: true, + category: true, + createdAt: true, + }, + }, + }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + res.json({ + success: true, + username: user.username, + classes: user.classes, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(500).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Get assignments for a specific class +router.get('/users/:username/classes/:category/assignments', async (req, res) => { + try { + const { username, category } = req.params; + + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + // Find all classes with this category (there might be multiple) + const classes = await prisma.class.findMany({ + where: { + userId: user.id, + category: category, + }, + include: { + assignments: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (classes.length === 0) { + return res.status(404).json({ + success: false, + error: 'No classes found for this category', + }); + } + + // Return all classes with this category + res.json({ + success: true, + category, + classes: classes.map(cls => ({ + className: cls.className, + teacher: cls.teacher, + period: cls.period, + category: cls.category, + assignments: cls.assignments, + })), + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(500).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Get fetch history for a user +router.get('/users/:username/history', async (req, res) => { + try { + const { username } = req.params; + const history = await getFetchHistory(username); + + res.json({ + success: true, + username, + count: history.length, + history, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(404).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Get final grades for a user +router.get('/users/:username/final-grades', async (req, res) => { + try { + const { username } = req.params; + const finalGrades = await getFinalGrades(username); + + res.json({ + success: true, + username, + count: finalGrades.length, + finalGrades, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(404).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Manual sync for one user +router.post('/sync/manual', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + error: 'Username and password are required', + }); + } + + const result = await syncUserGrades(username, password); + + res.json(result); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(500).json({ + success: false, + error: errorMsg, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/routes/stats.routes.ts b/src/routes/stats.routes.ts new file mode 100644 index 0000000..ab35c5b --- /dev/null +++ b/src/routes/stats.routes.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { getOverallStats, getUserStats, getClassStats } from '../services/stats.service'; + +const router = Router(); + +// Overall system stats +router.get('/overall', async (req, res) => { + try { + const stats = await getOverallStats(); + res.json({ + success: true, + stats, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(500).json({ + success: false, + error: errorMsg, + }); + } +}); + +// User-specific stats +router.get('/users/:username', async (req, res) => { + try { + const { username } = req.params; + const stats = await getUserStats(username); + res.json({ + success: true, + stats, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(404).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Class-specific stats +router.get('/users/:username/classes/:category', async (req, res) => { + try { + const { username, category } = req.params; + const stats = await getClassStats(username, category); + res.json({ + success: true, + stats, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(404).json({ + success: false, + error: errorMsg, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts new file mode 100644 index 0000000..06bc0aa --- /dev/null +++ b/src/routes/users.routes.ts @@ -0,0 +1,90 @@ +import { Router } from 'express'; +import { prisma } from '../db'; +import { getAllUsers, deleteUser } from '../services/grades.service'; + +const router = Router(); + +// Register a new user +router.post('/register', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + error: 'Username and password are required', + }); + } + + // Check if user exists + const existing = await prisma.user.findUnique({ + where: { username }, + }); + + if (existing) { + return res.status(409).json({ + success: false, + error: 'Username already exists', + }); + } + + // Create user + const user = await prisma.user.create({ + data: { username, password }, + select: { + id: true, + username: true, + createdAt: true, + }, + }); + + res.json({ + success: true, + user, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(500).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Get all users +router.get('/', async (req, res) => { + try { + const users = await getAllUsers(); + res.json({ + success: true, + count: users.length, + users, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(500).json({ + success: false, + error: errorMsg, + }); + } +}); + +// Delete user +router.delete('/:username', async (req, res) => { + try { + const { username } = req.params; + const result = await deleteUser(username); + res.json({ + success: true, + ...result, + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + res.status(404).json({ + success: false, + error: errorMsg, + }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/services/grades.service.ts b/src/services/grades.service.ts new file mode 100644 index 0000000..f6179e5 --- /dev/null +++ b/src/services/grades.service.ts @@ -0,0 +1,277 @@ +import { prisma } from '../db'; + +interface GradeData { + className: string; + teacher: string; + period: string; + category: string; + overallGrade?: string; + grades: Array<{ + name: string; + dueDate: string; + score: string; + attempts: string; + isMajorGrade: boolean; + }>; +} + +export async function syncGradesToDatabase( + username: string, + password: string, + gradesData: GradeData[] +) { + // Step 1: Find or create user + let user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + user = await prisma.user.create({ + data: { username, password }, + }); + } else { + // Update password in case it changed + user = await prisma.user.update({ + where: { username }, + data: { password }, + }); + } + + let classesProcessed = 0; + let assignmentsAdded = 0; + let assignmentsUpdated = 0; + let finalGradesUpdated = 0; + + // Step 2: Process each class + for (const classData of gradesData) { + // Find or create class + let classRecord = await prisma.class.findUnique({ + where: { + userId_category_className: { + userId: user.id, + category: classData.category, + className: classData.className, + }, + }, + }); + + if (!classRecord) { + classRecord = await prisma.class.create({ + data: { + userId: user.id, + className: classData.className, + teacher: classData.teacher, + period: classData.period, + category: classData.category, + }, + }); + console.log(`[DB] Created new class: ${classData.className} (${classData.category})`); + } else { + // Update class info in case it changed + classRecord = await prisma.class.update({ + where: { id: classRecord.id }, + data: { + teacher: classData.teacher, + period: classData.period, + }, + }); + console.log(`[DB] Updated existing class: ${classData.className} (${classData.category})`); + } + + classesProcessed++; + + // Step 2.5: Save/Update Final Grade if present + if (classData.overallGrade) { + const existingFinalGrade = await prisma.finalGrade.findUnique({ + where: { + userId_classId: { + userId: user.id, + classId: classRecord.id, + }, + }, + }); + + if (!existingFinalGrade) { + await prisma.finalGrade.create({ + data: { + userId: user.id, + classId: classRecord.id, + grade: classData.overallGrade, + }, + }); + console.log(`[DB] Created final grade for ${classData.className}: ${classData.overallGrade}`); + } else if (existingFinalGrade.grade !== classData.overallGrade) { + await prisma.finalGrade.update({ + where: { id: existingFinalGrade.id }, + data: { grade: classData.overallGrade }, + }); + console.log(`[DB] Updated final grade for ${classData.className}: ${existingFinalGrade.grade} → ${classData.overallGrade}`); + finalGradesUpdated++; + } + } + + // Get existing assignments for this class + const existingAssignments = await prisma.assignment.findMany({ + where: { classId: classRecord.id }, + }); + + // Create a map for quick lookup + const existingMap = new Map( + existingAssignments.map((a) => [a.name, a]) + ); + + // Process each assignment + for (const assignment of classData.grades) { + const existing = existingMap.get(assignment.name); + + if (!existing) { + // NEW assignment - insert it + await prisma.assignment.create({ + data: { + classId: classRecord.id, + name: assignment.name, + dueDate: assignment.dueDate, + score: assignment.score, + attempts: assignment.attempts, + isMajorGrade: assignment.isMajorGrade, + }, + }); + assignmentsAdded++; + } else { + // Check if anything changed + const hasChanged = + existing.dueDate !== assignment.dueDate || + existing.score !== assignment.score || + existing.attempts !== assignment.attempts || + existing.isMajorGrade !== assignment.isMajorGrade; + + if (hasChanged) { + // UPDATE assignment + await prisma.assignment.update({ + where: { id: existing.id }, + data: { + dueDate: assignment.dueDate, + score: assignment.score, + attempts: assignment.attempts, + isMajorGrade: assignment.isMajorGrade, + }, + }); + assignmentsUpdated++; + } + } + } + + console.log(`[DB] Class ${classData.className} (${classData.category}): ${assignmentsAdded} new, ${assignmentsUpdated} updated`); + } + + // Step 3: Log this fetch + await prisma.fetch.create({ + data: { + userId: user.id, + success: true, + classCount: classesProcessed, + }, + }); + + console.log(`[DB] āœ“ Sync complete: ${classesProcessed} classes, ${assignmentsAdded} total new assignments, ${assignmentsUpdated} total updated, ${finalGradesUpdated} final grades updated`); + + return { + userId: user.id, + username: user.username, + classesProcessed, + assignmentsAdded, + assignmentsUpdated, + finalGradesUpdated, + }; +} + +export async function getGradesForUser(username: string) { + const user = await prisma.user.findUnique({ + where: { username }, + include: { + classes: { + include: { + assignments: { + orderBy: { createdAt: 'desc' }, + }, + finalGrades: true, // Include final grades + }, + orderBy: { category: 'asc' }, + }, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + return user; +} + +export async function getAllUsers() { + return prisma.user.findMany({ + select: { + id: true, + username: true, + createdAt: true, + }, + }); +} + +export async function deleteUser(username: string) { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + throw new Error('User not found'); + } + + await prisma.user.delete({ + where: { username }, + }); + + return { message: 'User deleted successfully' }; +} + +export async function getFetchHistory(username: string) { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + throw new Error('User not found'); + } + + return prisma.fetch.findMany({ + where: { userId: user.id }, + orderBy: { timestamp: 'desc' }, + take: 50, + }); +} + +// New function to get final grades +export async function getFinalGrades(username: string) { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + throw new Error('User not found'); + } + + return prisma.finalGrade.findMany({ + where: { userId: user.id }, + include: { + class: { + select: { + className: true, + teacher: true, + period: true, + category: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); +} \ No newline at end of file diff --git a/src/services/scraper.service.ts b/src/services/scraper.service.ts new file mode 100644 index 0000000..0ae5bf1 --- /dev/null +++ b/src/services/scraper.service.ts @@ -0,0 +1,65 @@ +interface GradeData { + className: string; + teacher: string; + period: string; + category: string; + overallGrade?: string; + grades: Array<{ + name: string; + dueDate: string; + score: string; + attempts: string; + isMajorGrade: boolean; + }>; +} + +interface ScraperResponse { + success: boolean; + totalClasses: number; + grades: GradeData[]; + error?: string; + message?: string; +} + +export async function fetchGradesFromScraper( + username: string, + password: string +): Promise { + const scraperUrl = process.env.SCRAPER_API_URL || 'http://localhost:3000'; + + try { + const response = await fetch(`${scraperUrl}/fetch-grades`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + // Handle 429 specifically + if (response.status === 429) { + const errorData = await response.json().catch(() => ({})) as { message?: string; error?: string }; + throw new Error(errorData.message || errorData.error || 'Request already in progress for this user'); + } + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) as { error?: string }; + throw new Error( + errorData.error || `Scraper API error: ${response.status}` + ); + } + + const data = await response.json() as ScraperResponse; + + if (!data.success || !data.grades) { + throw new Error('Invalid response from scraper API'); + } + + return data.grades; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to fetch grades: ${error.message}`); + } + throw new Error('Failed to fetch grades: Unknown error'); + } +} \ No newline at end of file diff --git a/src/services/stats.service.ts b/src/services/stats.service.ts new file mode 100644 index 0000000..3774c29 --- /dev/null +++ b/src/services/stats.service.ts @@ -0,0 +1,204 @@ +import { prisma } from '../db'; + +export async function getOverallStats() { + const [userCount, classCount, assignmentCount, lastFetch] = await Promise.all([ + prisma.user.count(), + prisma.class.count(), + prisma.assignment.count(), + prisma.fetch.findFirst({ + orderBy: { timestamp: 'desc' }, + include: { user: { select: { username: true } } }, + }), + ]); + + // Calculate average grade across all assignments + const assignments = await prisma.assignment.findMany({ + select: { score: true }, + }); + + let totalScore = 0; + let validScores = 0; + + for (const assignment of assignments) { + const scoreMatch = assignment.score.match(/[\d.]+/); + if (scoreMatch) { + totalScore += parseFloat(scoreMatch[0]); + validScores++; + } + } + + const averageGrade = validScores > 0 ? totalScore / validScores : 0; + + return { + totalUsers: userCount, + totalClasses: classCount, + totalAssignments: assignmentCount, + averageGrade: averageGrade.toFixed(2), + lastSync: lastFetch + ? { + username: lastFetch.user.username, + timestamp: lastFetch.timestamp, + } + : null, + }; +} + +export async function getUserStats(username: string) { + const user = await prisma.user.findUnique({ + where: { username }, + include: { + classes: { + include: { + assignments: true, + }, + }, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const totalClasses = user.classes.length; + const totalAssignments = user.classes.reduce( + (sum, cls) => sum + cls.assignments.length, + 0 + ); + + // Calculate average grade + let totalScore = 0; + let validScores = 0; + + for (const cls of user.classes) { + for (const assignment of cls.assignments) { + const scoreMatch = assignment.score.match(/[\d.]+/); + if (scoreMatch) { + totalScore += parseFloat(scoreMatch[0]); + validScores++; + } + } + } + + const averageGrade = validScores > 0 ? totalScore / validScores : 0; + + // Find best and worst class + const classAverages = user.classes.map(cls => { + let classTotal = 0; + let classValid = 0; + + for (const assignment of cls.assignments) { + const scoreMatch = assignment.score.match(/[\d.]+/); + if (scoreMatch) { + classTotal += parseFloat(scoreMatch[0]); + classValid++; + } + } + + return { + className: cls.className, + category: cls.category, + average: classValid > 0 ? classTotal / classValid : 0, + }; + }); + + classAverages.sort((a, b) => b.average - a.average); + + return { + username: user.username, + totalClasses, + totalAssignments, + averageGrade: averageGrade.toFixed(2), + bestClass: classAverages[0] || null, + worstClass: classAverages[classAverages.length - 1] || null, + classBreakdown: classAverages, + }; +} + +export async function getClassStats(username: string, category: string, className?: string) { + const user = await prisma.user.findUnique({ + where: { username }, + }); + + if (!user) { + throw new Error('User not found'); + } + + // Find classes by userId + category (and optionally className) + const classes = await prisma.class.findMany({ + where: { + userId: user.id, + category, + ...(className && { className }), // Optional className filter + }, + include: { + assignments: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (classes.length === 0) { + throw new Error('Class not found'); + } + + // Calculate stats for each class + const results = classes.map(classRecord => { + let totalScore = 0; + let validScores = 0; + let majorCount = 0; + let minorCount = 0; + + const gradeDistribution: { [key: string]: number } = { + '90-100': 0, + '80-89': 0, + '70-79': 0, + 'Below 70': 0, + }; + + for (const assignment of classRecord.assignments) { + if (assignment.isMajorGrade) { + majorCount++; + } else { + minorCount++; + } + + const scoreMatch = assignment.score.match(/[\d.]+/); + if (scoreMatch) { + const score = parseFloat(scoreMatch[0]); + totalScore += score; + validScores++; + + if (score >= 90) gradeDistribution['90-100'] = (gradeDistribution['90-100'] || 0) + 1; + else if (score >= 80) gradeDistribution['80-89'] = (gradeDistribution['80-89'] || 0) + 1; + else if (score >= 70) gradeDistribution['70-79'] = (gradeDistribution['70-79'] || 0) + 1; + else gradeDistribution['Below 70'] = (gradeDistribution['Below 70'] || 0) + 1; + } + } + + const averageScore = validScores > 0 ? totalScore / validScores : 0; + + return { + className: classRecord.className, + teacher: classRecord.teacher, + period: classRecord.period, + category: classRecord.category, + totalAssignments: classRecord.assignments.length, + majorGrades: majorCount, + minorGrades: minorCount, + averageScore: averageScore.toFixed(2), + gradeDistribution, + recentAssignments: classRecord.assignments.slice(0, 5), + }; + }); + + // If only one class, return it directly + if (results.length === 1) { + return results[0]; + } + + // If multiple classes with same category, return all + return { + category, + classes: results, + }; +} \ No newline at end of file diff --git a/src/services/sync.service.ts b/src/services/sync.service.ts new file mode 100644 index 0000000..389cd79 --- /dev/null +++ b/src/services/sync.service.ts @@ -0,0 +1,110 @@ +import { fetchGradesFromScraper } from './scraper.service'; +import { syncGradesToDatabase, getAllUsers } from './grades.service'; + +interface SyncResult { + username: string; + success: boolean; + error?: string; + classesProcessed?: number; + assignmentsAdded?: number; + assignmentsUpdated?: number; +} + +export async function syncUserGrades( + username: string, + password: string +): Promise { + try { + console.log(`[Sync] Starting sync for user: ${username}`); + + // Step 1: Fetch from scraper + const gradesData = await fetchGradesFromScraper(username, password); + + // Step 2: Save to database + const result = await syncGradesToDatabase(username, password, gradesData); + + console.log(`[Sync] āœ“ Completed for ${username}: ${result.classesProcessed} classes, ${result.assignmentsAdded} new, ${result.assignmentsUpdated} updated`); + + return { + username, + success: true, + classesProcessed: result.classesProcessed, + assignmentsAdded: result.assignmentsAdded, + assignmentsUpdated: result.assignmentsUpdated, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Sync] āœ— Failed for ${username}: ${errorMsg}`); + + return { + username, + success: false, + error: errorMsg, + }; + } +} + +export async function syncMultipleUsers(): Promise<{ + total: number; + successful: number; + failed: number; + results: SyncResult[]; + duration: number; +}> { + const startTime = Date.now(); + const maxConcurrent = parseInt(process.env.MAX_CONCURRENT_SYNCS || '3'); + + console.log(`\n[Sync Job] Starting batch sync (max ${maxConcurrent} concurrent)`); + + // Get all users with their credentials + const users = await getAllUsers(); + + if (users.length === 0) { + console.log('[Sync Job] No users to sync'); + return { + total: 0, + successful: 0, + failed: 0, + results: [], + duration: 0, + }; + } + + // We need to get passwords, so fetch full user records + const { prisma } = await import('../db'); + const usersWithPasswords = await prisma.user.findMany({ + select: { + username: true, + password: true, + }, + }); + + const results: SyncResult[] = []; + + // Process in batches + for (let i = 0; i < usersWithPasswords.length; i += maxConcurrent) { + const batch = usersWithPasswords.slice(i, i + maxConcurrent); + console.log(`[Sync Job] Processing batch ${Math.floor(i / maxConcurrent) + 1}: ${batch.map(u => u.username).join(', ')}`); + + const batchPromises = batch.map(user => + syncUserGrades(user.username, user.password) + ); + + const batchResults = await Promise.all(batchPromises); + results.push(...batchResults); + } + + const successful = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + const duration = Date.now() - startTime; + + console.log(`[Sync Job] Completed: ${successful} successful, ${failed} failed (${(duration / 1000).toFixed(1)}s)\n`); + + return { + total: users.length, + successful, + failed, + results, + duration, + }; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}