diff --git a/.VSCodeCounter/2026-02-03_15-48-00/details.md b/.VSCodeCounter/2026-02-03_15-48-00/details.md
new file mode 100644
index 0000000..38a3dfd
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/details.md
@@ -0,0 +1,116 @@
+# Details
+
+Date : 2026-02-03 15:48:00
+
+Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
+
+Total : 101 files, 40545 codes, 597 comments, 2288 blanks, all 43430 lines
+
+[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
+
+## Files
+| filename | language | code | comment | blank | total |
+| :--- | :--- | ---: | ---: | ---: | ---: |
+| [AGENTS.md](/AGENTS.md) | Markdown | 98 | 2 | 21 | 121 |
+| [README.md](/README.md) | Markdown | 3 | 0 | 2 | 5 |
+| [backend/Dockerfile](/backend/Dockerfile) | Docker | 14 | 2 | 10 | 26 |
+| [backend/README.md](/backend/README.md) | Markdown | 1 | 0 | 0 | 1 |
+| [backend/bun.lock](/backend/bun.lock) | JSON with Comments | 170 | 0 | 145 | 315 |
+| [backend/dist/index.js](/backend/dist/index.js) | JavaScript | 29,463 | 323 | 446 | 30,232 |
+| [backend/drizzle.config.ts](/backend/drizzle.config.ts) | TypeScript | 10 | 0 | 2 | 12 |
+| [backend/package.json](/backend/package.json) | JSON | 25 | 0 | 1 | 26 |
+| [backend/scripts/reset-projects.ts](/backend/scripts/reset-projects.ts) | TypeScript | 6 | 0 | 3 | 9 |
+| [backend/src/config.ts](/backend/src/config.ts) | TypeScript | 19 | 5 | 8 | 32 |
+| [backend/src/db/index.ts](/backend/src/db/index.ts) | TypeScript | 3 | 0 | 2 | 5 |
+| [backend/src/db/schema.ts](/backend/src/db/schema.ts) | TypeScript | 2 | 0 | 1 | 3 |
+| [backend/src/index.ts](/backend/src/index.ts) | TypeScript | 35 | 0 | 4 | 39 |
+| [backend/src/lib/auth.ts](/backend/src/lib/auth.ts) | TypeScript | 181 | 0 | 30 | 211 |
+| [backend/src/lib/scraps.ts](/backend/src/lib/scraps.ts) | TypeScript | 52 | 1 | 10 | 63 |
+| [backend/src/lib/slack.ts](/backend/src/lib/slack.ts) | TypeScript | 42 | 0 | 7 | 49 |
+| [backend/src/routes/admin.ts](/backend/src/routes/admin.ts) | TypeScript | 505 | 14 | 109 | 628 |
+| [backend/src/routes/auth.ts](/backend/src/routes/auth.ts) | TypeScript | 100 | 4 | 17 | 121 |
+| [backend/src/routes/hackatime.ts](/backend/src/routes/hackatime.ts) | TypeScript | 53 | 0 | 13 | 66 |
+| [backend/src/routes/items.ts](/backend/src/routes/items.ts) | TypeScript | 73 | 5 | 5 | 83 |
+| [backend/src/routes/leaderboard.ts](/backend/src/routes/leaderboard.ts) | TypeScript | 146 | 0 | 21 | 167 |
+| [backend/src/routes/news.ts](/backend/src/routes/news.ts) | TypeScript | 23 | 2 | 7 | 32 |
+| [backend/src/routes/projects.ts](/backend/src/routes/projects.ts) | TypeScript | 329 | 10 | 63 | 402 |
+| [backend/src/routes/shop.ts](/backend/src/routes/shop.ts) | TypeScript | 653 | 2 | 122 | 777 |
+| [backend/src/routes/upload.ts](/backend/src/routes/upload.ts) | TypeScript | 60 | 0 | 16 | 76 |
+| [backend/src/routes/user.ts](/backend/src/routes/user.ts) | TypeScript | 141 | 4 | 23 | 168 |
+| [backend/src/schemas/activity.ts](/backend/src/schemas/activity.ts) | TypeScript | 10 | 0 | 2 | 12 |
+| [backend/src/schemas/index.ts](/backend/src/schemas/index.ts) | TypeScript | 7 | 0 | 1 | 8 |
+| [backend/src/schemas/news.ts](/backend/src/schemas/news.ts) | TypeScript | 9 | 0 | 2 | 11 |
+| [backend/src/schemas/projects.ts](/backend/src/schemas/projects.ts) | TypeScript | 19 | 8 | 8 | 35 |
+| [backend/src/schemas/reviews.ts](/backend/src/schemas/reviews.ts) | TypeScript | 12 | 0 | 4 | 16 |
+| [backend/src/schemas/sessions.ts](/backend/src/schemas/sessions.ts) | TypeScript | 8 | 0 | 2 | 10 |
+| [backend/src/schemas/shop.ts](/backend/src/schemas/shop.ts) | TypeScript | 66 | 0 | 7 | 73 |
+| [backend/src/schemas/users.ts](/backend/src/schemas/users.ts) | TypeScript | 24 | 5 | 9 | 38 |
+| [backend/tsconfig.json](/backend/tsconfig.json) | JSON with Comments | 12 | 83 | 9 | 104 |
+| [frontend/.prettierignore](/frontend/.prettierignore) | Ignore | 6 | 2 | 2 | 10 |
+| [frontend/.prettierrc](/frontend/.prettierrc) | JSON | 16 | 0 | 1 | 17 |
+| [frontend/Dockerfile](/frontend/Dockerfile) | Docker | 13 | 2 | 10 | 25 |
+| [frontend/bun.lock](/frontend/bun.lock) | JSON with Comments | 397 | 0 | 359 | 756 |
+| [frontend/eslint.config.js](/frontend/eslint.config.js) | JavaScript | 35 | 2 | 3 | 40 |
+| [frontend/nginx.conf](/frontend/nginx.conf) | Properties | 15 | 3 | 4 | 22 |
+| [frontend/package.json](/frontend/package.json) | JSON | 47 | 0 | 1 | 48 |
+| [frontend/src/app.d.ts](/frontend/src/app.d.ts) | TypeScript | 5 | 7 | 2 | 14 |
+| [frontend/src/app.html](/frontend/src/app.html) | HTML | 11 | 0 | 1 | 12 |
+| [frontend/src/lib/auth-client.ts](/frontend/src/lib/auth-client.ts) | TypeScript | 63 | 0 | 11 | 74 |
+| [frontend/src/lib/components/AddressSelectModal.svelte](/frontend/src/lib/components/AddressSelectModal.svelte) | Svelte | 315 | 0 | 28 | 343 |
+| [frontend/src/lib/components/ConfirmModal.svelte](/frontend/src/lib/components/ConfirmModal.svelte) | Svelte | 69 | 0 | 4 | 73 |
+| [frontend/src/lib/components/CreateProjectModal.svelte](/frontend/src/lib/components/CreateProjectModal.svelte) | Svelte | 350 | 6 | 38 | 394 |
+| [frontend/src/lib/components/Footer.svelte](/frontend/src/lib/components/Footer.svelte) | Svelte | 5 | 0 | 2 | 7 |
+| [frontend/src/lib/components/HeartButton.svelte](/frontend/src/lib/components/HeartButton.svelte) | Svelte | 24 | 0 | 3 | 27 |
+| [frontend/src/lib/components/Navbar.svelte](/frontend/src/lib/components/Navbar.svelte) | Svelte | 300 | 8 | 25 | 333 |
+| [frontend/src/lib/components/NewsCarousel.svelte](/frontend/src/lib/components/NewsCarousel.svelte) | Svelte | 96 | 0 | 13 | 109 |
+| [frontend/src/lib/components/ProjectModal.svelte](/frontend/src/lib/components/ProjectModal.svelte) | Svelte | 343 | 3 | 35 | 381 |
+| [frontend/src/lib/components/ProjectPlaceholder.svelte](/frontend/src/lib/components/ProjectPlaceholder.svelte) | Svelte | 35 | 0 | 7 | 42 |
+| [frontend/src/lib/components/RandomPhrase.svelte](/frontend/src/lib/components/RandomPhrase.svelte) | Svelte | 15 | 0 | 3 | 18 |
+| [frontend/src/lib/components/ShopItemModal.svelte](/frontend/src/lib/components/ShopItemModal.svelte) | Svelte | 389 | 0 | 30 | 419 |
+| [frontend/src/lib/components/Superscript.svelte](/frontend/src/lib/components/Superscript.svelte) | Svelte | 11 | 0 | 2 | 13 |
+| [frontend/src/lib/components/Tutorial.svelte](/frontend/src/lib/components/Tutorial.svelte) | Svelte | 359 | 15 | 33 | 407 |
+| [frontend/src/lib/components/WishlistAvatars.svelte](/frontend/src/lib/components/WishlistAvatars.svelte) | Svelte | 108 | 3 | 12 | 123 |
+| [frontend/src/lib/config.ts](/frontend/src/lib/config.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/lib/stores.ts](/frontend/src/lib/stores.ts) | TypeScript | 235 | 7 | 35 | 277 |
+| [frontend/src/lib/utils.ts](/frontend/src/lib/utils.ts) | TypeScript | 3 | 0 | 1 | 4 |
+| [frontend/src/routes/+layout.svelte](/frontend/src/routes/+layout.svelte) | Svelte | 51 | 0 | 9 | 60 |
+| [frontend/src/routes/+layout.ts](/frontend/src/routes/+layout.ts) | TypeScript | 1 | 0 | 0 | 1 |
+| [frontend/src/routes/+page.svelte](/frontend/src/routes/+page.svelte) | Svelte | 249 | 6 | 40 | 295 |
+| [frontend/src/routes/admin/news/+page.svelte](/frontend/src/routes/admin/news/+page.svelte) | Svelte | 307 | 0 | 32 | 339 |
+| [frontend/src/routes/admin/reviews/+page.svelte](/frontend/src/routes/admin/reviews/+page.svelte) | Svelte | 123 | 1 | 12 | 136 |
+| [frontend/src/routes/admin/reviews/\[id\]/+page.svelte](/frontend/src/routes/admin/reviews/%5Bid%5D/+page.svelte) | Svelte | 472 | 7 | 37 | 516 |
+| [frontend/src/routes/admin/shop/+page.svelte](/frontend/src/routes/admin/shop/+page.svelte) | Svelte | 400 | 0 | 34 | 434 |
+| [frontend/src/routes/admin/users/+page.svelte](/frontend/src/routes/admin/users/+page.svelte) | Svelte | 291 | 3 | 26 | 320 |
+| [frontend/src/routes/admin/users/\[id\]/+page.svelte](/frontend/src/routes/admin/users/%5Bid%5D/+page.svelte) | Svelte | 313 | 4 | 25 | 342 |
+| [frontend/src/routes/admin/users/\[id\]/+page.ts](/frontend/src/routes/admin/users/%5Bid%5D/+page.ts) | TypeScript | 5 | 0 | 1 | 6 |
+| [frontend/src/routes/auth/callback/+page.svelte](/frontend/src/routes/auth/callback/+page.svelte) | Svelte | 52 | 0 | 7 | 59 |
+| [frontend/src/routes/auth/callback/+page.ts](/frontend/src/routes/auth/callback/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/routes/auth/error/+page.svelte](/frontend/src/routes/auth/error/+page.svelte) | Svelte | 50 | 0 | 8 | 58 |
+| [frontend/src/routes/auth/error/+page.ts](/frontend/src/routes/auth/error/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/routes/dashboard/+page.svelte](/frontend/src/routes/dashboard/+page.svelte) | Svelte | 113 | 4 | 14 | 131 |
+| [frontend/src/routes/dashboard/+page.ts](/frontend/src/routes/dashboard/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/routes/layout.css](/frontend/src/routes/layout.css) | PostCSS | 20 | 1 | 4 | 25 |
+| [frontend/src/routes/leaderboard/+page.svelte](/frontend/src/routes/leaderboard/+page.svelte) | Svelte | 156 | 0 | 8 | 164 |
+| [frontend/src/routes/leaderboard/+page.ts](/frontend/src/routes/leaderboard/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/routes/project/\[id\]/+page.svelte](/frontend/src/routes/project/%5Bid%5D/+page.svelte) | Svelte | 186 | 4 | 23 | 213 |
+| [frontend/src/routes/projects/\[id\]/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/+page.svelte) | Svelte | 349 | 7 | 26 | 382 |
+| [frontend/src/routes/projects/\[id\]/+page.ts](/frontend/src/routes/projects/%5Bid%5D/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
+| [frontend/src/routes/projects/\[id\]/edit/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/edit/+page.svelte) | Svelte | 399 | 9 | 44 | 452 |
+| [frontend/src/routes/projects/\[id\]/edit/+page.ts](/frontend/src/routes/projects/%5Bid%5D/edit/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
+| [frontend/src/routes/projects/\[id\]/submit/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/submit/+page.svelte) | Svelte | 330 | 9 | 32 | 371 |
+| [frontend/src/routes/projects/\[id\]/submit/+page.ts](/frontend/src/routes/projects/%5Bid%5D/submit/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
+| [frontend/src/routes/projects/\[id\]/view/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/view/+page.svelte) | Svelte | 148 | 3 | 17 | 168 |
+| [frontend/src/routes/projects/\[id\]/view/+page.ts](/frontend/src/routes/projects/%5Bid%5D/view/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
+| [frontend/src/routes/refinery/+page.svelte](/frontend/src/routes/refinery/+page.svelte) | Svelte | 134 | 0 | 10 | 144 |
+| [frontend/src/routes/refinery/+page.ts](/frontend/src/routes/refinery/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/routes/shop/+page.svelte](/frontend/src/routes/shop/+page.svelte) | Svelte | 258 | 5 | 23 | 286 |
+| [frontend/src/routes/shop/+page.ts](/frontend/src/routes/shop/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
+| [frontend/src/routes/submit/+page.svelte](/frontend/src/routes/submit/+page.svelte) | Svelte | 151 | 0 | 19 | 170 |
+| [frontend/src/routes/users/\[id\]/+page.svelte](/frontend/src/routes/users/%5Bid%5D/+page.svelte) | Svelte | 277 | 5 | 18 | 300 |
+| [frontend/src/routes/users/\[id\]/+page.ts](/frontend/src/routes/users/%5Bid%5D/+page.ts) | TypeScript | 5 | 0 | 1 | 6 |
+| [frontend/static/site.webmanifest](/frontend/static/site.webmanifest) | JSON | 1 | 0 | 0 | 1 |
+| [frontend/svelte.config.js](/frontend/svelte.config.js) | JavaScript | 16 | 1 | 2 | 19 |
+| [frontend/tsconfig.json](/frontend/tsconfig.json) | JSON with Comments | 14 | 0 | 1 | 15 |
+| [frontend/vite.config.ts](/frontend/vite.config.ts) | TypeScript | 4 | 0 | 3 | 7 |
+
+[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/diff-details.md b/.VSCodeCounter/2026-02-03_15-48-00/diff-details.md
new file mode 100644
index 0000000..3877a92
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/diff-details.md
@@ -0,0 +1,15 @@
+# Diff Details
+
+Date : 2026-02-03 15:48:00
+
+Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
+
+Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
+
+[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
+
+## Files
+| filename | language | code | comment | blank | total |
+| :--- | :--- | ---: | ---: | ---: | ---: |
+
+[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/diff.csv b/.VSCodeCounter/2026-02-03_15-48-00/diff.csv
new file mode 100644
index 0000000..b7d8d75
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/diff.csv
@@ -0,0 +1,2 @@
+"filename", "language", "", "comment", "blank", "total"
+"Total", "-", , 0, 0, 0
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/diff.md b/.VSCodeCounter/2026-02-03_15-48-00/diff.md
new file mode 100644
index 0000000..459d347
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/diff.md
@@ -0,0 +1,19 @@
+# Diff Summary
+
+Date : 2026-02-03 15:48:00
+
+Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
+
+Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
+
+[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
+
+## Languages
+| language | files | code | comment | blank | total |
+| :--- | ---: | ---: | ---: | ---: | ---: |
+
+## Directories
+| path | files | code | comment | blank | total |
+| :--- | ---: | ---: | ---: | ---: | ---: |
+
+[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/diff.txt b/.VSCodeCounter/2026-02-03_15-48-00/diff.txt
new file mode 100644
index 0000000..e4cfe12
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/diff.txt
@@ -0,0 +1,22 @@
+Date : 2026-02-03 15:48:00
+Directory : c:\Users\Nathan\Documents\GitHub\scraps
+Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
+
+Languages
++----------+------------+------------+------------+------------+------------+
+| language | files | code | comment | blank | total |
++----------+------------+------------+------------+------------+------------+
++----------+------------+------------+------------+------------+------------+
+
+Directories
++------+------------+------------+------------+------------+------------+
+| path | files | code | comment | blank | total |
++------+------------+------------+------------+------------+------------+
++------+------------+------------+------------+------------+------------+
+
+Files
++----------+----------+------------+------------+------------+------------+
+| filename | language | code | comment | blank | total |
++----------+----------+------------+------------+------------+------------+
+| Total | | 0 | 0 | 0 | 0 |
++----------+----------+------------+------------+------------+------------+
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/results.csv b/.VSCodeCounter/2026-02-03_15-48-00/results.csv
new file mode 100644
index 0000000..d7933bc
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/results.csv
@@ -0,0 +1,103 @@
+"filename", "language", "Markdown", "TypeScript", "JSON with Comments", "JavaScript", "Svelte", "PostCSS", "Properties", "JSON", "HTML", "Docker", "Ignore", "comment", "blank", "total"
+"c:\Users\Nathan\Documents\GitHub\scraps\AGENTS.md", "Markdown", 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 21, 121
+"c:\Users\Nathan\Documents\GitHub\scraps\README.md", "Markdown", 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\Dockerfile", "Docker", 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 0, 2, 10, 26
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\README.md", "Markdown", 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\bun.lock", "JSON with Comments", 0, 0, 170, 0, 0, 0, 0, 0, 0, 0, 0, 0, 145, 315
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\dist\index.js", "JavaScript", 0, 0, 0, 29463, 0, 0, 0, 0, 0, 0, 0, 323, 446, 30232
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\drizzle.config.ts", "TypeScript", 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 12
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\package.json", "JSON", 0, 0, 0, 0, 0, 0, 0, 25, 0, 0, 0, 0, 1, 26
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\scripts\reset-projects.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 9
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\config.ts", "TypeScript", 0, 19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 8, 32
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\index.ts", "TypeScript", 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\schema.ts", "TypeScript", 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\index.ts", "TypeScript", 0, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 39
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\auth.ts", "TypeScript", 0, 181, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 211
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\scraps.ts", "TypeScript", 0, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 10, 63
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\slack.ts", "TypeScript", 0, 42, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 49
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\admin.ts", "TypeScript", 0, 505, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 109, 628
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\auth.ts", "TypeScript", 0, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 17, 121
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\hackatime.ts", "TypeScript", 0, 53, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 66
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\items.ts", "TypeScript", 0, 73, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 83
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\leaderboard.ts", "TypeScript", 0, 146, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 167
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\news.ts", "TypeScript", 0, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 7, 32
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\projects.ts", "TypeScript", 0, 329, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 63, 402
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\shop.ts", "TypeScript", 0, 653, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 122, 777
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\upload.ts", "TypeScript", 0, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 76
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\user.ts", "TypeScript", 0, 141, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 23, 168
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\activity.ts", "TypeScript", 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 12
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\index.ts", "TypeScript", 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 8
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\news.ts", "TypeScript", 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 11
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\projects.ts", "TypeScript", 0, 19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 35
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\reviews.ts", "TypeScript", 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 16
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\sessions.ts", "TypeScript", 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 10
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\shop.ts", "TypeScript", 0, 66, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 73
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\users.ts", "TypeScript", 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 9, 38
+"c:\Users\Nathan\Documents\GitHub\scraps\backend\tsconfig.json", "JSON with Comments", 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 83, 9, 104
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierignore", "Ignore", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 2, 2, 10
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierrc", "JSON", 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 1, 17
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\Dockerfile", "Docker", 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 0, 2, 10, 25
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\bun.lock", "JSON with Comments", 0, 0, 397, 0, 0, 0, 0, 0, 0, 0, 0, 0, 359, 756
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\eslint.config.js", "JavaScript", 0, 0, 0, 35, 0, 0, 0, 0, 0, 0, 0, 2, 3, 40
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\nginx.conf", "Properties", 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 3, 4, 22
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\package.json", "JSON", 0, 0, 0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 1, 48
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.d.ts", "TypeScript", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 2, 14
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 1, 12
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\auth-client.ts", "TypeScript", 0, 63, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 74
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\AddressSelectModal.svelte", "Svelte", 0, 0, 0, 0, 315, 0, 0, 0, 0, 0, 0, 0, 28, 343
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ConfirmModal.svelte", "Svelte", 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0, 4, 73
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\CreateProjectModal.svelte", "Svelte", 0, 0, 0, 0, 350, 0, 0, 0, 0, 0, 0, 6, 38, 394
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Footer.svelte", "Svelte", 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 2, 7
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\HeartButton.svelte", "Svelte", 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 3, 27
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Navbar.svelte", "Svelte", 0, 0, 0, 0, 300, 0, 0, 0, 0, 0, 0, 8, 25, 333
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\NewsCarousel.svelte", "Svelte", 0, 0, 0, 0, 96, 0, 0, 0, 0, 0, 0, 0, 13, 109
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectModal.svelte", "Svelte", 0, 0, 0, 0, 343, 0, 0, 0, 0, 0, 0, 3, 35, 381
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectPlaceholder.svelte", "Svelte", 0, 0, 0, 0, 35, 0, 0, 0, 0, 0, 0, 0, 7, 42
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\RandomPhrase.svelte", "Svelte", 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 3, 18
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ShopItemModal.svelte", "Svelte", 0, 0, 0, 0, 389, 0, 0, 0, 0, 0, 0, 0, 30, 419
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Superscript.svelte", "Svelte", 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 2, 13
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Tutorial.svelte", "Svelte", 0, 0, 0, 0, 359, 0, 0, 0, 0, 0, 0, 15, 33, 407
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\WishlistAvatars.svelte", "Svelte", 0, 0, 0, 0, 108, 0, 0, 0, 0, 0, 0, 3, 12, 123
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\config.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\stores.ts", "TypeScript", 0, 235, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 35, 277
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\utils.ts", "TypeScript", 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.svelte", "Svelte", 0, 0, 0, 0, 51, 0, 0, 0, 0, 0, 0, 0, 9, 60
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+page.svelte", "Svelte", 0, 0, 0, 0, 249, 0, 0, 0, 0, 0, 0, 6, 40, 295
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\news\+page.svelte", "Svelte", 0, 0, 0, 0, 307, 0, 0, 0, 0, 0, 0, 0, 32, 339
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\+page.svelte", "Svelte", 0, 0, 0, 0, 123, 0, 0, 0, 0, 0, 0, 1, 12, 136
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 472, 0, 0, 0, 0, 0, 0, 7, 37, 516
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\shop\+page.svelte", "Svelte", 0, 0, 0, 0, 400, 0, 0, 0, 0, 0, 0, 0, 34, 434
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\+page.svelte", "Svelte", 0, 0, 0, 0, 291, 0, 0, 0, 0, 0, 0, 3, 26, 320
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 313, 0, 0, 0, 0, 0, 0, 4, 25, 342
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.ts", "TypeScript", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 6
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.svelte", "Svelte", 0, 0, 0, 0, 52, 0, 0, 0, 0, 0, 0, 0, 7, 59
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.svelte", "Svelte", 0, 0, 0, 0, 50, 0, 0, 0, 0, 0, 0, 0, 8, 58
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.svelte", "Svelte", 0, 0, 0, 0, 113, 0, 0, 0, 0, 0, 0, 4, 14, 131
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\layout.css", "PostCSS", 0, 0, 0, 0, 0, 20, 0, 0, 0, 0, 0, 1, 4, 25
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.svelte", "Svelte", 0, 0, 0, 0, 156, 0, 0, 0, 0, 0, 0, 0, 8, 164
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\project\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 186, 0, 0, 0, 0, 0, 0, 4, 23, 213
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 349, 0, 0, 0, 0, 0, 0, 7, 26, 382
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.svelte", "Svelte", 0, 0, 0, 0, 399, 0, 0, 0, 0, 0, 0, 9, 44, 452
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.svelte", "Svelte", 0, 0, 0, 0, 330, 0, 0, 0, 0, 0, 0, 9, 32, 371
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.svelte", "Svelte", 0, 0, 0, 0, 148, 0, 0, 0, 0, 0, 0, 3, 17, 168
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.svelte", "Svelte", 0, 0, 0, 0, 134, 0, 0, 0, 0, 0, 0, 0, 10, 144
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.svelte", "Svelte", 0, 0, 0, 0, 258, 0, 0, 0, 0, 0, 0, 5, 23, 286
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\submit\+page.svelte", "Svelte", 0, 0, 0, 0, 151, 0, 0, 0, 0, 0, 0, 0, 19, 170
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 277, 0, 0, 0, 0, 0, 0, 5, 18, 300
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.ts", "TypeScript", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 6
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\static\site.webmanifest", "JSON", 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\svelte.config.js", "JavaScript", 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 1, 2, 19
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\tsconfig.json", "JSON with Comments", 0, 0, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 15
+"c:\Users\Nathan\Documents\GitHub\scraps\frontend\vite.config.ts", "TypeScript", 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 7
+"Total", "-", 102, 2940, 593, 29514, 7228, 20, 15, 89, 11, 27, 6, 597, 2288, 43430
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/results.json b/.VSCodeCounter/2026-02-03_15-48-00/results.json
new file mode 100644
index 0000000..00de247
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/results.json
@@ -0,0 +1 @@
+{"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/AGENTS.md":{"language":"Markdown","code":98,"comment":2,"blank":21},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/vite.config.ts":{"language":"TypeScript","code":4,"comment":0,"blank":3},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/tsconfig.json":{"language":"JSON with Comments","code":14,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/svelte.config.js":{"language":"JavaScript","code":16,"comment":1,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/%2Blayout.svelte":{"language":"Svelte","code":51,"comment":0,"blank":9},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/%2Blayout.ts":{"language":"TypeScript","code":1,"comment":0,"blank":0},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/shop/%2Bpage.svelte":{"language":"Svelte","code":258,"comment":5,"blank":23},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/submit/%2Bpage.svelte":{"language":"Svelte","code":151,"comment":0,"blank":19},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/shop/%2Bpage.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/users/%5Bid%5D/%2Bpage.ts":{"language":"TypeScript","code":5,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/users/%5Bid%5D/%2Bpage.svelte":{"language":"Svelte","code":277,"comment":5,"blank":18},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/leaderboard/%2Bpage.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/refinery/%2Bpage.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/refinery/%2Bpage.svelte":{"language":"Svelte","code":134,"comment":0,"blank":10},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/leaderboard/%2Bpage.svelte":{"language":"Svelte","code":156,"comment":0,"blank":8},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/layout.css":{"language":"PostCSS","code":20,"comment":1,"blank":4},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/dashboard/%2Bpage.svelte":{"language":"Svelte","code":113,"comment":4,"blank":14},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/project/%5Bid%5D/%2Bpage.svelte":{"language":"Svelte","code":186,"comment":4,"blank":23},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/nginx.conf":{"language":"Properties","code":15,"comment":3,"blank":4},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/static/site.webmanifest":{"language":"JSON","code":1,"comment":0,"blank":0},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/view/%2Bpage.ts":{"language":"TypeScript","code":6,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/%2Bpage.ts":{"language":"TypeScript","code":6,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/view/%2Bpage.svelte":{"language":"Svelte","code":148,"comment":3,"blank":17},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/dashboard/%2Bpage.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/edit/%2Bpage.ts":{"language":"TypeScript","code":6,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/submit/%2Bpage.ts":{"language":"TypeScript","code":6,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/submit/%2Bpage.svelte":{"language":"Svelte","code":330,"comment":9,"blank":32},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/auth/error/%2Bpage.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/README.md":{"language":"Markdown","code":3,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/news/%2Bpage.svelte":{"language":"Svelte","code":307,"comment":0,"blank":32},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/%2Bpage.svelte":{"language":"Svelte","code":249,"comment":6,"blank":40},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/users/%2Bpage.svelte":{"language":"Svelte","code":291,"comment":3,"blank":26},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/shop/%2Bpage.svelte":{"language":"Svelte","code":400,"comment":0,"blank":34},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/reviews/%2Bpage.svelte":{"language":"Svelte","code":123,"comment":1,"blank":12},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/auth/error/%2Bpage.svelte":{"language":"Svelte","code":50,"comment":0,"blank":8},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/reviews/%5Bid%5D/%2Bpage.svelte":{"language":"Svelte","code":472,"comment":7,"blank":37},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/%2Bpage.svelte":{"language":"Svelte","code":349,"comment":7,"blank":26},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/users/%5Bid%5D/%2Bpage.ts":{"language":"TypeScript","code":5,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/admin/users/%5Bid%5D/%2Bpage.svelte":{"language":"Svelte","code":313,"comment":4,"blank":25},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/auth/callback/%2Bpage.svelte":{"language":"Svelte","code":52,"comment":0,"blank":7},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/app.html":{"language":"HTML","code":11,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/auth/callback/%2Bpage.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/app.d.ts":{"language":"TypeScript","code":5,"comment":7,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/routes/projects/%5Bid%5D/edit/%2Bpage.svelte":{"language":"Svelte","code":399,"comment":9,"blank":44},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/package.json":{"language":"JSON","code":47,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/tsconfig.json":{"language":"JSON with Comments","code":12,"comment":83,"blank":9},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/utils.ts":{"language":"TypeScript","code":3,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/stores.ts":{"language":"TypeScript","code":235,"comment":7,"blank":35},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/Dockerfile":{"language":"Docker","code":13,"comment":2,"blank":10},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/WishlistAvatars.svelte":{"language":"Svelte","code":108,"comment":3,"blank":12},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/.prettierrc":{"language":"JSON","code":16,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/config.ts":{"language":"TypeScript","code":1,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/config.ts":{"language":"TypeScript","code":19,"comment":5,"blank":8},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/index.ts":{"language":"TypeScript","code":35,"comment":0,"blank":4},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/db/schema.ts":{"language":"TypeScript","code":2,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/lib/auth.ts":{"language":"TypeScript","code":181,"comment":0,"blank":30},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/lib/slack.ts":{"language":"TypeScript","code":42,"comment":0,"blank":7},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/lib/scraps.ts":{"language":"TypeScript","code":52,"comment":1,"blank":10},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/activity.ts":{"language":"TypeScript","code":10,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/hackatime.ts":{"language":"TypeScript","code":53,"comment":0,"blank":13},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/admin.ts":{"language":"TypeScript","code":505,"comment":14,"blank":109},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/items.ts":{"language":"TypeScript","code":73,"comment":5,"blank":5},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/auth.ts":{"language":"TypeScript","code":100,"comment":4,"blank":17},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/leaderboard.ts":{"language":"TypeScript","code":146,"comment":0,"blank":21},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/news.ts":{"language":"TypeScript","code":23,"comment":2,"blank":7},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/eslint.config.js":{"language":"JavaScript","code":35,"comment":2,"blank":3},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/index.ts":{"language":"TypeScript","code":7,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/projects.ts":{"language":"TypeScript","code":19,"comment":8,"blank":8},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/projects.ts":{"language":"TypeScript","code":329,"comment":10,"blank":63},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/news.ts":{"language":"TypeScript","code":9,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/sessions.ts":{"language":"TypeScript","code":8,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/user.ts":{"language":"TypeScript","code":141,"comment":4,"blank":23},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/upload.ts":{"language":"TypeScript","code":60,"comment":0,"blank":16},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/shop.ts":{"language":"TypeScript","code":66,"comment":0,"blank":7},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/routes/shop.ts":{"language":"TypeScript","code":653,"comment":2,"blank":122},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/db/index.ts":{"language":"TypeScript","code":3,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/reviews.ts":{"language":"TypeScript","code":12,"comment":0,"blank":4},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/Tutorial.svelte":{"language":"Svelte","code":359,"comment":15,"blank":33},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/src/schemas/users.ts":{"language":"TypeScript","code":24,"comment":5,"blank":9},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/ShopItemModal.svelte":{"language":"Svelte","code":389,"comment":0,"blank":30},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/Superscript.svelte":{"language":"Svelte","code":11,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/RandomPhrase.svelte":{"language":"Svelte","code":15,"comment":0,"blank":3},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/bun.lock":{"language":"JSON with Comments","code":397,"comment":0,"blank":359},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/ProjectPlaceholder.svelte":{"language":"Svelte","code":35,"comment":0,"blank":7},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/ProjectModal.svelte":{"language":"Svelte","code":343,"comment":3,"blank":35},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/NewsCarousel.svelte":{"language":"Svelte","code":96,"comment":0,"blank":13},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/Navbar.svelte":{"language":"Svelte","code":300,"comment":8,"blank":25},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/HeartButton.svelte":{"language":"Svelte","code":24,"comment":0,"blank":3},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/Footer.svelte":{"language":"Svelte","code":5,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/CreateProjectModal.svelte":{"language":"Svelte","code":350,"comment":6,"blank":38},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/ConfirmModal.svelte":{"language":"Svelte","code":69,"comment":0,"blank":4},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/components/AddressSelectModal.svelte":{"language":"Svelte","code":315,"comment":0,"blank":28},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/src/lib/auth-client.ts":{"language":"TypeScript","code":63,"comment":0,"blank":11},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/drizzle.config.ts":{"language":"TypeScript","code":10,"comment":0,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/package.json":{"language":"JSON","code":25,"comment":0,"blank":1},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/frontend/.prettierignore":{"language":"Ignore","code":6,"comment":2,"blank":2},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/Dockerfile":{"language":"Docker","code":14,"comment":2,"blank":10},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/scripts/reset-projects.ts":{"language":"TypeScript","code":6,"comment":0,"blank":3},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/README.md":{"language":"Markdown","code":1,"comment":0,"blank":0},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/bun.lock":{"language":"JSON with Comments","code":170,"comment":0,"blank":145},"file:///c%3A/Users/Nathan/Documents/GitHub/scraps/backend/dist/index.js":{"language":"JavaScript","code":29463,"comment":323,"blank":446}}
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/results.md b/.VSCodeCounter/2026-02-03_15-48-00/results.md
new file mode 100644
index 0000000..9ec68b8
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/results.md
@@ -0,0 +1,79 @@
+# Summary
+
+Date : 2026-02-03 15:48:00
+
+Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
+
+Total : 101 files, 40545 codes, 597 comments, 2288 blanks, all 43430 lines
+
+Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
+
+## Languages
+| language | files | code | comment | blank | total |
+| :--- | ---: | ---: | ---: | ---: | ---: |
+| JavaScript | 3 | 29,514 | 326 | 451 | 30,291 |
+| Svelte | 35 | 7,228 | 102 | 699 | 8,029 |
+| TypeScript | 46 | 2,940 | 74 | 567 | 3,581 |
+| JSON with Comments | 4 | 593 | 83 | 514 | 1,190 |
+| Markdown | 3 | 102 | 2 | 23 | 127 |
+| JSON | 4 | 89 | 0 | 3 | 92 |
+| Docker | 2 | 27 | 4 | 20 | 51 |
+| PostCSS | 1 | 20 | 1 | 4 | 25 |
+| Properties | 1 | 15 | 3 | 4 | 22 |
+| HTML | 1 | 11 | 0 | 1 | 12 |
+| Ignore | 1 | 6 | 2 | 2 | 10 |
+
+## Directories
+| path | files | code | comment | blank | total |
+| :--- | ---: | ---: | ---: | ---: | ---: |
+| . | 101 | 40,545 | 597 | 2,288 | 43,430 |
+| . (Files) | 2 | 101 | 2 | 23 | 126 |
+| backend | 33 | 32,273 | 468 | 1,109 | 33,850 |
+| backend (Files) | 6 | 232 | 85 | 167 | 484 |
+| backend\\dist | 1 | 29,463 | 323 | 446 | 30,232 |
+| backend\\scripts | 1 | 6 | 0 | 3 | 9 |
+| backend\\src | 25 | 2,572 | 60 | 493 | 3,125 |
+| backend\\src (Files) | 2 | 54 | 5 | 12 | 71 |
+| backend\\src\\db | 2 | 5 | 0 | 3 | 8 |
+| backend\\src\\lib | 3 | 275 | 1 | 47 | 323 |
+| backend\\src\\routes | 10 | 2,083 | 41 | 396 | 2,520 |
+| backend\\src\\schemas | 8 | 155 | 13 | 35 | 203 |
+| frontend | 66 | 8,171 | 127 | 1,156 | 9,454 |
+| frontend (Files) | 10 | 563 | 10 | 386 | 959 |
+| frontend\\src | 55 | 7,607 | 117 | 770 | 8,494 |
+| frontend\\src (Files) | 2 | 16 | 7 | 3 | 26 |
+| frontend\\src\\lib | 18 | 2,721 | 42 | 283 | 3,046 |
+| frontend\\src\\lib (Files) | 4 | 302 | 7 | 48 | 357 |
+| frontend\\src\\lib\\components | 14 | 2,419 | 35 | 235 | 2,689 |
+| frontend\\src\\routes | 35 | 4,870 | 68 | 484 | 5,422 |
+| frontend\\src\\routes (Files) | 4 | 321 | 7 | 53 | 381 |
+| frontend\\src\\routes\\admin | 7 | 1,911 | 15 | 167 | 2,093 |
+| frontend\\src\\routes\\admin\\news | 1 | 307 | 0 | 32 | 339 |
+| frontend\\src\\routes\\admin\\reviews | 2 | 595 | 8 | 49 | 652 |
+| frontend\\src\\routes\\admin\\reviews (Files) | 1 | 123 | 1 | 12 | 136 |
+| frontend\\src\\routes\\admin\\reviews\\[id] | 1 | 472 | 7 | 37 | 516 |
+| frontend\\src\\routes\\admin\\shop | 1 | 400 | 0 | 34 | 434 |
+| frontend\\src\\routes\\admin\\users | 3 | 609 | 7 | 52 | 668 |
+| frontend\\src\\routes\\admin\\users (Files) | 1 | 291 | 3 | 26 | 320 |
+| frontend\\src\\routes\\admin\\users\\[id] | 2 | 318 | 4 | 26 | 348 |
+| frontend\\src\\routes\\auth | 4 | 104 | 0 | 17 | 121 |
+| frontend\\src\\routes\\auth\\callback | 2 | 53 | 0 | 8 | 61 |
+| frontend\\src\\routes\\auth\\error | 2 | 51 | 0 | 9 | 60 |
+| frontend\\src\\routes\\dashboard | 2 | 114 | 4 | 15 | 133 |
+| frontend\\src\\routes\\leaderboard | 2 | 157 | 0 | 9 | 166 |
+| frontend\\src\\routes\\project | 1 | 186 | 4 | 23 | 213 |
+| frontend\\src\\routes\\project\\[id] | 1 | 186 | 4 | 23 | 213 |
+| frontend\\src\\routes\\projects | 8 | 1,250 | 28 | 127 | 1,405 |
+| frontend\\src\\routes\\projects\\[id] | 8 | 1,250 | 28 | 127 | 1,405 |
+| frontend\\src\\routes\\projects\\[id] (Files) | 2 | 355 | 7 | 28 | 390 |
+| frontend\\src\\routes\\projects\\[id]\\edit | 2 | 405 | 9 | 46 | 460 |
+| frontend\\src\\routes\\projects\\[id]\\submit | 2 | 336 | 9 | 34 | 379 |
+| frontend\\src\\routes\\projects\\[id]\\view | 2 | 154 | 3 | 19 | 176 |
+| frontend\\src\\routes\\refinery | 2 | 135 | 0 | 11 | 146 |
+| frontend\\src\\routes\\shop | 2 | 259 | 5 | 24 | 288 |
+| frontend\\src\\routes\\submit | 1 | 151 | 0 | 19 | 170 |
+| frontend\\src\\routes\\users | 2 | 282 | 5 | 19 | 306 |
+| frontend\\src\\routes\\users\\[id] | 2 | 282 | 5 | 19 | 306 |
+| frontend\\static | 1 | 1 | 0 | 0 | 1 |
+
+Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
\ No newline at end of file
diff --git a/.VSCodeCounter/2026-02-03_15-48-00/results.txt b/.VSCodeCounter/2026-02-03_15-48-00/results.txt
new file mode 100644
index 0000000..007cf5e
--- /dev/null
+++ b/.VSCodeCounter/2026-02-03_15-48-00/results.txt
@@ -0,0 +1,183 @@
+Date : 2026-02-03 15:48:00
+Directory : c:\Users\Nathan\Documents\GitHub\scraps
+Total : 101 files, 40545 codes, 597 comments, 2288 blanks, all 43430 lines
+
+Languages
++--------------------+------------+------------+------------+------------+------------+
+| language | files | code | comment | blank | total |
++--------------------+------------+------------+------------+------------+------------+
+| JavaScript | 3 | 29,514 | 326 | 451 | 30,291 |
+| Svelte | 35 | 7,228 | 102 | 699 | 8,029 |
+| TypeScript | 46 | 2,940 | 74 | 567 | 3,581 |
+| JSON with Comments | 4 | 593 | 83 | 514 | 1,190 |
+| Markdown | 3 | 102 | 2 | 23 | 127 |
+| JSON | 4 | 89 | 0 | 3 | 92 |
+| Docker | 2 | 27 | 4 | 20 | 51 |
+| PostCSS | 1 | 20 | 1 | 4 | 25 |
+| Properties | 1 | 15 | 3 | 4 | 22 |
+| HTML | 1 | 11 | 0 | 1 | 12 |
+| Ignore | 1 | 6 | 2 | 2 | 10 |
++--------------------+------------+------------+------------+------------+------------+
+
+Directories
++-----------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
+| path | files | code | comment | blank | total |
++-----------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
+| . | 101 | 40,545 | 597 | 2,288 | 43,430 |
+| . (Files) | 2 | 101 | 2 | 23 | 126 |
+| backend | 33 | 32,273 | 468 | 1,109 | 33,850 |
+| backend (Files) | 6 | 232 | 85 | 167 | 484 |
+| backend\dist | 1 | 29,463 | 323 | 446 | 30,232 |
+| backend\scripts | 1 | 6 | 0 | 3 | 9 |
+| backend\src | 25 | 2,572 | 60 | 493 | 3,125 |
+| backend\src (Files) | 2 | 54 | 5 | 12 | 71 |
+| backend\src\db | 2 | 5 | 0 | 3 | 8 |
+| backend\src\lib | 3 | 275 | 1 | 47 | 323 |
+| backend\src\routes | 10 | 2,083 | 41 | 396 | 2,520 |
+| backend\src\schemas | 8 | 155 | 13 | 35 | 203 |
+| frontend | 66 | 8,171 | 127 | 1,156 | 9,454 |
+| frontend (Files) | 10 | 563 | 10 | 386 | 959 |
+| frontend\src | 55 | 7,607 | 117 | 770 | 8,494 |
+| frontend\src (Files) | 2 | 16 | 7 | 3 | 26 |
+| frontend\src\lib | 18 | 2,721 | 42 | 283 | 3,046 |
+| frontend\src\lib (Files) | 4 | 302 | 7 | 48 | 357 |
+| frontend\src\lib\components | 14 | 2,419 | 35 | 235 | 2,689 |
+| frontend\src\routes | 35 | 4,870 | 68 | 484 | 5,422 |
+| frontend\src\routes (Files) | 4 | 321 | 7 | 53 | 381 |
+| frontend\src\routes\admin | 7 | 1,911 | 15 | 167 | 2,093 |
+| frontend\src\routes\admin\news | 1 | 307 | 0 | 32 | 339 |
+| frontend\src\routes\admin\reviews | 2 | 595 | 8 | 49 | 652 |
+| frontend\src\routes\admin\reviews (Files) | 1 | 123 | 1 | 12 | 136 |
+| frontend\src\routes\admin\reviews\[id] | 1 | 472 | 7 | 37 | 516 |
+| frontend\src\routes\admin\shop | 1 | 400 | 0 | 34 | 434 |
+| frontend\src\routes\admin\users | 3 | 609 | 7 | 52 | 668 |
+| frontend\src\routes\admin\users (Files) | 1 | 291 | 3 | 26 | 320 |
+| frontend\src\routes\admin\users\[id] | 2 | 318 | 4 | 26 | 348 |
+| frontend\src\routes\auth | 4 | 104 | 0 | 17 | 121 |
+| frontend\src\routes\auth\callback | 2 | 53 | 0 | 8 | 61 |
+| frontend\src\routes\auth\error | 2 | 51 | 0 | 9 | 60 |
+| frontend\src\routes\dashboard | 2 | 114 | 4 | 15 | 133 |
+| frontend\src\routes\leaderboard | 2 | 157 | 0 | 9 | 166 |
+| frontend\src\routes\project | 1 | 186 | 4 | 23 | 213 |
+| frontend\src\routes\project\[id] | 1 | 186 | 4 | 23 | 213 |
+| frontend\src\routes\projects | 8 | 1,250 | 28 | 127 | 1,405 |
+| frontend\src\routes\projects\[id] | 8 | 1,250 | 28 | 127 | 1,405 |
+| frontend\src\routes\projects\[id] (Files) | 2 | 355 | 7 | 28 | 390 |
+| frontend\src\routes\projects\[id]\edit | 2 | 405 | 9 | 46 | 460 |
+| frontend\src\routes\projects\[id]\submit | 2 | 336 | 9 | 34 | 379 |
+| frontend\src\routes\projects\[id]\view | 2 | 154 | 3 | 19 | 176 |
+| frontend\src\routes\refinery | 2 | 135 | 0 | 11 | 146 |
+| frontend\src\routes\shop | 2 | 259 | 5 | 24 | 288 |
+| frontend\src\routes\submit | 1 | 151 | 0 | 19 | 170 |
+| frontend\src\routes\users | 2 | 282 | 5 | 19 | 306 |
+| frontend\src\routes\users\[id] | 2 | 282 | 5 | 19 | 306 |
+| frontend\static | 1 | 1 | 0 | 0 | 1 |
++-----------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
+
+Files
++-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
+| filename | language | code | comment | blank | total |
++-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
+| c:\Users\Nathan\Documents\GitHub\scraps\AGENTS.md | Markdown | 98 | 2 | 21 | 121 |
+| c:\Users\Nathan\Documents\GitHub\scraps\README.md | Markdown | 3 | 0 | 2 | 5 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\Dockerfile | Docker | 14 | 2 | 10 | 26 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\README.md | Markdown | 1 | 0 | 0 | 1 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\bun.lock | JSON with Comments | 170 | 0 | 145 | 315 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\dist\index.js | JavaScript | 29,463 | 323 | 446 | 30,232 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\drizzle.config.ts | TypeScript | 10 | 0 | 2 | 12 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\package.json | JSON | 25 | 0 | 1 | 26 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\scripts\reset-projects.ts | TypeScript | 6 | 0 | 3 | 9 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\config.ts | TypeScript | 19 | 5 | 8 | 32 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\index.ts | TypeScript | 3 | 0 | 2 | 5 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\schema.ts | TypeScript | 2 | 0 | 1 | 3 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\index.ts | TypeScript | 35 | 0 | 4 | 39 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\auth.ts | TypeScript | 181 | 0 | 30 | 211 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\scraps.ts | TypeScript | 52 | 1 | 10 | 63 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\slack.ts | TypeScript | 42 | 0 | 7 | 49 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\admin.ts | TypeScript | 505 | 14 | 109 | 628 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\auth.ts | TypeScript | 100 | 4 | 17 | 121 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\hackatime.ts | TypeScript | 53 | 0 | 13 | 66 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\items.ts | TypeScript | 73 | 5 | 5 | 83 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\leaderboard.ts | TypeScript | 146 | 0 | 21 | 167 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\news.ts | TypeScript | 23 | 2 | 7 | 32 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\projects.ts | TypeScript | 329 | 10 | 63 | 402 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\shop.ts | TypeScript | 653 | 2 | 122 | 777 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\upload.ts | TypeScript | 60 | 0 | 16 | 76 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\user.ts | TypeScript | 141 | 4 | 23 | 168 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\activity.ts | TypeScript | 10 | 0 | 2 | 12 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\index.ts | TypeScript | 7 | 0 | 1 | 8 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\news.ts | TypeScript | 9 | 0 | 2 | 11 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\projects.ts | TypeScript | 19 | 8 | 8 | 35 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\reviews.ts | TypeScript | 12 | 0 | 4 | 16 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\sessions.ts | TypeScript | 8 | 0 | 2 | 10 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\shop.ts | TypeScript | 66 | 0 | 7 | 73 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\users.ts | TypeScript | 24 | 5 | 9 | 38 |
+| c:\Users\Nathan\Documents\GitHub\scraps\backend\tsconfig.json | JSON with Comments | 12 | 83 | 9 | 104 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierignore | Ignore | 6 | 2 | 2 | 10 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierrc | JSON | 16 | 0 | 1 | 17 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\Dockerfile | Docker | 13 | 2 | 10 | 25 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\bun.lock | JSON with Comments | 397 | 0 | 359 | 756 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\eslint.config.js | JavaScript | 35 | 2 | 3 | 40 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\nginx.conf | Properties | 15 | 3 | 4 | 22 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\package.json | JSON | 47 | 0 | 1 | 48 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.d.ts | TypeScript | 5 | 7 | 2 | 14 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.html | HTML | 11 | 0 | 1 | 12 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\auth-client.ts | TypeScript | 63 | 0 | 11 | 74 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\AddressSelectModal.svelte | Svelte | 315 | 0 | 28 | 343 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ConfirmModal.svelte | Svelte | 69 | 0 | 4 | 73 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\CreateProjectModal.svelte | Svelte | 350 | 6 | 38 | 394 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Footer.svelte | Svelte | 5 | 0 | 2 | 7 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\HeartButton.svelte | Svelte | 24 | 0 | 3 | 27 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Navbar.svelte | Svelte | 300 | 8 | 25 | 333 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\NewsCarousel.svelte | Svelte | 96 | 0 | 13 | 109 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectModal.svelte | Svelte | 343 | 3 | 35 | 381 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectPlaceholder.svelte | Svelte | 35 | 0 | 7 | 42 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\RandomPhrase.svelte | Svelte | 15 | 0 | 3 | 18 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ShopItemModal.svelte | Svelte | 389 | 0 | 30 | 419 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Superscript.svelte | Svelte | 11 | 0 | 2 | 13 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Tutorial.svelte | Svelte | 359 | 15 | 33 | 407 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\WishlistAvatars.svelte | Svelte | 108 | 3 | 12 | 123 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\config.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\stores.ts | TypeScript | 235 | 7 | 35 | 277 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\utils.ts | TypeScript | 3 | 0 | 1 | 4 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.svelte | Svelte | 51 | 0 | 9 | 60 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.ts | TypeScript | 1 | 0 | 0 | 1 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+page.svelte | Svelte | 249 | 6 | 40 | 295 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\news\+page.svelte | Svelte | 307 | 0 | 32 | 339 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\+page.svelte | Svelte | 123 | 1 | 12 | 136 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\[id]\+page.svelte | Svelte | 472 | 7 | 37 | 516 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\shop\+page.svelte | Svelte | 400 | 0 | 34 | 434 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\+page.svelte | Svelte | 291 | 3 | 26 | 320 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.svelte | Svelte | 313 | 4 | 25 | 342 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.ts | TypeScript | 5 | 0 | 1 | 6 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.svelte | Svelte | 52 | 0 | 7 | 59 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.svelte | Svelte | 50 | 0 | 8 | 58 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.svelte | Svelte | 113 | 4 | 14 | 131 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\layout.css | PostCSS | 20 | 1 | 4 | 25 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.svelte | Svelte | 156 | 0 | 8 | 164 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\project\[id]\+page.svelte | Svelte | 186 | 4 | 23 | 213 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.svelte | Svelte | 349 | 7 | 26 | 382 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.svelte | Svelte | 399 | 9 | 44 | 452 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.svelte | Svelte | 330 | 9 | 32 | 371 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.svelte | Svelte | 148 | 3 | 17 | 168 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.svelte | Svelte | 134 | 0 | 10 | 144 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.svelte | Svelte | 258 | 5 | 23 | 286 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\submit\+page.svelte | Svelte | 151 | 0 | 19 | 170 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.svelte | Svelte | 277 | 5 | 18 | 300 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.ts | TypeScript | 5 | 0 | 1 | 6 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\static\site.webmanifest | JSON | 1 | 0 | 0 | 1 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\svelte.config.js | JavaScript | 16 | 1 | 2 | 19 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\tsconfig.json | JSON with Comments | 14 | 0 | 1 | 15 |
+| c:\Users\Nathan\Documents\GitHub\scraps\frontend\vite.config.ts | TypeScript | 4 | 0 | 3 | 7 |
+| Total | | 40,545 | 597 | 2,288 | 43,430 |
++-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index f175d04..769694e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -57,6 +57,57 @@ class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focu
class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto"
```
+### Confirmation Modals
+Use the `ConfirmModal` component from `$lib/components/ConfirmModal.svelte` or follow this pattern:
+
+**Backdrop**
+```html
+
e.target === e.currentTarget && onCancel()}
+ onkeydown={(e) => e.key === 'Escape' && onCancel()}
+ role="dialog"
+ tabindex="-1"
+>
+```
+
+**Modal Container**
+```html
+class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black"
+```
+
+**Title**
+```html
+
{title}
+```
+
+**Message**
+```html
+
{message}
+```
+
+**Button Row**
+```html
+
+
+
+
+
+
+```
+
+**Confirm Button Variants**
+- Primary: `bg-black text-white`
+- Success: `bg-green-600 text-white`
+- Warning: `bg-yellow-500 text-white`
+- Danger: `bg-red-600 text-white`
+
+**Do NOT use browser `alert()` or `confirm()`. Always use styled modals.**
+
### Key Patterns
- **Border style**: `border-4` for buttons, `border-2` for inputs, `border-4` for cards/containers
- **Rounding**: `rounded-full` for buttons, `rounded-2xl` for cards, `rounded-lg` for inputs
@@ -66,3 +117,4 @@ class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90v
- **Animation**: Always include `transition-all duration-200`
- **Colors**: Black borders, white backgrounds, no colors except for errors (red)
- **Cursor**: Always include `cursor-pointer` on clickable elements (buttons, links, interactive cards)
+- **Disabled cursor**: Always include `disabled:cursor-not-allowed` on buttons that can be disabled
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..e4ac331
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,25 @@
+# Build stage
+FROM oven/bun:1 AS builder
+
+WORKDIR /app
+
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile
+
+COPY . .
+RUN bun build src/index.ts --target bun --outdir ./dist
+
+# Production stage
+FROM oven/bun:1-slim
+
+WORKDIR /app
+
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./
+
+ENV NODE_ENV=production
+
+EXPOSE 3000
+
+CMD ["bun", "run", "dist/index.js"]
diff --git a/backend/dist/index.js b/backend/dist/index.js
index 43668c9..a19e13c 100644
--- a/backend/dist/index.js
+++ b/backend/dist/index.js
@@ -3953,7 +3953,7 @@ var require_main = __commonJS((exports, module) => {
var os = __require("os");
var crypto2 = __require("crypto");
var packageJson = require_package();
- var version2 = packageJson.version;
+ var version = packageJson.version;
var TIPS = [
"\uD83D\uDD10 encrypt with Dotenvx: https://dotenvx.com",
"\uD83D\uDD10 prevent committing .env to code: https://dotenvx.com/precommit",
@@ -3984,8 +3984,8 @@ var require_main = __commonJS((exports, module) => {
function supportsAnsi() {
return process.stdout.isTTY;
}
- function dim(text2) {
- return supportsAnsi() ? `\x1B[2m${text2}\x1B[0m` : text2;
+ function dim(text) {
+ return supportsAnsi() ? `\x1B[2m${text}\x1B[0m` : text;
}
var LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg;
function parse2(src) {
@@ -4037,13 +4037,13 @@ var require_main = __commonJS((exports, module) => {
return DotenvModule.parse(decrypted);
}
function _warn(message) {
- console.error(`[dotenv@${version2}][WARN] ${message}`);
+ console.error(`[dotenv@${version}][WARN] ${message}`);
}
function _debug(message) {
- console.log(`[dotenv@${version2}][DEBUG] ${message}`);
+ console.log(`[dotenv@${version}][DEBUG] ${message}`);
}
function _log(message) {
- console.log(`[dotenv@${version2}] ${message}`);
+ console.log(`[dotenv@${version}] ${message}`);
}
function _dotenvKey(options) {
if (options && options.DOTENV_KEY && options.DOTENV_KEY.length > 0) {
@@ -5391,19 +5391,19 @@ var require_utils = __commonJS((exports, module) => {
ret += " BC";
return ret;
}
- function normalizeQueryConfig(config, values, callback) {
- config = typeof config === "string" ? { text: config } : config;
+ function normalizeQueryConfig(config2, values, callback) {
+ config2 = typeof config2 === "string" ? { text: config2 } : config2;
if (values) {
if (typeof values === "function") {
- config.callback = values;
+ config2.callback = values;
} else {
- config.values = values;
+ config2.values = values;
}
}
if (callback) {
- config.callback = callback;
+ config2.callback = callback;
}
- return config;
+ return config2;
}
var escapeIdentifier = function(str) {
return '"' + str.replace(/"/g, '""') + '"';
@@ -5851,10 +5851,10 @@ var require_pg_connection_string = __commonJS((exports, module) => {
var { emitWarning } = __require("process");
function parse2(str, options = {}) {
if (str.charAt(0) === "/") {
- const config2 = str.split(" ");
- return { host: config2[0], database: config2[1] };
+ const config3 = str.split(" ");
+ return { host: config3[0], database: config3[1] };
}
- const config = {};
+ const config2 = {};
let result;
let dummyHost = false;
if (/ |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(str)) {
@@ -5872,72 +5872,72 @@ var require_pg_connection_string = __commonJS((exports, module) => {
throw err;
}
for (const entry of result.searchParams.entries()) {
- config[entry[0]] = entry[1];
+ config2[entry[0]] = entry[1];
}
- config.user = config.user || decodeURIComponent(result.username);
- config.password = config.password || decodeURIComponent(result.password);
+ config2.user = config2.user || decodeURIComponent(result.username);
+ config2.password = config2.password || decodeURIComponent(result.password);
if (result.protocol == "socket:") {
- config.host = decodeURI(result.pathname);
- config.database = result.searchParams.get("db");
- config.client_encoding = result.searchParams.get("encoding");
- return config;
+ config2.host = decodeURI(result.pathname);
+ config2.database = result.searchParams.get("db");
+ config2.client_encoding = result.searchParams.get("encoding");
+ return config2;
}
const hostname = dummyHost ? "" : result.hostname;
- if (!config.host) {
- config.host = decodeURIComponent(hostname);
+ if (!config2.host) {
+ config2.host = decodeURIComponent(hostname);
} else if (hostname && /^%2f/i.test(hostname)) {
result.pathname = hostname + result.pathname;
}
- if (!config.port) {
- config.port = result.port;
+ if (!config2.port) {
+ config2.port = result.port;
}
const pathname = result.pathname.slice(1) || null;
- config.database = pathname ? decodeURI(pathname) : null;
- if (config.ssl === "true" || config.ssl === "1") {
- config.ssl = true;
+ config2.database = pathname ? decodeURI(pathname) : null;
+ if (config2.ssl === "true" || config2.ssl === "1") {
+ config2.ssl = true;
}
- if (config.ssl === "0") {
- config.ssl = false;
+ if (config2.ssl === "0") {
+ config2.ssl = false;
}
- if (config.sslcert || config.sslkey || config.sslrootcert || config.sslmode) {
- config.ssl = {};
+ if (config2.sslcert || config2.sslkey || config2.sslrootcert || config2.sslmode) {
+ config2.ssl = {};
}
- const fs = config.sslcert || config.sslkey || config.sslrootcert ? __require("fs") : null;
- if (config.sslcert) {
- config.ssl.cert = fs.readFileSync(config.sslcert).toString();
+ const fs = config2.sslcert || config2.sslkey || config2.sslrootcert ? __require("fs") : null;
+ if (config2.sslcert) {
+ config2.ssl.cert = fs.readFileSync(config2.sslcert).toString();
}
- if (config.sslkey) {
- config.ssl.key = fs.readFileSync(config.sslkey).toString();
+ if (config2.sslkey) {
+ config2.ssl.key = fs.readFileSync(config2.sslkey).toString();
}
- if (config.sslrootcert) {
- config.ssl.ca = fs.readFileSync(config.sslrootcert).toString();
+ if (config2.sslrootcert) {
+ config2.ssl.ca = fs.readFileSync(config2.sslrootcert).toString();
}
- if (options.useLibpqCompat && config.uselibpqcompat) {
+ if (options.useLibpqCompat && config2.uselibpqcompat) {
throw new Error("Both useLibpqCompat and uselibpqcompat are set. Please use only one of them.");
}
- if (config.uselibpqcompat === "true" || options.useLibpqCompat) {
- switch (config.sslmode) {
+ if (config2.uselibpqcompat === "true" || options.useLibpqCompat) {
+ switch (config2.sslmode) {
case "disable": {
- config.ssl = false;
+ config2.ssl = false;
break;
}
case "prefer": {
- config.ssl.rejectUnauthorized = false;
+ config2.ssl.rejectUnauthorized = false;
break;
}
case "require": {
- if (config.sslrootcert) {
- config.ssl.checkServerIdentity = function() {};
+ if (config2.sslrootcert) {
+ config2.ssl.checkServerIdentity = function() {};
} else {
- config.ssl.rejectUnauthorized = false;
+ config2.ssl.rejectUnauthorized = false;
}
break;
}
case "verify-ca": {
- if (!config.ssl.ca) {
+ if (!config2.ssl.ca) {
throw new Error("SECURITY WARNING: Using sslmode=verify-ca requires specifying a CA with sslrootcert. If a public CA is used, verify-ca allows connections to a server that somebody else may have registered with the CA, making you vulnerable to Man-in-the-Middle attacks. Either specify a custom CA certificate with sslrootcert parameter or use sslmode=verify-full for proper security.");
}
- config.ssl.checkServerIdentity = function() {};
+ config2.ssl.checkServerIdentity = function() {};
break;
}
case "verify-full": {
@@ -5945,27 +5945,27 @@ var require_pg_connection_string = __commonJS((exports, module) => {
}
}
} else {
- switch (config.sslmode) {
+ switch (config2.sslmode) {
case "disable": {
- config.ssl = false;
+ config2.ssl = false;
break;
}
case "prefer":
case "require":
case "verify-ca":
case "verify-full": {
- if (config.sslmode !== "verify-full") {
- deprecatedSslModeWarning(config.sslmode);
+ if (config2.sslmode !== "verify-full") {
+ deprecatedSslModeWarning(config2.sslmode);
}
break;
}
case "no-verify": {
- config.ssl.rejectUnauthorized = false;
+ config2.ssl.rejectUnauthorized = false;
break;
}
}
}
- return config;
+ return config2;
}
function toConnectionOptions(sslConfig) {
const connectionOptions = Object.entries(sslConfig).reduce((c, [key, value]) => {
@@ -5976,8 +5976,8 @@ var require_pg_connection_string = __commonJS((exports, module) => {
}, {});
return connectionOptions;
}
- function toClientConfig(config) {
- const poolConfig = Object.entries(config).reduce((c, [key, value]) => {
+ function toClientConfig(config2) {
+ const poolConfig = Object.entries(config2).reduce((c, [key, value]) => {
if (key === "ssl") {
const sslConfig = value;
if (typeof sslConfig === "boolean") {
@@ -6030,9 +6030,9 @@ var require_connection_parameters = __commonJS((exports, module) => {
var dns = __require("dns");
var defaults = require_defaults();
var parse2 = require_pg_connection_string().parse;
- var val = function(key, config, envVar) {
- if (config[key]) {
- return config[key];
+ var val = function(key, config2, envVar) {
+ if (config2[key]) {
+ return config2[key];
}
if (envVar === undefined) {
envVar = process.env["PG" + key.toUpperCase()];
@@ -6058,35 +6058,35 @@ var require_connection_parameters = __commonJS((exports, module) => {
var quoteParamValue = function(value) {
return "'" + ("" + value).replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'";
};
- var add = function(params, config, paramName) {
- const value = config[paramName];
+ var add = function(params, config2, paramName) {
+ const value = config2[paramName];
if (value !== undefined && value !== null) {
params.push(paramName + "=" + quoteParamValue(value));
}
};
class ConnectionParameters {
- constructor(config) {
- config = typeof config === "string" ? parse2(config) : config || {};
- if (config.connectionString) {
- config = Object.assign({}, config, parse2(config.connectionString));
+ constructor(config2) {
+ config2 = typeof config2 === "string" ? parse2(config2) : config2 || {};
+ if (config2.connectionString) {
+ config2 = Object.assign({}, config2, parse2(config2.connectionString));
}
- this.user = val("user", config);
- this.database = val("database", config);
+ this.user = val("user", config2);
+ this.database = val("database", config2);
if (this.database === undefined) {
this.database = this.user;
}
- this.port = parseInt(val("port", config), 10);
- this.host = val("host", config);
+ this.port = parseInt(val("port", config2), 10);
+ this.host = val("host", config2);
Object.defineProperty(this, "password", {
configurable: true,
enumerable: false,
writable: true,
- value: val("password", config)
+ value: val("password", config2)
});
- this.binary = val("binary", config);
- this.options = val("options", config);
- this.ssl = typeof config.ssl === "undefined" ? readSSLConfigFromEnvironment() : config.ssl;
+ this.binary = val("binary", config2);
+ this.options = val("options", config2);
+ this.ssl = typeof config2.ssl === "undefined" ? readSSLConfigFromEnvironment() : config2.ssl;
if (typeof this.ssl === "string") {
if (this.ssl === "true") {
this.ssl = true;
@@ -6100,27 +6100,27 @@ var require_connection_parameters = __commonJS((exports, module) => {
enumerable: false
});
}
- this.client_encoding = val("client_encoding", config);
- this.replication = val("replication", config);
+ this.client_encoding = val("client_encoding", config2);
+ this.replication = val("replication", config2);
this.isDomainSocket = !(this.host || "").indexOf("/");
- this.application_name = val("application_name", config, "PGAPPNAME");
- this.fallback_application_name = val("fallback_application_name", config, false);
- this.statement_timeout = val("statement_timeout", config, false);
- this.lock_timeout = val("lock_timeout", config, false);
- this.idle_in_transaction_session_timeout = val("idle_in_transaction_session_timeout", config, false);
- this.query_timeout = val("query_timeout", config, false);
- if (config.connectionTimeoutMillis === undefined) {
+ this.application_name = val("application_name", config2, "PGAPPNAME");
+ this.fallback_application_name = val("fallback_application_name", config2, false);
+ this.statement_timeout = val("statement_timeout", config2, false);
+ this.lock_timeout = val("lock_timeout", config2, false);
+ this.idle_in_transaction_session_timeout = val("idle_in_transaction_session_timeout", config2, false);
+ this.query_timeout = val("query_timeout", config2, false);
+ if (config2.connectionTimeoutMillis === undefined) {
this.connect_timeout = process.env.PGCONNECT_TIMEOUT || 0;
} else {
- this.connect_timeout = Math.floor(config.connectionTimeoutMillis / 1000);
+ this.connect_timeout = Math.floor(config2.connectionTimeoutMillis / 1000);
}
- if (config.keepAlive === false) {
+ if (config2.keepAlive === false) {
this.keepalives = 0;
- } else if (config.keepAlive === true) {
+ } else if (config2.keepAlive === true) {
this.keepalives = 1;
}
- if (typeof config.keepAliveInitialDelayMillis === "number") {
- this.keepalives_idle = Math.floor(config.keepAliveInitialDelayMillis / 1000);
+ if (typeof config2.keepAliveInitialDelayMillis === "number") {
+ this.keepalives_idle = Math.floor(config2.keepAliveInitialDelayMillis / 1000);
}
}
getLibpqConnectionString(cb) {
@@ -6259,21 +6259,21 @@ var require_query = __commonJS((exports, module) => {
var utils = require_utils();
class Query extends EventEmitter {
- constructor(config, values, callback) {
+ constructor(config2, values, callback) {
super();
- config = utils.normalizeQueryConfig(config, values, callback);
- this.text = config.text;
- this.values = config.values;
- this.rows = config.rows;
- this.types = config.types;
- this.name = config.name;
- this.queryMode = config.queryMode;
- this.binary = config.binary;
- this.portal = config.portal || "";
- this.callback = config.callback;
- this._rowMode = config.rowMode;
- if (process.domain && config.callback) {
- this.callback = process.domain.bind(config.callback);
+ config2 = utils.normalizeQueryConfig(config2, values, callback);
+ this.text = config2.text;
+ this.values = config2.values;
+ this.rows = config2.rows;
+ this.types = config2.types;
+ this.name = config2.name;
+ this.queryMode = config2.queryMode;
+ this.binary = config2.binary;
+ this.portal = config2.portal || "";
+ this.callback = config2.callback;
+ this._rowMode = config2.rowMode;
+ if (process.domain && config2.callback) {
+ this.callback = process.domain.bind(config2.callback);
}
this._result = new Result(this._rowMode, this.types);
this._results = this._result;
@@ -6762,15 +6762,15 @@ var require_serializer = __commonJS((exports) => {
}
}
};
- var bind = (config = {}) => {
- const portal = config.portal || "";
- const statement = config.statement || "";
- const binary = config.binary || false;
- const values = config.values || emptyArray;
+ var bind = (config2 = {}) => {
+ const portal = config2.portal || "";
+ const statement = config2.statement || "";
+ const binary = config2.binary || false;
+ const values = config2.values || emptyArray;
const len = values.length;
writer.addCString(portal).addCString(statement);
writer.addInt16(len);
- writeValues(values, config.valueMapper);
+ writeValues(values, config2.valueMapper);
writer.addInt16(len);
writer.add(paramWriter.flush());
writer.addInt16(1);
@@ -6778,12 +6778,12 @@ var require_serializer = __commonJS((exports) => {
return writer.flush(66);
};
var emptyExecute = Buffer.from([69, 0, 0, 0, 9, 0, 0, 0, 0, 0]);
- var execute = (config) => {
- if (!config || !config.portal && !config.rows) {
+ var execute = (config2) => {
+ if (!config2 || !config2.portal && !config2.rows) {
return emptyExecute;
}
- const portal = config.portal || "";
- const rows = config.rows || 0;
+ const portal = config2.portal || "";
+ const rows = config2.rows || 0;
const portalLength = Buffer.byteLength(portal);
const len = 4 + portalLength + 1 + 4;
const buff = Buffer.allocUnsafe(1 + len);
@@ -7310,17 +7310,17 @@ var require_connection = __commonJS((exports, module) => {
var endBuffer = serialize.end();
class Connection extends EventEmitter {
- constructor(config) {
+ constructor(config2) {
super();
- config = config || {};
- this.stream = config.stream || getStream(config.ssl);
+ config2 = config2 || {};
+ this.stream = config2.stream || getStream(config2.ssl);
if (typeof this.stream === "function") {
- this.stream = this.stream(config);
+ this.stream = this.stream(config2);
}
- this._keepAlive = config.keepAlive;
- this._keepAliveInitialDelayMillis = config.keepAliveInitialDelayMillis;
+ this._keepAlive = config2.keepAlive;
+ this._keepAliveInitialDelayMillis = config2.keepAliveInitialDelayMillis;
this.parsedStatements = {};
- this.ssl = config.ssl || false;
+ this.ssl = config2.ssl || false;
this._ending = false;
this._emitMessage = false;
const self = this;
@@ -7401,8 +7401,8 @@ var require_connection = __commonJS((exports, module) => {
requestSsl() {
this.stream.write(serialize.requestSsl());
}
- startup(config) {
- this.stream.write(serialize.startup(config));
+ startup(config2) {
+ this.stream.write(serialize.startup(config2));
}
cancel(processID, secretKey) {
this._send(serialize.cancel(processID, secretKey));
@@ -7428,11 +7428,11 @@ var require_connection = __commonJS((exports, module) => {
parse(query) {
this._send(serialize.parse(query));
}
- bind(config) {
- this._send(serialize.bind(config));
+ bind(config2) {
+ this._send(serialize.bind(config2));
}
- execute(config) {
- this._send(serialize.execute(config));
+ execute(config2) {
+ this._send(serialize.execute(config2));
}
flush() {
if (this.stream.writable) {
@@ -7777,9 +7777,9 @@ var require_client = __commonJS((exports, module) => {
var byoPromiseDeprecationNotice = nodeUtils.deprecate(() => {}, "Passing a custom Promise implementation to the Client/Pool constructor is deprecated and will be removed in a future version.");
class Client extends EventEmitter {
- constructor(config) {
+ constructor(config2) {
super();
- this.connectionParameters = new ConnectionParameters(config);
+ this.connectionParameters = new ConnectionParameters(config2);
this.user = this.connectionParameters.user;
this.database = this.connectionParameters.database;
this.port = this.connectionParameters.port;
@@ -7791,7 +7791,7 @@ var require_client = __commonJS((exports, module) => {
value: this.connectionParameters.password
});
this.replication = this.connectionParameters.replication;
- const c = config || {};
+ const c = config2 || {};
if (c.Promise) {
byoPromiseDeprecationNotice();
}
@@ -8191,23 +8191,23 @@ var require_client = __commonJS((exports, module) => {
}
}
}
- query(config, values, callback) {
+ query(config2, values, callback) {
let query;
let result;
let readTimeout;
let readTimeoutTimer;
let queryCallback;
- if (config === null || config === undefined) {
+ if (config2 === null || config2 === undefined) {
throw new TypeError("Client was passed a null or undefined query");
- } else if (typeof config.submit === "function") {
- readTimeout = config.query_timeout || this.connectionParameters.query_timeout;
- result = query = config;
+ } else if (typeof config2.submit === "function") {
+ readTimeout = config2.query_timeout || this.connectionParameters.query_timeout;
+ result = query = config2;
if (typeof values === "function") {
query.callback = query.callback || values;
}
} else {
- readTimeout = config.query_timeout || this.connectionParameters.query_timeout;
- query = new Query(config, values, callback);
+ readTimeout = config2.query_timeout || this.connectionParameters.query_timeout;
+ query = new Query(config2, values, callback);
if (!query.callback) {
result = new this._Promise((resolve, reject) => {
query.callback = (err, res) => err ? reject(err) : resolve(res);
@@ -8683,16 +8683,16 @@ var require_query2 = __commonJS((exports, module) => {
var EventEmitter = __require("events").EventEmitter;
var util = __require("util");
var utils = require_utils();
- var NativeQuery = module.exports = function(config, values, callback) {
+ var NativeQuery = module.exports = function(config2, values, callback) {
EventEmitter.call(this);
- config = utils.normalizeQueryConfig(config, values, callback);
- this.text = config.text;
- this.values = config.values;
- this.name = config.name;
- this.queryMode = config.queryMode;
- this.callback = config.callback;
+ config2 = utils.normalizeQueryConfig(config2, values, callback);
+ this.text = config2.text;
+ this.values = config2.values;
+ this.name = config2.name;
+ this.queryMode = config2.queryMode;
+ this.callback = config2.callback;
this.state = "new";
- this._arrayMode = config.rowMode === "array";
+ this._arrayMode = config2.rowMode === "array";
this._emitRowEvents = false;
this.on("newListener", function(event) {
if (event === "row")
@@ -8827,11 +8827,11 @@ var require_client2 = __commonJS((exports, module) => {
var util = __require("util");
var ConnectionParameters = require_connection_parameters();
var NativeQuery = require_query2();
- var Client = module.exports = function(config) {
+ var Client = module.exports = function(config2) {
EventEmitter.call(this);
- config = config || {};
- this._Promise = config.Promise || global.Promise;
- this._types = new TypeOverrides(config.types);
+ config2 = config2 || {};
+ this._Promise = config2.Promise || global.Promise;
+ this._types = new TypeOverrides(config2.types);
this.native = new Native({
types: this._types
});
@@ -8840,9 +8840,9 @@ var require_client2 = __commonJS((exports, module) => {
this._connecting = false;
this._connected = false;
this._queryable = true;
- const cp = this.connectionParameters = new ConnectionParameters(config);
- if (config.nativeConnectionString)
- cp.nativeConnectionString = config.nativeConnectionString;
+ const cp = this.connectionParameters = new ConnectionParameters(config2);
+ if (config2.nativeConnectionString)
+ cp.nativeConnectionString = config2.nativeConnectionString;
this.user = cp.user;
Object.defineProperty(this, "password", {
configurable: true,
@@ -8921,23 +8921,23 @@ var require_client2 = __commonJS((exports, module) => {
});
});
};
- Client.prototype.query = function(config, values, callback) {
+ Client.prototype.query = function(config2, values, callback) {
let query;
let result;
let readTimeout;
let readTimeoutTimer;
let queryCallback;
- if (config === null || config === undefined) {
+ if (config2 === null || config2 === undefined) {
throw new TypeError("Client was passed a null or undefined query");
- } else if (typeof config.submit === "function") {
- readTimeout = config.query_timeout || this.connectionParameters.query_timeout;
- result = query = config;
+ } else if (typeof config2.submit === "function") {
+ readTimeout = config2.query_timeout || this.connectionParameters.query_timeout;
+ result = query = config2;
if (typeof values === "function") {
- config.callback = values;
+ config2.callback = values;
}
} else {
- readTimeout = config.query_timeout || this.connectionParameters.query_timeout;
- query = new NativeQuery(config, values, callback);
+ readTimeout = config2.query_timeout || this.connectionParameters.query_timeout;
+ query = new NativeQuery(config2, values, callback);
if (!query.callback) {
let resolveOut, rejectOut;
result = new this._Promise((resolve, reject) => {
@@ -23048,6 +23048,23 @@ var cors = (config) => {
});
};
+// src/config.ts
+var import_config = __toESM(require_config(), 1);
+var isDev = true;
+var config = {
+ isDev,
+ port: 3000,
+ databaseUrl: process.env.DATABASE_URL,
+ frontendUrl: isDev ? "http://localhost:5173" : process.env.FRONTEND_URL,
+ hcauth: {
+ clientId: process.env.HCAUTH_CLIENT_ID,
+ clientSecret: process.env.HCAUTH_CLIENT_SECRET,
+ redirectUri: isDev ? "http://localhost:3000/auth/callback" : process.env.HCAUTH_REDIRECT_URI
+ },
+ slackBotToken: process.env.SLACK_BOT_TOKEN,
+ hccdnKey: process.env.HCCDN_KEY
+};
+
// node_modules/drizzle-orm/entity.js
var entityKind = Symbol.for("drizzle:entityKind");
var hasOwnEntityKind = Symbol.for("drizzle:hasOwnEntityKind");
@@ -23075,24 +23092,24 @@ function is(value, type) {
// node_modules/drizzle-orm/column.js
class Column {
- constructor(table, config) {
+ constructor(table, config2) {
this.table = table;
- this.config = config;
- this.name = config.name;
- this.keyAsName = config.keyAsName;
- this.notNull = config.notNull;
- this.default = config.default;
- this.defaultFn = config.defaultFn;
- this.onUpdateFn = config.onUpdateFn;
- this.hasDefault = config.hasDefault;
- this.primary = config.primaryKey;
- this.isUnique = config.isUnique;
- this.uniqueName = config.uniqueName;
- this.uniqueType = config.uniqueType;
- this.dataType = config.dataType;
- this.columnType = config.columnType;
- this.generated = config.generated;
- this.generatedIdentity = config.generatedIdentity;
+ this.config = config2;
+ this.name = config2.name;
+ this.keyAsName = config2.keyAsName;
+ this.notNull = config2.notNull;
+ this.default = config2.default;
+ this.defaultFn = config2.defaultFn;
+ this.onUpdateFn = config2.onUpdateFn;
+ this.hasDefault = config2.hasDefault;
+ this.primary = config2.primaryKey;
+ this.isUnique = config2.isUnique;
+ this.uniqueName = config2.uniqueName;
+ this.uniqueType = config2.uniqueType;
+ this.dataType = config2.dataType;
+ this.columnType = config2.columnType;
+ this.generated = config2.generated;
+ this.generatedIdentity = config2.generatedIdentity;
}
static [entityKind] = "Column";
name;
@@ -23188,9 +23205,9 @@ class ForeignKeyBuilder {
reference;
_onUpdate = "no action";
_onDelete = "no action";
- constructor(config, actions) {
+ constructor(config2, actions) {
this.reference = () => {
- const { name, columns, foreignColumns } = config();
+ const { name, columns, foreignColumns } = config2();
return { name, columns, foreignTable: foreignColumns[0].table, foreignColumns };
};
if (actions) {
@@ -23380,10 +23397,10 @@ class PgColumnBuilder extends ColumnBuilder {
this.foreignKeyConfigs.push({ ref, actions });
return this;
}
- unique(name, config) {
+ unique(name, config2) {
this.config.isUnique = true;
this.config.uniqueName = name;
- this.config.uniqueType = config?.nulls;
+ this.config.uniqueType = config2?.nulls;
return this;
}
generatedAlwaysAs(as) {
@@ -23417,11 +23434,11 @@ class PgColumnBuilder extends ColumnBuilder {
}
class PgColumn extends Column {
- constructor(table, config) {
- if (!config.uniqueName) {
- config.uniqueName = uniqueKeyName(table, [config.name]);
+ constructor(table, config2) {
+ if (!config2.uniqueName) {
+ config2.uniqueName = uniqueKeyName(table, [config2.name]);
}
- super(table, config);
+ super(table, config2);
this.table = table;
}
static [entityKind] = "PgColumn";
@@ -23477,11 +23494,11 @@ class PgArrayBuilder extends PgColumnBuilder {
}
class PgArray extends PgColumn {
- constructor(table, config, baseColumn, range) {
- super(table, config);
+ constructor(table, config2, baseColumn, range) {
+ super(table, config2);
this.baseColumn = baseColumn;
this.range = range;
- this.size = config.size;
+ this.size = config2.size;
}
size;
static [entityKind] = "PgArray";
@@ -23507,9 +23524,9 @@ class PgEnumObjectColumn extends PgColumn {
static [entityKind] = "PgEnumObjectColumn";
enum;
enumValues = this.config.enum.enumValues;
- constructor(table, config) {
- super(table, config);
- this.enum = config.enum;
+ constructor(table, config2) {
+ super(table, config2);
+ this.enum = config2.enum;
}
getSQLType() {
return this.enum.enumName;
@@ -23523,9 +23540,9 @@ class PgEnumColumn extends PgColumn {
static [entityKind] = "PgEnumColumn";
enum = this.config.enum;
enumValues = this.config.enum.enumValues;
- constructor(table, config) {
- super(table, config);
- this.enum = config.enum;
+ constructor(table, config2) {
+ super(table, config2);
+ this.enum = config2.enum;
}
getSQLType() {
return this.enum.enumName;
@@ -23676,9 +23693,9 @@ class SQL {
this.queryChunks.push(...query.queryChunks);
return this;
}
- toQuery(config) {
+ toQuery(config2) {
return tracer.startActiveSpan("drizzle.buildSQL", (span) => {
- const query = this.buildQueryFromSourceParams(this.queryChunks, config);
+ const query = this.buildQueryFromSourceParams(this.queryChunks, config2);
span?.setAttributes({
"drizzle.query.text": query.sql,
"drizzle.query.params": JSON.stringify(query.params)
@@ -23687,7 +23704,7 @@ class SQL {
});
}
buildQueryFromSourceParams(chunks, _config) {
- const config = Object.assign({}, _config, {
+ const config2 = Object.assign({}, _config, {
inlineParams: _config.inlineParams || this.shouldInlineParams,
paramStartIndex: _config.paramStartIndex || { value: 0 }
});
@@ -23698,7 +23715,7 @@ class SQL {
prepareTyping,
inlineParams,
paramStartIndex
- } = config;
+ } = config2;
return mergeQueries(chunks.map((chunk) => {
if (is(chunk, StringChunk)) {
return { sql: chunk.value.join(""), params: [] };
@@ -23718,11 +23735,11 @@ class SQL {
}
}
result.push(new StringChunk(")"));
- return this.buildQueryFromSourceParams(result, config);
+ return this.buildQueryFromSourceParams(result, config2);
}
if (is(chunk, SQL)) {
return this.buildQueryFromSourceParams(chunk.queryChunks, {
- ...config,
+ ...config2,
inlineParams: inlineParams || chunk.shouldInlineParams
});
}
@@ -23759,10 +23776,10 @@ class SQL {
}
const mappedValue = chunk.value === null ? null : chunk.encoder.mapToDriverValue(chunk.value);
if (is(mappedValue, SQL)) {
- return this.buildQueryFromSourceParams([mappedValue], config);
+ return this.buildQueryFromSourceParams([mappedValue], config2);
}
if (inlineParams) {
- return { sql: this.mapInlineParam(mappedValue, config), params: [] };
+ return { sql: this.mapInlineParam(mappedValue, config2), params: [] };
}
let typings = ["none"];
if (prepareTyping) {
@@ -23785,7 +23802,7 @@ class SQL {
chunk._.sql,
new StringChunk(") "),
new Name(chunk._.alias)
- ], config);
+ ], config2);
}
if (isPgEnum(chunk)) {
if (chunk.schema) {
@@ -23795,16 +23812,16 @@ class SQL {
}
if (isSQLWrapper(chunk)) {
if (chunk.shouldOmitSQLParens?.()) {
- return this.buildQueryFromSourceParams([chunk.getSQL()], config);
+ return this.buildQueryFromSourceParams([chunk.getSQL()], config2);
}
return this.buildQueryFromSourceParams([
new StringChunk("("),
chunk.getSQL(),
new StringChunk(")")
- ], config);
+ ], config2);
}
if (inlineParams) {
- return { sql: this.mapInlineParam(chunk, config), params: [] };
+ return { sql: this.mapInlineParam(chunk, config2), params: [] };
}
return { sql: escapeParam(paramStartIndex.value++, chunk), params: [chunk], typings: ["none"] };
}));
@@ -24128,8 +24145,8 @@ class ConsoleLogWriter {
class DefaultLogger {
static [entityKind] = "DefaultLogger";
writer;
- constructor(config) {
- this.writer = config?.writer ?? new ConsoleLogWriter;
+ constructor(config2) {
+ this.writer = config2?.writer ?? new ConsoleLogWriter;
}
logQuery(query, params) {
const stringifiedParams = params.map((p) => {
@@ -24407,8 +24424,8 @@ class PgBigInt64 extends PgColumn {
}
}
function bigint(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (config.mode === "number") {
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (config2.mode === "number") {
return new PgBigInt53Builder(name);
}
return new PgBigInt64Builder(name);
@@ -24461,8 +24478,8 @@ class PgBigSerial64 extends PgColumn {
}
}
function bigserial(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (config.mode === "number") {
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (config2.mode === "number") {
return new PgBigSerial53Builder(name);
}
return new PgBigSerial64Builder(name);
@@ -24492,10 +24509,10 @@ function boolean(name) {
// node_modules/drizzle-orm/pg-core/columns/char.js
class PgCharBuilder extends PgColumnBuilder {
static [entityKind] = "PgCharBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "string", "PgChar");
- this.config.length = config.length;
- this.config.enumValues = config.enum;
+ this.config.length = config2.length;
+ this.config.enumValues = config2.enum;
}
build(table) {
return new PgChar(table, this.config);
@@ -24511,8 +24528,8 @@ class PgChar extends PgColumn {
}
}
function char(a, b = {}) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgCharBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgCharBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/cidr.js
@@ -24554,11 +24571,11 @@ class PgCustomColumn extends PgColumn {
sqlName;
mapTo;
mapFrom;
- constructor(table, config) {
- super(table, config);
- this.sqlName = config.customTypeParams.dataType(config.fieldConfig);
- this.mapTo = config.customTypeParams.toDriver;
- this.mapFrom = config.customTypeParams.fromDriver;
+ constructor(table, config2) {
+ super(table, config2);
+ this.sqlName = config2.customTypeParams.dataType(config2.fieldConfig);
+ this.mapTo = config2.customTypeParams.toDriver;
+ this.mapFrom = config2.customTypeParams.fromDriver;
}
getSQLType() {
return this.sqlName;
@@ -24572,8 +24589,8 @@ class PgCustomColumn extends PgColumn {
}
function customType(customTypeParams) {
return (a, b) => {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgCustomColumnBuilder(name, config, customTypeParams);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgCustomColumnBuilder(name, config2, customTypeParams);
};
}
@@ -24633,8 +24650,8 @@ class PgDateString extends PgColumn {
}
}
function date2(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (config?.mode === "date") {
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (config2?.mode === "date") {
return new PgDateBuilder(name);
}
return new PgDateStringBuilder(name);
@@ -24738,8 +24755,8 @@ class PgInterval extends PgColumn {
}
}
function interval(a, b = {}) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgIntervalBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgIntervalBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/json.js
@@ -24755,8 +24772,8 @@ class PgJsonBuilder extends PgColumnBuilder {
class PgJson extends PgColumn {
static [entityKind] = "PgJson";
- constructor(table, config) {
- super(table, config);
+ constructor(table, config2) {
+ super(table, config2);
}
getSQLType() {
return "json";
@@ -24792,8 +24809,8 @@ class PgJsonbBuilder extends PgColumnBuilder {
class PgJsonb extends PgColumn {
static [entityKind] = "PgJsonb";
- constructor(table, config) {
- super(table, config);
+ constructor(table, config2) {
+ super(table, config2);
}
getSQLType() {
return "jsonb";
@@ -24865,8 +24882,8 @@ class PgLineABC extends PgColumn {
}
}
function line(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (!config?.mode || config.mode === "tuple") {
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (!config2?.mode || config2.mode === "tuple") {
return new PgLineBuilder(name);
}
return new PgLineABCBuilder(name);
@@ -24931,10 +24948,10 @@ class PgNumeric extends PgColumn {
static [entityKind] = "PgNumeric";
precision;
scale;
- constructor(table, config) {
- super(table, config);
- this.precision = config.precision;
- this.scale = config.scale;
+ constructor(table, config2) {
+ super(table, config2);
+ this.precision = config2.precision;
+ this.scale = config2.scale;
}
mapFromDriverValue(value) {
if (typeof value === "string")
@@ -24968,10 +24985,10 @@ class PgNumericNumber extends PgColumn {
static [entityKind] = "PgNumericNumber";
precision;
scale;
- constructor(table, config) {
- super(table, config);
- this.precision = config.precision;
- this.scale = config.scale;
+ constructor(table, config2) {
+ super(table, config2);
+ this.precision = config2.precision;
+ this.scale = config2.scale;
}
mapFromDriverValue(value) {
if (typeof value === "number")
@@ -25006,10 +25023,10 @@ class PgNumericBigInt extends PgColumn {
static [entityKind] = "PgNumericBigInt";
precision;
scale;
- constructor(table, config) {
- super(table, config);
- this.precision = config.precision;
- this.scale = config.scale;
+ constructor(table, config2) {
+ super(table, config2);
+ this.precision = config2.precision;
+ this.scale = config2.scale;
}
mapFromDriverValue = BigInt;
mapToDriverValue = String;
@@ -25024,9 +25041,9 @@ class PgNumericBigInt extends PgColumn {
}
}
function numeric(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- const mode = config?.mode;
- return mode === "number" ? new PgNumericNumberBuilder(name, config?.precision, config?.scale) : mode === "bigint" ? new PgNumericBigIntBuilder(name, config?.precision, config?.scale) : new PgNumericBuilder(name, config?.precision, config?.scale);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ const mode = config2?.mode;
+ return mode === "number" ? new PgNumericNumberBuilder(name, config2?.precision, config2?.scale) : mode === "bigint" ? new PgNumericBigIntBuilder(name, config2?.precision, config2?.scale) : new PgNumericBuilder(name, config2?.precision, config2?.scale);
}
// node_modules/drizzle-orm/pg-core/columns/point.js
@@ -25084,8 +25101,8 @@ class PgPointObject extends PgColumn {
}
}
function point(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (!config?.mode || config.mode === "tuple") {
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (!config2?.mode || config2.mode === "tuple") {
return new PgPointTupleBuilder(name);
}
return new PgPointObjectBuilder(name);
@@ -25178,8 +25195,8 @@ class PgGeometryObject extends PgColumn {
}
}
function geometry(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (!config?.mode || config.mode === "tuple") {
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (!config2?.mode || config2.mode === "tuple") {
return new PgGeometryBuilder(name);
}
return new PgGeometryObjectBuilder(name);
@@ -25199,8 +25216,8 @@ class PgRealBuilder extends PgColumnBuilder {
class PgReal extends PgColumn {
static [entityKind] = "PgReal";
- constructor(table, config) {
- super(table, config);
+ constructor(table, config2) {
+ super(table, config2);
}
getSQLType() {
return "real";
@@ -25292,9 +25309,9 @@ function smallserial(name) {
// node_modules/drizzle-orm/pg-core/columns/text.js
class PgTextBuilder extends PgColumnBuilder {
static [entityKind] = "PgTextBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "string", "PgText");
- this.config.enumValues = config.enum;
+ this.config.enumValues = config2.enum;
}
build(table) {
return new PgText(table, this.config);
@@ -25309,8 +25326,8 @@ class PgText extends PgColumn {
}
}
function text(a, b = {}) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgTextBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgTextBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/time.js
@@ -25332,10 +25349,10 @@ class PgTime extends PgColumn {
static [entityKind] = "PgTime";
withTimezone;
precision;
- constructor(table, config) {
- super(table, config);
- this.withTimezone = config.withTimezone;
- this.precision = config.precision;
+ constructor(table, config2) {
+ super(table, config2);
+ this.withTimezone = config2.withTimezone;
+ this.precision = config2.precision;
}
getSQLType() {
const precision = this.precision === undefined ? "" : `(${this.precision})`;
@@ -25343,8 +25360,8 @@ class PgTime extends PgColumn {
}
}
function time(a, b = {}) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgTimeBuilder(name, config.withTimezone ?? false, config.precision);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgTimeBuilder(name, config2.withTimezone ?? false, config2.precision);
}
// node_modules/drizzle-orm/pg-core/columns/timestamp.js
@@ -25364,10 +25381,10 @@ class PgTimestamp extends PgColumn {
static [entityKind] = "PgTimestamp";
withTimezone;
precision;
- constructor(table, config) {
- super(table, config);
- this.withTimezone = config.withTimezone;
- this.precision = config.precision;
+ constructor(table, config2) {
+ super(table, config2);
+ this.withTimezone = config2.withTimezone;
+ this.precision = config2.precision;
}
getSQLType() {
const precision = this.precision === undefined ? "" : ` (${this.precision})`;
@@ -25399,10 +25416,10 @@ class PgTimestampString extends PgColumn {
static [entityKind] = "PgTimestampString";
withTimezone;
precision;
- constructor(table, config) {
- super(table, config);
- this.withTimezone = config.withTimezone;
- this.precision = config.precision;
+ constructor(table, config2) {
+ super(table, config2);
+ this.withTimezone = config2.withTimezone;
+ this.precision = config2.precision;
}
getSQLType() {
const precision = this.precision === undefined ? "" : `(${this.precision})`;
@@ -25421,11 +25438,11 @@ class PgTimestampString extends PgColumn {
}
}
function timestamp(a, b = {}) {
- const { name, config } = getColumnNameAndConfig(a, b);
- if (config?.mode === "string") {
- return new PgTimestampStringBuilder(name, config.withTimezone ?? false, config.precision);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ if (config2?.mode === "string") {
+ return new PgTimestampStringBuilder(name, config2.withTimezone ?? false, config2.precision);
}
- return new PgTimestampBuilder(name, config?.withTimezone ?? false, config?.precision);
+ return new PgTimestampBuilder(name, config2?.withTimezone ?? false, config2?.precision);
}
// node_modules/drizzle-orm/pg-core/columns/uuid.js
@@ -25455,10 +25472,10 @@ function uuid(name) {
// node_modules/drizzle-orm/pg-core/columns/varchar.js
class PgVarcharBuilder extends PgColumnBuilder {
static [entityKind] = "PgVarcharBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "string", "PgVarchar");
- this.config.length = config.length;
- this.config.enumValues = config.enum;
+ this.config.length = config2.length;
+ this.config.enumValues = config2.enum;
}
build(table) {
return new PgVarchar(table, this.config);
@@ -25474,16 +25491,16 @@ class PgVarchar extends PgColumn {
}
}
function varchar(a, b = {}) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgVarcharBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgVarcharBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/vector_extension/bit.js
class PgBinaryVectorBuilder extends PgColumnBuilder {
static [entityKind] = "PgBinaryVectorBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "string", "PgBinaryVector");
- this.config.dimensions = config.dimensions;
+ this.config.dimensions = config2.dimensions;
}
build(table) {
return new PgBinaryVector(table, this.config);
@@ -25498,16 +25515,16 @@ class PgBinaryVector extends PgColumn {
}
}
function bit(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgBinaryVectorBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgBinaryVectorBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/vector_extension/halfvec.js
class PgHalfVectorBuilder extends PgColumnBuilder {
static [entityKind] = "PgHalfVectorBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "array", "PgHalfVector");
- this.config.dimensions = config.dimensions;
+ this.config.dimensions = config2.dimensions;
}
build(table) {
return new PgHalfVector(table, this.config);
@@ -25528,16 +25545,16 @@ class PgHalfVector extends PgColumn {
}
}
function halfvec(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgHalfVectorBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgHalfVectorBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/vector_extension/sparsevec.js
class PgSparseVectorBuilder extends PgColumnBuilder {
static [entityKind] = "PgSparseVectorBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "string", "PgSparseVector");
- this.config.dimensions = config.dimensions;
+ this.config.dimensions = config2.dimensions;
}
build(table) {
return new PgSparseVector(table, this.config);
@@ -25552,16 +25569,16 @@ class PgSparseVector extends PgColumn {
}
}
function sparsevec(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgSparseVectorBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgSparseVectorBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/vector_extension/vector.js
class PgVectorBuilder extends PgColumnBuilder {
static [entityKind] = "PgVectorBuilder";
- constructor(name, config) {
+ constructor(name, config2) {
super(name, "array", "PgVector");
- this.config.dimensions = config.dimensions;
+ this.config.dimensions = config2.dimensions;
}
build(table) {
return new PgVector(table, this.config);
@@ -25582,8 +25599,8 @@ class PgVector extends PgColumn {
}
}
function vector(a, b) {
- const { name, config } = getColumnNameAndConfig(a, b);
- return new PgVectorBuilder(name, config);
+ const { name, config: config2 } = getColumnNameAndConfig(a, b);
+ return new PgVectorBuilder(name, config2);
}
// node_modules/drizzle-orm/pg-core/columns/all.js
@@ -25827,17 +25844,17 @@ class Relation {
}
class Relations {
- constructor(table, config) {
+ constructor(table, config2) {
this.table = table;
- this.config = config;
+ this.config = config2;
}
static [entityKind] = "Relations";
}
class One extends Relation {
- constructor(sourceTable, referencedTable, config, isNullable) {
- super(sourceTable, referencedTable, config?.relationName);
- this.config = config;
+ constructor(sourceTable, referencedTable, config2, isNullable) {
+ super(sourceTable, referencedTable, config2?.relationName);
+ this.config = config2;
this.isNullable = isNullable;
}
static [entityKind] = "One";
@@ -25849,9 +25866,9 @@ class One extends Relation {
}
class Many extends Relation {
- constructor(sourceTable, referencedTable, config) {
- super(sourceTable, referencedTable, config?.relationName);
- this.config = config;
+ constructor(sourceTable, referencedTable, config2) {
+ super(sourceTable, referencedTable, config2?.relationName);
+ this.config = config2;
}
static [entityKind] = "Many";
withFieldName(fieldName) {
@@ -25953,13 +25970,13 @@ function extractTablesRelationalConfig(schema, configHelpers) {
return { tables: tablesConfig, tableNamesMap };
}
function createOne(sourceTable) {
- return function one(table, config) {
- return new One(sourceTable, table, config, config?.fields.reduce((res, f) => res && f.notNull, true) ?? false);
+ return function one(table, config2) {
+ return new One(sourceTable, table, config2, config2?.fields.reduce((res, f) => res && f.notNull, true) ?? false);
};
}
function createMany(sourceTable) {
- return function many(referencedTable, config) {
- return new Many(sourceTable, referencedTable, config);
+ return function many(referencedTable, config2) {
+ return new Many(sourceTable, referencedTable, config2);
};
}
function normalizeRelation(schema, tableNamesMap, relation) {
@@ -26033,9 +26050,6 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
return result;
}
-// src/db/index.ts
-var import_config = __toESM(require_config(), 1);
-
// node_modules/pg/esm/index.mjs
var import_lib = __toESM(require_lib2(), 1);
var Client = import_lib.default.Client;
@@ -26055,8 +26069,8 @@ var esm_default = import_lib.default;
class SelectionProxyHandler {
static [entityKind] = "SelectionProxyHandler";
config;
- constructor(config) {
- this.config = { ...config };
+ constructor(config2) {
+ this.config = { ...config2 };
}
get(subquery, prop) {
if (prop === "_") {
@@ -26165,12 +26179,12 @@ class PgViewBase extends View {
class PgDialect {
static [entityKind] = "PgDialect";
casing;
- constructor(config) {
- this.casing = new CasingCache(config?.casing);
+ constructor(config2) {
+ this.casing = new CasingCache(config2?.casing);
}
- async migrate(migrations, session, config) {
- const migrationsTable = typeof config === "string" ? "__drizzle_migrations" : config.migrationsTable ?? "__drizzle_migrations";
- const migrationsSchema = typeof config === "string" ? "drizzle" : config.migrationsSchema ?? "drizzle";
+ async migrate(migrations, session, config2) {
+ const migrationsTable = typeof config2 === "string" ? "__drizzle_migrations" : config2.migrationsTable ?? "__drizzle_migrations";
+ const migrationsSchema = typeof config2 === "string" ? "drizzle" : config2.migrationsSchema ?? "drizzle";
const migrationTableCreate = sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsSchema)}.${sql.identifier(migrationsTable)} (
id SERIAL PRIMARY KEY,
@@ -26528,7 +26542,7 @@ class PgDialect {
tableNamesMap,
table,
tableConfig,
- queryConfig: config,
+ queryConfig: config2,
tableAlias,
nestedQueryRelation,
joinOn
@@ -26536,7 +26550,7 @@ class PgDialect {
let selection = [];
let limit, offset, orderBy = [], where;
const joins = [];
- if (config === true) {
+ if (config2 === true) {
const selectionEntries = Object.entries(tableConfig.columns);
selection = selectionEntries.map(([key, value]) => ({
dbKey: value.name,
@@ -26548,15 +26562,15 @@ class PgDialect {
}));
} else {
const aliasedColumns = Object.fromEntries(Object.entries(tableConfig.columns).map(([key, value]) => [key, aliasedTableColumn(value, tableAlias)]));
- if (config.where) {
- const whereSql = typeof config.where === "function" ? config.where(aliasedColumns, getOperators()) : config.where;
+ if (config2.where) {
+ const whereSql = typeof config2.where === "function" ? config2.where(aliasedColumns, getOperators()) : config2.where;
where = whereSql && mapColumnsInSQLToAlias(whereSql, tableAlias);
}
const fieldsSelection = [];
let selectedColumns = [];
- if (config.columns) {
+ if (config2.columns) {
let isIncludeMode = false;
- for (const [field, value] of Object.entries(config.columns)) {
+ for (const [field, value] of Object.entries(config2.columns)) {
if (value === undefined) {
continue;
}
@@ -26568,7 +26582,7 @@ class PgDialect {
}
}
if (selectedColumns.length > 0) {
- selectedColumns = isIncludeMode ? selectedColumns.filter((c) => config.columns?.[c] === true) : Object.keys(tableConfig.columns).filter((key) => !selectedColumns.includes(key));
+ selectedColumns = isIncludeMode ? selectedColumns.filter((c) => config2.columns?.[c] === true) : Object.keys(tableConfig.columns).filter((key) => !selectedColumns.includes(key));
}
} else {
selectedColumns = Object.keys(tableConfig.columns);
@@ -26578,12 +26592,12 @@ class PgDialect {
fieldsSelection.push({ tsKey: field, value: column });
}
let selectedRelations = [];
- if (config.with) {
- selectedRelations = Object.entries(config.with).filter((entry) => !!entry[1]).map(([tsKey, queryConfig]) => ({ tsKey, queryConfig, relation: tableConfig.relations[tsKey] }));
+ if (config2.with) {
+ selectedRelations = Object.entries(config2.with).filter((entry) => !!entry[1]).map(([tsKey, queryConfig]) => ({ tsKey, queryConfig, relation: tableConfig.relations[tsKey] }));
}
let extras;
- if (config.extras) {
- extras = typeof config.extras === "function" ? config.extras(aliasedColumns, { sql }) : config.extras;
+ if (config2.extras) {
+ extras = typeof config2.extras === "function" ? config2.extras(aliasedColumns, { sql }) : config2.extras;
for (const [tsKey, value] of Object.entries(extras)) {
fieldsSelection.push({
tsKey,
@@ -26601,7 +26615,7 @@ class PgDialect {
selection: []
});
}
- let orderByOrig = typeof config.orderBy === "function" ? config.orderBy(aliasedColumns, getOrderByOperators()) : config.orderBy ?? [];
+ let orderByOrig = typeof config2.orderBy === "function" ? config2.orderBy(aliasedColumns, getOrderByOperators()) : config2.orderBy ?? [];
if (!Array.isArray(orderByOrig)) {
orderByOrig = [orderByOrig];
}
@@ -26611,8 +26625,8 @@ class PgDialect {
}
return mapColumnsInSQLToAlias(orderByValue, tableAlias);
});
- limit = config.limit;
- offset = config.offset;
+ limit = config2.limit;
+ offset = config2.offset;
for (const {
tsKey: selectedRelationTsKey,
queryConfig: selectedRelationConfigValue,
@@ -26746,14 +26760,14 @@ class PgSelectBuilder {
dialect;
withList = [];
distinct;
- constructor(config) {
- this.fields = config.fields;
- this.session = config.session;
- this.dialect = config.dialect;
- if (config.withList) {
- this.withList = config.withList;
+ constructor(config2) {
+ this.fields = config2.fields;
+ this.session = config2.session;
+ this.dialect = config2.dialect;
+ if (config2.withList) {
+ this.withList = config2.withList;
}
- this.distinct = config.distinct;
+ this.distinct = config2.distinct;
}
authToken;
setToken(token) {
@@ -26961,8 +26975,8 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder {
}
return this;
}
- for(strength, config = {}) {
- this.config.lockingClause = { strength, config };
+ for(strength, config2 = {}) {
+ this.config.lockingClause = { strength, config: config2 };
return this;
}
getSQL() {
@@ -26987,8 +27001,8 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder {
$dynamic() {
return this;
}
- $withCache(config) {
- this.cacheConfig = config === undefined ? { config: {}, enable: true, autoInvalidate: true } : config === false ? { enable: false } : { enable: true, autoInvalidate: true, ...config };
+ $withCache(config2) {
+ this.cacheConfig = config2 === undefined ? { config: {}, enable: true, autoInvalidate: true } : config2 === false ? { enable: false } : { enable: true, autoInvalidate: true, ...config2 };
return this;
}
}
@@ -26996,11 +27010,11 @@ class PgSelectQueryBuilderBase extends TypedQueryBuilder {
class PgSelectBase extends PgSelectQueryBuilderBase {
static [entityKind] = "PgSelect";
_prepare(name) {
- const { session, config, dialect, joinsNotNullableMap, authToken, cacheConfig, usedTables } = this;
+ const { session, config: config2, dialect, joinsNotNullableMap, authToken, cacheConfig, usedTables } = this;
if (!session) {
throw new Error("Cannot execute a query on a query builder. Please use a database instance instead.");
}
- const { fields } = config;
+ const { fields } = config2;
return tracer.startActiveSpan("drizzle.prepareQuery", () => {
const fieldsList = orderSelectedFields(fields);
const query = session.prepareQuery(dialect.sqlToQuery(this.getSQL()), fieldsList, name, true, undefined, {
@@ -27267,27 +27281,27 @@ class PgInsertBase extends QueryPromise {
this.config.returning = orderSelectedFields(fields);
return this;
}
- onConflictDoNothing(config = {}) {
- if (config.target === undefined) {
+ onConflictDoNothing(config2 = {}) {
+ if (config2.target === undefined) {
this.config.onConflict = sql`do nothing`;
} else {
let targetColumn = "";
- targetColumn = Array.isArray(config.target) ? config.target.map((it) => this.dialect.escapeName(this.dialect.casing.getColumnCasing(it))).join(",") : this.dialect.escapeName(this.dialect.casing.getColumnCasing(config.target));
- const whereSql = config.where ? sql` where ${config.where}` : undefined;
+ targetColumn = Array.isArray(config2.target) ? config2.target.map((it) => this.dialect.escapeName(this.dialect.casing.getColumnCasing(it))).join(",") : this.dialect.escapeName(this.dialect.casing.getColumnCasing(config2.target));
+ const whereSql = config2.where ? sql` where ${config2.where}` : undefined;
this.config.onConflict = sql`(${sql.raw(targetColumn)})${whereSql} do nothing`;
}
return this;
}
- onConflictDoUpdate(config) {
- if (config.where && (config.targetWhere || config.setWhere)) {
+ onConflictDoUpdate(config2) {
+ if (config2.where && (config2.targetWhere || config2.setWhere)) {
throw new Error('You cannot use both "where" and "targetWhere"/"setWhere" at the same time - "where" is deprecated, use "targetWhere" or "setWhere" instead.');
}
- const whereSql = config.where ? sql` where ${config.where}` : undefined;
- const targetWhereSql = config.targetWhere ? sql` where ${config.targetWhere}` : undefined;
- const setWhereSql = config.setWhere ? sql` where ${config.setWhere}` : undefined;
- const setSql = this.dialect.buildUpdateSet(this.config.table, mapUpdateSet(this.config.table, config.set));
+ const whereSql = config2.where ? sql` where ${config2.where}` : undefined;
+ const targetWhereSql = config2.targetWhere ? sql` where ${config2.targetWhere}` : undefined;
+ const setWhereSql = config2.setWhere ? sql` where ${config2.setWhere}` : undefined;
+ const setSql = this.dialect.buildUpdateSet(this.config.table, mapUpdateSet(this.config.table, config2.set));
let targetColumn = "";
- targetColumn = Array.isArray(config.target) ? config.target.map((it) => this.dialect.escapeName(this.dialect.casing.getColumnCasing(it))).join(",") : this.dialect.escapeName(this.dialect.casing.getColumnCasing(config.target));
+ targetColumn = Array.isArray(config2.target) ? config2.target.map((it) => this.dialect.escapeName(this.dialect.casing.getColumnCasing(it))).join(",") : this.dialect.escapeName(this.dialect.casing.getColumnCasing(config2.target));
this.config.onConflict = sql`(${sql.raw(targetColumn)})${targetWhereSql} do update set ${setSql}${whereSql}${setWhereSql}`;
return this;
}
@@ -27589,16 +27603,16 @@ class RelationalQueryBuilder {
this.session = session;
}
static [entityKind] = "PgRelationalQueryBuilder";
- findMany(config) {
- return new PgRelationalQuery(this.fullSchema, this.schema, this.tableNamesMap, this.table, this.tableConfig, this.dialect, this.session, config ? config : {}, "many");
+ findMany(config2) {
+ return new PgRelationalQuery(this.fullSchema, this.schema, this.tableNamesMap, this.table, this.tableConfig, this.dialect, this.session, config2 ? config2 : {}, "many");
}
- findFirst(config) {
- return new PgRelationalQuery(this.fullSchema, this.schema, this.tableNamesMap, this.table, this.tableConfig, this.dialect, this.session, config ? { ...config, limit: 1 } : { limit: 1 }, "first");
+ findFirst(config2) {
+ return new PgRelationalQuery(this.fullSchema, this.schema, this.tableNamesMap, this.table, this.tableConfig, this.dialect, this.session, config2 ? { ...config2, limit: 1 } : { limit: 1 }, "first");
}
}
class PgRelationalQuery extends QueryPromise {
- constructor(fullSchema, schema, tableNamesMap, table, tableConfig, dialect, session, config, mode) {
+ constructor(fullSchema, schema, tableNamesMap, table, tableConfig, dialect, session, config2, mode) {
super();
this.fullSchema = fullSchema;
this.schema = schema;
@@ -27607,7 +27621,7 @@ class PgRelationalQuery extends QueryPromise {
this.tableConfig = tableConfig;
this.dialect = dialect;
this.session = session;
- this.config = config;
+ this.config = config2;
this.mode = mode;
}
static [entityKind] = "PgRelationalQuery";
@@ -27808,8 +27822,8 @@ class PgDatabase {
const prepared = this.session.prepareQuery(builtQuery, undefined, undefined, false);
return new PgRaw(() => prepared.execute(undefined, this.authToken), sequel, builtQuery, (result) => prepared.mapResult(result, true));
}
- transaction(transaction, config) {
- return this.session.transaction(transaction, config);
+ transaction(transaction, config2) {
+ return this.session.transaction(transaction, config2);
}
}
@@ -27953,21 +27967,21 @@ class PgTransaction extends PgDatabase {
rollback() {
throw new TransactionRollbackError;
}
- getTransactionConfigSQL(config) {
+ getTransactionConfigSQL(config2) {
const chunks = [];
- if (config.isolationLevel) {
- chunks.push(`isolation level ${config.isolationLevel}`);
+ if (config2.isolationLevel) {
+ chunks.push(`isolation level ${config2.isolationLevel}`);
}
- if (config.accessMode) {
- chunks.push(config.accessMode);
+ if (config2.accessMode) {
+ chunks.push(config2.accessMode);
}
- if (typeof config.deferrable === "boolean") {
- chunks.push(config.deferrable ? "deferrable" : "not deferrable");
+ if (typeof config2.deferrable === "boolean") {
+ chunks.push(config2.deferrable ? "deferrable" : "not deferrable");
}
return sql.raw(chunks.join(" "));
}
- setTransaction(config) {
- return this.session.execute(sql`set transaction ${this.getTransactionConfigSQL(config)}`);
+ setTransaction(config2) {
+ return this.session.execute(sql`set transaction ${this.getTransactionConfigSQL(config2)}`);
}
}
@@ -28129,11 +28143,11 @@ class NodePgSession extends PgSession {
prepareQuery(query, fields, name, isResponseInArrayMode, customResultMapper, queryMetadata, cacheConfig) {
return new NodePgPreparedQuery(this.client, query.sql, query.params, this.logger, this.cache, queryMetadata, cacheConfig, fields, name, isResponseInArrayMode, customResultMapper);
}
- async transaction(transaction, config) {
+ async transaction(transaction, config2) {
const isPool = this.client instanceof Pool2 || Object.getPrototypeOf(this.client).constructor.name.includes("Pool");
const session = isPool ? new NodePgSession(await this.client.connect(), this.dialect, this.schema, this.options) : this;
const tx = new NodePgTransaction(this.dialect, session, this.schema);
- await tx.execute(sql`begin${config ? sql` ${tx.getTransactionConfigSQL(config)}` : undefined}`);
+ await tx.execute(sql`begin${config2 ? sql` ${tx.getTransactionConfigSQL(config2)}` : undefined}`);
try {
const result = await transaction(tx);
await tx.execute(sql`commit`);
@@ -28188,30 +28202,30 @@ class NodePgDriver {
class NodePgDatabase extends PgDatabase {
static [entityKind] = "NodePgDatabase";
}
-function construct(client, config = {}) {
- const dialect = new PgDialect({ casing: config.casing });
+function construct(client, config2 = {}) {
+ const dialect = new PgDialect({ casing: config2.casing });
let logger;
- if (config.logger === true) {
+ if (config2.logger === true) {
logger = new DefaultLogger;
- } else if (config.logger !== false) {
- logger = config.logger;
+ } else if (config2.logger !== false) {
+ logger = config2.logger;
}
let schema;
- if (config.schema) {
- const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers);
+ if (config2.schema) {
+ const tablesConfig = extractTablesRelationalConfig(config2.schema, createTableRelationsHelpers);
schema = {
- fullSchema: config.schema,
+ fullSchema: config2.schema,
schema: tablesConfig.tables,
tableNamesMap: tablesConfig.tableNamesMap
};
}
- const driver = new NodePgDriver(client, dialect, { logger, cache: config.cache });
+ const driver = new NodePgDriver(client, dialect, { logger, cache: config2.cache });
const session = driver.createSession(schema);
const db = new NodePgDatabase(dialect, session, schema);
db.$client = client;
- db.$cache = config.cache;
+ db.$cache = config2.cache;
if (db.$cache) {
- db.$cache["invalidate"] = config.cache?.onMutate;
+ db.$cache["invalidate"] = config2.cache?.onMutate;
}
return db;
}
@@ -28234,14 +28248,14 @@ function drizzle(...params) {
return construct(params[0], params[1]);
}
((drizzle2) => {
- function mock(config) {
- return construct({}, config);
+ function mock(config2) {
+ return construct({}, config2);
}
drizzle2.mock = mock;
})(drizzle || (drizzle = {}));
// src/db/index.ts
-var db = drizzle(process.env.DATABASE_URL);
+var db = drizzle(config.databaseUrl);
// src/schemas/users.ts
var usersTable = pgTable("users", {
@@ -28254,12 +28268,19 @@ var usersTable = pgTable("users", {
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
- scraps: integer().notNull().default(0),
role: varchar().notNull().default("member"),
internalNotes: text("internal_notes"),
+ tutorialCompleted: boolean("tutorial_completed").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull()
});
+var userBonusesTable = pgTable("user_bonuses", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer("user_id").notNull().references(() => usersTable.id),
+ type: varchar().notNull(),
+ amount: integer().notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull()
+});
// src/schemas/projects.ts
var projectsTable = pgTable("projects", {
@@ -28269,11 +28290,13 @@ var projectsTable = pgTable("projects", {
description: varchar().notNull(),
image: text(),
githubUrl: varchar("github_url"),
+ playableUrl: varchar("playable_url"),
hackatimeProject: varchar("hackatime_project"),
hours: real().default(0),
hoursOverride: real("hours_override"),
status: varchar().notNull().default("in_progress"),
deleted: integer("deleted").default(0),
+ scrapsAwarded: integer("scraps_awarded").notNull().default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull()
});
@@ -28332,9 +28355,9 @@ function getAvatarUrl(profile) {
// src/lib/auth.ts
var HACKCLUB_AUTH_URL = "https://auth.hackclub.com";
-var CLIENT_ID = process.env.HCAUTH_CLIENT_ID;
-var CLIENT_SECRET = process.env.HCAUTH_CLIENT_SECRET;
-var REDIRECT_URI = process.env.HCAUTH_REDIRECT_URI || "http://localhost:3000/api/auth/callback";
+var CLIENT_ID = config.hcauth.clientId;
+var CLIENT_SECRET = config.hcauth.clientSecret;
+var REDIRECT_URI = config.hcauth.redirectUri;
function getAuthorizationUrl() {
const params = new URLSearchParams({
client_id: CLIENT_ID,
@@ -28391,8 +28414,8 @@ async function createOrUpdateUser(identity, tokens) {
}
let username = null;
let avatarUrl = null;
- if (identity.slack_id && process.env.SLACK_BOT_TOKEN) {
- const slackProfile = await getSlackProfile(identity.slack_id, process.env.SLACK_BOT_TOKEN);
+ if (identity.slack_id && config.slackBotToken) {
+ const slackProfile = await getSlackProfile(identity.slack_id, config.slackBotToken);
if (slackProfile) {
username = slackProfile.display_name || slackProfile.real_name || null;
avatarUrl = getAvatarUrl(slackProfile);
@@ -28537,6 +28560,50 @@ projects.get("/:id", async ({ params, headers }) => {
return { error: "Not found" };
}
const projectOwner = await db.select({ id: usersTable.id, username: usersTable.username, avatar: usersTable.avatar }).from(usersTable).where(eq(usersTable.id, project[0].userId)).limit(1);
+ let activity = [];
+ if (isOwner) {
+ const reviews = await db.select({
+ id: reviewsTable.id,
+ reviewerId: reviewsTable.reviewerId,
+ action: reviewsTable.action,
+ feedbackForAuthor: reviewsTable.feedbackForAuthor,
+ createdAt: reviewsTable.createdAt
+ }).from(reviewsTable).where(eq(reviewsTable.projectId, parseInt(params.id)));
+ const reviewerIds = reviews.map((r) => r.reviewerId);
+ let reviewers = [];
+ if (reviewerIds.length > 0) {
+ reviewers = await db.select({ id: usersTable.id, username: usersTable.username, avatar: usersTable.avatar }).from(usersTable).where(inArray(usersTable.id, reviewerIds));
+ }
+ for (const r of reviews) {
+ activity.push({
+ type: "review",
+ action: r.action,
+ feedbackForAuthor: r.feedbackForAuthor,
+ createdAt: r.createdAt,
+ reviewer: reviewers.find((rv) => rv.id === r.reviewerId) || null
+ });
+ }
+ const submissions = await db.select({
+ id: activityTable.id,
+ action: activityTable.action,
+ createdAt: activityTable.createdAt
+ }).from(activityTable).where(and(eq(activityTable.projectId, parseInt(params.id)), eq(activityTable.action, "project_submitted")));
+ for (const s of submissions) {
+ activity.push({
+ type: "submitted",
+ createdAt: s.createdAt
+ });
+ }
+ activity.push({
+ type: "created",
+ createdAt: project[0].createdAt
+ });
+ activity.sort((a, b) => {
+ const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
+ const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
+ return dateB - dateA;
+ });
+ }
return {
project: {
id: project[0].id,
@@ -28544,6 +28611,7 @@ projects.get("/:id", async ({ params, headers }) => {
description: project[0].description,
image: project[0].image,
githubUrl: project[0].githubUrl,
+ playableUrl: project[0].playableUrl,
hackatimeProject: isOwner ? project[0].hackatimeProject : undefined,
hours: project[0].hoursOverride ?? project[0].hours,
hoursOverride: isOwner ? project[0].hoursOverride : undefined,
@@ -28552,7 +28620,8 @@ projects.get("/:id", async ({ params, headers }) => {
updatedAt: project[0].updatedAt
},
owner: projectOwner[0] || null,
- isOwner
+ isOwner,
+ activity: isOwner ? activity : undefined
};
});
projects.post("/", async ({ body, headers }) => {
@@ -28585,6 +28654,12 @@ projects.put("/:id", async ({ params, body, headers }) => {
const user = await getUserFromSession(headers);
if (!user)
return { error: "Unauthorized" };
+ const existing = await db.select().from(projectsTable).where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id))).limit(1);
+ if (!existing[0])
+ return { error: "Not found" };
+ if (existing[0].status === "waiting_for_review") {
+ return { error: "Cannot edit project while waiting for review" };
+ }
const data = body;
let hours = 0;
const parsed = parseHackatimeProject(data.hackatimeProject || null);
@@ -28596,6 +28671,7 @@ projects.put("/:id", async ({ params, body, headers }) => {
description: data.description,
image: data.image,
githubUrl: data.githubUrl,
+ playableUrl: data.playableUrl,
hackatimeProject: data.hackatimeProject,
hours,
updatedAt: new Date
@@ -28766,8 +28842,107 @@ items.get("/", async () => {
});
var items_default = items;
+// src/schemas/shop.ts
+var shopItemsTable = pgTable("shop_items", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ name: varchar().notNull(),
+ image: varchar().notNull(),
+ description: varchar().notNull(),
+ price: integer().notNull(),
+ category: varchar().notNull(),
+ count: integer().notNull().default(0),
+ baseProbability: integer("base_probability").notNull().default(50),
+ baseUpgradeCost: integer("base_upgrade_cost").notNull().default(10),
+ costMultiplier: integer("cost_multiplier").notNull().default(115),
+ boostAmount: integer("boost_amount").notNull().default(1),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull()
+});
+var shopHeartsTable = pgTable("shop_hearts", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer("user_id").notNull().references(() => usersTable.id),
+ shopItemId: integer("shop_item_id").notNull().references(() => shopItemsTable.id),
+ createdAt: timestamp("created_at").defaultNow().notNull()
+}, (table) => [
+ unique().on(table.userId, table.shopItemId)
+]);
+var shopOrdersTable = pgTable("shop_orders", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer("user_id").notNull().references(() => usersTable.id),
+ shopItemId: integer("shop_item_id").notNull().references(() => shopItemsTable.id),
+ quantity: integer().notNull().default(1),
+ pricePerItem: integer("price_per_item").notNull(),
+ totalPrice: integer("total_price").notNull(),
+ status: varchar().notNull().default("pending"),
+ orderType: varchar("order_type").notNull().default("purchase"),
+ shippingAddress: text("shipping_address"),
+ notes: text(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull()
+});
+var shopRollsTable = pgTable("shop_rolls", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer("user_id").notNull().references(() => usersTable.id),
+ shopItemId: integer("shop_item_id").notNull().references(() => shopItemsTable.id),
+ rolled: integer().notNull(),
+ threshold: integer().notNull(),
+ won: boolean().notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull()
+});
+var refineryOrdersTable = pgTable("refinery_orders", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer("user_id").notNull().references(() => usersTable.id),
+ shopItemId: integer("shop_item_id").notNull().references(() => shopItemsTable.id),
+ cost: integer().notNull(),
+ boostAmount: integer("boost_amount").notNull().default(1),
+ createdAt: timestamp("created_at").defaultNow().notNull()
+});
+var shopPenaltiesTable = pgTable("shop_penalties", {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer("user_id").notNull().references(() => usersTable.id),
+ shopItemId: integer("shop_item_id").notNull().references(() => shopItemsTable.id),
+ probabilityMultiplier: integer("probability_multiplier").notNull().default(100),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull()
+}, (table) => [
+ unique().on(table.userId, table.shopItemId)
+]);
+
+// src/lib/scraps.ts
+var PHI = (1 + Math.sqrt(5)) / 2;
+var MULTIPLIER = 120;
+function calculateScrapsFromHours(hours) {
+ return Math.floor(hours * PHI * MULTIPLIER);
+}
+async function getUserScrapsBalance(userId) {
+ const earnedResult = await db.select({
+ total: sql`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`
+ }).from(projectsTable).where(eq(projectsTable.userId, userId));
+ const bonusResult = await db.select({
+ total: sql`COALESCE(SUM(${userBonusesTable.amount}), 0)`
+ }).from(userBonusesTable).where(eq(userBonusesTable.userId, userId));
+ const spentResult = await db.select({
+ total: sql`COALESCE(SUM(${shopOrdersTable.totalPrice}), 0)`
+ }).from(shopOrdersTable).where(eq(shopOrdersTable.userId, userId));
+ const upgradeSpentResult = await db.select({
+ total: sql`COALESCE(SUM(${refineryOrdersTable.cost}), 0)`
+ }).from(refineryOrdersTable).where(eq(refineryOrdersTable.userId, userId));
+ const projectEarned = Number(earnedResult[0]?.total) || 0;
+ const bonusEarned = Number(bonusResult[0]?.total) || 0;
+ const earned = projectEarned + bonusEarned;
+ const shopSpent = Number(spentResult[0]?.total) || 0;
+ const upgradeSpent = Number(upgradeSpentResult[0]?.total) || 0;
+ const spent = shopSpent + upgradeSpent;
+ const balance = earned - spent;
+ return { earned, spent, balance };
+}
+async function canAfford(userId, cost) {
+ const { balance } = await getUserScrapsBalance(userId);
+ return balance >= cost;
+}
+
// src/routes/auth.ts
-var FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173";
+var FRONTEND_URL = config.frontendUrl;
var authRoutes = new Elysia({ prefix: "/auth" });
authRoutes.get("/login", ({ redirect: redirect2 }) => {
console.log("[AUTH] Login initiated");
@@ -28809,7 +28984,7 @@ authRoutes.get("/callback", async ({ query, redirect: redirect2, cookie }) => {
cookie.session.set({
value: sessionToken,
httpOnly: true,
- secure: false,
+ secure: !config.isDev,
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/"
@@ -28832,6 +29007,7 @@ authRoutes.get("/me", async ({ headers }) => {
if (user.role === "banned") {
return { user: null, banned: true };
}
+ const scrapsBalance = await getUserScrapsBalance(user.id);
return {
user: {
id: user.id,
@@ -28839,8 +29015,9 @@ authRoutes.get("/me", async ({ headers }) => {
email: user.email,
avatar: user.avatar,
slackId: user.slackId,
- scraps: user.scraps,
- role: user.role
+ scraps: scrapsBalance.balance,
+ role: user.role,
+ tutorialCompleted: user.tutorialCompleted
}
};
});
@@ -28855,27 +29032,6 @@ authRoutes.post("/logout", async ({ cookie }) => {
});
var auth_default = authRoutes;
-// src/schemas/shop.ts
-var shopItemsTable = pgTable("shop_items", {
- id: integer().primaryKey().generatedAlwaysAsIdentity(),
- name: varchar().notNull(),
- image: varchar().notNull(),
- description: varchar().notNull(),
- price: integer().notNull(),
- category: varchar().notNull(),
- count: integer().notNull().default(0),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull()
-});
-var shopHeartsTable = pgTable("shop_hearts", {
- id: integer().primaryKey().generatedAlwaysAsIdentity(),
- userId: integer("user_id").notNull().references(() => usersTable.id),
- shopItemId: integer("shop_item_id").notNull().references(() => shopItemsTable.id),
- createdAt: timestamp("created_at").defaultNow().notNull()
-}, (table) => [
- unique().on(table.userId, table.shopItemId)
-]);
-
// src/routes/user.ts
var user = new Elysia({ prefix: "/user" });
user.get("/me", async ({ headers }) => {
@@ -28891,17 +29047,36 @@ user.get("/me", async ({ headers }) => {
verificationStatus = eligibility.verificationStatus;
}
}
+ const scrapsBalance = await getUserScrapsBalance(userData.id);
return {
id: userData.id,
username: userData.username,
email: userData.email,
avatar: userData.avatar,
slackId: userData.slackId,
- scraps: userData.scraps,
+ scraps: scrapsBalance.balance,
+ scrapsEarned: scrapsBalance.earned,
+ scrapsSpent: scrapsBalance.spent,
yswsEligible,
- verificationStatus
+ verificationStatus,
+ tutorialCompleted: userData.tutorialCompleted
};
});
+user.post("/complete-tutorial", async ({ headers }) => {
+ const userData = await getUserFromSession(headers);
+ if (!userData)
+ return { error: "Unauthorized" };
+ if (userData.tutorialCompleted) {
+ return { success: true, alreadyCompleted: true };
+ }
+ await db.update(usersTable).set({ tutorialCompleted: true, updatedAt: new Date }).where(eq(usersTable.id, userData.id));
+ await db.insert(userBonusesTable).values({
+ userId: userData.id,
+ type: "tutorial_bonus",
+ amount: 10
+ });
+ return { success: true, bonusAwarded: 10 };
+});
user.get("/profile/:id", async ({ params, headers }) => {
const currentUser = await getUserFromSession(headers);
if (!currentUser)
@@ -28910,10 +29085,12 @@ user.get("/profile/:id", async ({ params, headers }) => {
if (!targetUser[0])
return { error: "User not found" };
const allProjects = await db.select().from(projectsTable).where(eq(projectsTable.userId, parseInt(params.id)));
- const visibleProjects = allProjects.filter((p) => p.status === "shipped" || p.status === "in_progress");
- const shippedCount = allProjects.filter((p) => p.status === "shipped").length;
- const inProgressCount = allProjects.filter((p) => p.status === "in_progress").length;
- const totalHours = allProjects.filter((p) => p.status === "shipped").reduce((sum, p) => sum + (p.hoursOverride ?? p.hours), 0);
+ const visibleProjects = allProjects.filter((p) => !p.deleted && (p.status === "shipped" || p.status === "in_progress" || p.status === "waiting_for_review"));
+ const shippedCount = allProjects.filter((p) => !p.deleted && p.status === "shipped").length;
+ const inProgressCount = allProjects.filter((p) => !p.deleted && (p.status === "in_progress" || p.status === "waiting_for_review")).length;
+ const shippedHours = allProjects.filter((p) => !p.deleted && p.status === "shipped").reduce((sum, p) => sum + (p.hoursOverride ?? p.hours ?? 0), 0);
+ const inProgressHours = allProjects.filter((p) => !p.deleted && (p.status === "in_progress" || p.status === "waiting_for_review")).reduce((sum, p) => sum + (p.hoursOverride ?? p.hours ?? 0), 0);
+ const totalHours = shippedHours + inProgressHours;
const userHearts = await db.select({ shopItemId: shopHeartsTable.shopItemId }).from(shopHeartsTable).where(eq(shopHeartsTable.userId, parseInt(params.id)));
const heartedItemIds = userHearts.map((h) => h.shopItemId);
let heartedItems = [];
@@ -28925,12 +29102,20 @@ user.get("/profile/:id", async ({ params, headers }) => {
price: shopItemsTable.price
}).from(shopItemsTable).where(inArray(shopItemsTable.id, heartedItemIds));
}
+ const scrapsBalance = await getUserScrapsBalance(parseInt(params.id));
+ const refinements = await db.select({
+ shopItemId: refineryOrdersTable.shopItemId,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image,
+ baseProbability: shopItemsTable.baseProbability,
+ totalBoost: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).innerJoin(shopItemsTable, eq(refineryOrdersTable.shopItemId, shopItemsTable.id)).where(eq(refineryOrdersTable.userId, parseInt(params.id))).groupBy(refineryOrdersTable.shopItemId, shopItemsTable.name, shopItemsTable.image, shopItemsTable.baseProbability);
return {
user: {
id: targetUser[0].id,
username: targetUser[0].username,
avatar: targetUser[0].avatar,
- scraps: targetUser[0].scraps,
+ scraps: scrapsBalance.balance,
createdAt: targetUser[0].createdAt
},
projects: visibleProjects.map((p) => ({
@@ -28944,6 +29129,14 @@ user.get("/profile/:id", async ({ params, headers }) => {
createdAt: p.createdAt
})),
heartedItems,
+ refinements: refinements.map((r) => ({
+ shopItemId: r.shopItemId,
+ itemName: r.itemName,
+ itemImage: r.itemImage,
+ baseProbability: r.baseProbability,
+ totalBoost: Number(r.totalBoost),
+ effectiveProbability: Math.min(r.baseProbability + Number(r.totalBoost), 100)
+ })),
stats: {
projectCount: shippedCount,
inProgressCount,
@@ -28965,21 +29158,46 @@ shop.get("/items", async ({ headers }) => {
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
+ baseProbability: shopItemsTable.baseProbability,
+ baseUpgradeCost: shopItemsTable.baseUpgradeCost,
+ costMultiplier: shopItemsTable.costMultiplier,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as("heart_count")
}).from(shopItemsTable);
if (user2) {
const userHearts = await db.select({ shopItemId: shopHeartsTable.shopItemId }).from(shopHeartsTable).where(eq(shopHeartsTable.userId, user2.id));
+ const userBoosts = await db.select({
+ shopItemId: refineryOrdersTable.shopItemId,
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).where(eq(refineryOrdersTable.userId, user2.id)).groupBy(refineryOrdersTable.shopItemId);
+ const userPenalties = await db.select({
+ shopItemId: shopPenaltiesTable.shopItemId,
+ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier
+ }).from(shopPenaltiesTable).where(eq(shopPenaltiesTable.userId, user2.id));
const heartedIds = new Set(userHearts.map((h) => h.shopItemId));
- return items2.map((item) => ({
- ...item,
- hearted: heartedIds.has(item.id)
- }));
+ const boostMap = new Map(userBoosts.map((b) => [b.shopItemId, Number(b.boostPercent)]));
+ const penaltyMap = new Map(userPenalties.map((p) => [p.shopItemId, p.probabilityMultiplier]));
+ return items2.map((item) => {
+ const userBoostPercent = boostMap.get(item.id) ?? 0;
+ const penaltyMultiplier = penaltyMap.get(item.id) ?? 100;
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
+ return {
+ ...item,
+ heartCount: Number(item.heartCount) || 0,
+ userBoostPercent,
+ adjustedBaseProbability,
+ effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100),
+ userHearted: heartedIds.has(item.id)
+ };
+ });
}
return items2.map((item) => ({
...item,
- hearted: false
+ heartCount: Number(item.heartCount) || 0,
+ userBoostPercent: 0,
+ effectiveProbability: Math.min(item.baseProbability, 100),
+ userHearted: false
}));
});
shop.get("/items/:id", async ({ params, headers }) => {
@@ -28993,6 +29211,9 @@ shop.get("/items/:id", async ({ params, headers }) => {
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
+ baseProbability: shopItemsTable.baseProbability,
+ baseUpgradeCost: shopItemsTable.baseUpgradeCost,
+ costMultiplier: shopItemsTable.costMultiplier,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as("heart_count")
@@ -29002,11 +29223,27 @@ shop.get("/items/:id", async ({ params, headers }) => {
}
const item = items2[0];
let hearted = false;
+ let userBoostPercent = 0;
+ let penaltyMultiplier = 100;
if (user2) {
const heart = await db.select().from(shopHeartsTable).where(and(eq(shopHeartsTable.userId, user2.id), eq(shopHeartsTable.shopItemId, itemId))).limit(1);
hearted = heart.length > 0;
+ const boost = await db.select({
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
+ userBoostPercent = boost.length > 0 ? Number(boost[0].boostPercent) : 0;
+ const penalty = await db.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
+ penaltyMultiplier = penalty.length > 0 ? penalty[0].probabilityMultiplier : 100;
}
- return { ...item, hearted };
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
+ return {
+ ...item,
+ heartCount: Number(item.heartCount) || 0,
+ userBoostPercent,
+ adjustedBaseProbability,
+ effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100),
+ userHearted: hearted
+ };
});
shop.post("/items/:id/heart", async ({ params, headers }) => {
const user2 = await getUserFromSession(headers);
@@ -29014,26 +29251,353 @@ shop.post("/items/:id/heart", async ({ params, headers }) => {
return { error: "Unauthorized" };
}
const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
const item = await db.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
if (item.length === 0) {
return { error: "Item not found" };
}
- const existingHeart = await db.select().from(shopHeartsTable).where(and(eq(shopHeartsTable.userId, user2.id), eq(shopHeartsTable.shopItemId, itemId))).limit(1);
- if (existingHeart.length > 0) {
- await db.delete(shopHeartsTable).where(and(eq(shopHeartsTable.userId, user2.id), eq(shopHeartsTable.shopItemId, itemId)));
+ const deleted = await db.delete(shopHeartsTable).where(and(eq(shopHeartsTable.userId, user2.id), eq(shopHeartsTable.shopItemId, itemId))).returning({ userId: shopHeartsTable.userId });
+ if (deleted.length > 0) {
return { hearted: false };
- } else {
- await db.insert(shopHeartsTable).values({
- userId: user2.id,
- shopItemId: itemId
- });
- return { hearted: true };
}
+ await db.insert(shopHeartsTable).values({
+ userId: user2.id,
+ shopItemId: itemId
+ }).onConflictDoNothing();
+ return { hearted: true };
});
shop.get("/categories", async () => {
const result = await db.selectDistinct({ category: shopItemsTable.category }).from(shopItemsTable);
return result.map((r) => r.category);
});
+shop.get("/balance", async ({ headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ return await getUserScrapsBalance(user2.id);
+});
+shop.post("/items/:id/purchase", async ({ params, body, headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
+ const { quantity = 1, shippingAddress } = body;
+ if (quantity < 1 || !Number.isInteger(quantity)) {
+ return { error: "Invalid quantity" };
+ }
+ const items2 = await db.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
+ if (items2.length === 0) {
+ return { error: "Item not found" };
+ }
+ const item = items2[0];
+ if (item.count < quantity) {
+ return { error: "Not enough stock available" };
+ }
+ const totalPrice = item.price * quantity;
+ const affordable = await canAfford(user2.id, totalPrice);
+ if (!affordable) {
+ const { balance } = await getUserScrapsBalance(user2.id);
+ return { error: "Insufficient scraps", required: totalPrice, available: balance };
+ }
+ const order = await db.transaction(async (tx) => {
+ const currentItem = await tx.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
+ if (currentItem.length === 0 || currentItem[0].count < quantity) {
+ throw new Error("Not enough stock");
+ }
+ await tx.update(shopItemsTable).set({
+ count: currentItem[0].count - quantity,
+ updatedAt: new Date
+ }).where(eq(shopItemsTable.id, itemId));
+ const newOrder = await tx.insert(shopOrdersTable).values({
+ userId: user2.id,
+ shopItemId: itemId,
+ quantity,
+ pricePerItem: item.price,
+ totalPrice,
+ shippingAddress: shippingAddress || null,
+ status: "pending"
+ }).returning();
+ return newOrder[0];
+ });
+ return {
+ success: true,
+ order: {
+ id: order.id,
+ itemName: item.name,
+ quantity: order.quantity,
+ totalPrice: order.totalPrice,
+ status: order.status
+ }
+ };
+});
+shop.get("/orders", async ({ headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const orders = await db.select({
+ id: shopOrdersTable.id,
+ quantity: shopOrdersTable.quantity,
+ pricePerItem: shopOrdersTable.pricePerItem,
+ totalPrice: shopOrdersTable.totalPrice,
+ status: shopOrdersTable.status,
+ createdAt: shopOrdersTable.createdAt,
+ itemId: shopItemsTable.id,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image
+ }).from(shopOrdersTable).innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id)).where(eq(shopOrdersTable.userId, user2.id)).orderBy(desc(shopOrdersTable.createdAt));
+ return orders;
+});
+shop.post("/items/:id/try-luck", async ({ params, headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
+ const items2 = await db.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
+ if (items2.length === 0) {
+ return { error: "Item not found" };
+ }
+ const item = items2[0];
+ if (item.count < 1) {
+ return { error: "Out of stock" };
+ }
+ const boostResult = await db.select({
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
+ const penaltyResult = await db.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
+ const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0;
+ const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100;
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
+ const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100);
+ const affordable = await canAfford(user2.id, item.price);
+ if (!affordable) {
+ const { balance } = await getUserScrapsBalance(user2.id);
+ return { error: "Insufficient scraps", required: item.price, available: balance };
+ }
+ const rolled = Math.floor(Math.random() * 100) + 1;
+ const won = rolled <= effectiveProbability;
+ await db.insert(shopRollsTable).values({
+ userId: user2.id,
+ shopItemId: itemId,
+ rolled,
+ threshold: effectiveProbability,
+ won
+ });
+ if (won) {
+ const order = await db.transaction(async (tx) => {
+ const currentItem = await tx.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
+ if (currentItem.length === 0 || currentItem[0].count < 1) {
+ throw new Error("Out of stock");
+ }
+ await tx.update(shopItemsTable).set({
+ count: currentItem[0].count - 1,
+ updatedAt: new Date
+ }).where(eq(shopItemsTable.id, itemId));
+ const newOrder = await tx.insert(shopOrdersTable).values({
+ userId: user2.id,
+ shopItemId: itemId,
+ quantity: 1,
+ pricePerItem: item.price,
+ totalPrice: item.price,
+ shippingAddress: null,
+ status: "pending",
+ orderType: "luck_win"
+ }).returning();
+ await tx.delete(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
+ const existingPenalty = await tx.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
+ if (existingPenalty.length > 0) {
+ const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2));
+ await tx.update(shopPenaltiesTable).set({
+ probabilityMultiplier: newMultiplier,
+ updatedAt: new Date
+ }).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId)));
+ } else {
+ await tx.insert(shopPenaltiesTable).values({
+ userId: user2.id,
+ shopItemId: itemId,
+ probabilityMultiplier: 50
+ });
+ }
+ return newOrder[0];
+ });
+ return { success: true, won: true, orderId: order.id, effectiveProbability, rolled, refineryReset: true, probabilityHalved: true };
+ }
+ return { success: true, won: false, effectiveProbability, rolled };
+});
+shop.post("/items/:id/upgrade-probability", async ({ params, headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
+ const items2 = await db.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
+ if (items2.length === 0) {
+ return { error: "Item not found" };
+ }
+ const item = items2[0];
+ const boostResult = await db.select({
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId)));
+ const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0;
+ const penaltyResult = await db.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(and(eq(shopPenaltiesTable.userId, user2.id), eq(shopPenaltiesTable.shopItemId, itemId))).limit(1);
+ const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100;
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100);
+ const maxBoost = 100 - adjustedBaseProbability;
+ if (currentBoost >= maxBoost) {
+ return { error: "Already at maximum probability" };
+ }
+ const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost));
+ const affordable = await canAfford(user2.id, cost);
+ if (!affordable) {
+ const { balance } = await getUserScrapsBalance(user2.id);
+ return { error: "Insufficient scraps", required: cost, available: balance };
+ }
+ const newBoost = currentBoost + 1;
+ await db.insert(refineryOrdersTable).values({
+ userId: user2.id,
+ shopItemId: itemId,
+ cost,
+ boostAmount: 1
+ });
+ const nextCost = newBoost >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost));
+ return {
+ boostPercent: newBoost,
+ nextCost,
+ effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100)
+ };
+});
+shop.get("/items/:id/leaderboard", async ({ params }) => {
+ const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
+ const items2 = await db.select().from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
+ if (items2.length === 0) {
+ return { error: "Item not found" };
+ }
+ const item = items2[0];
+ const leaderboard = await db.select({
+ userId: refineryOrdersTable.userId,
+ username: usersTable.username,
+ avatar: usersTable.avatar,
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).innerJoin(usersTable, eq(refineryOrdersTable.userId, usersTable.id)).where(eq(refineryOrdersTable.shopItemId, itemId)).groupBy(refineryOrdersTable.userId, usersTable.username, usersTable.avatar).orderBy(desc(sql`SUM(${refineryOrdersTable.boostAmount})`)).limit(20);
+ return leaderboard.map((entry) => ({
+ ...entry,
+ boostPercent: Number(entry.boostPercent),
+ effectiveProbability: Math.min(item.baseProbability + Number(entry.boostPercent), 100)
+ }));
+});
+shop.get("/items/:id/buyers", async ({ params }) => {
+ const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
+ const buyers = await db.select({
+ userId: shopOrdersTable.userId,
+ username: usersTable.username,
+ avatar: usersTable.avatar,
+ quantity: shopOrdersTable.quantity,
+ createdAt: shopOrdersTable.createdAt
+ }).from(shopOrdersTable).innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id)).where(eq(shopOrdersTable.shopItemId, itemId)).orderBy(desc(shopOrdersTable.createdAt)).limit(20);
+ return buyers;
+});
+shop.get("/items/:id/hearts", async ({ params }) => {
+ const itemId = parseInt(params.id);
+ if (!Number.isInteger(itemId)) {
+ return { error: "Invalid item id" };
+ }
+ const hearts = await db.select({
+ userId: shopHeartsTable.userId,
+ username: usersTable.username,
+ avatar: usersTable.avatar,
+ createdAt: shopHeartsTable.createdAt
+ }).from(shopHeartsTable).innerJoin(usersTable, eq(shopHeartsTable.userId, usersTable.id)).where(eq(shopHeartsTable.shopItemId, itemId)).orderBy(desc(shopHeartsTable.createdAt)).limit(20);
+ return hearts;
+});
+shop.get("/addresses", async ({ headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const userData = await db.select({ accessToken: usersTable.accessToken }).from(usersTable).where(eq(usersTable.id, user2.id)).limit(1);
+ if (userData.length === 0 || !userData[0].accessToken) {
+ return [];
+ }
+ try {
+ const response = await fetch("https://identity.hackclub.com/api/v1/me", {
+ headers: {
+ Authorization: `Bearer ${userData[0].accessToken}`
+ }
+ });
+ if (!response.ok) {
+ return [];
+ }
+ const data = await response.json();
+ return data.identity?.addresses ?? [];
+ } catch {
+ return [];
+ }
+});
+shop.get("/orders/pending-address", async ({ headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const orders = await db.select({
+ id: shopOrdersTable.id,
+ quantity: shopOrdersTable.quantity,
+ pricePerItem: shopOrdersTable.pricePerItem,
+ totalPrice: shopOrdersTable.totalPrice,
+ status: shopOrdersTable.status,
+ orderType: shopOrdersTable.orderType,
+ createdAt: shopOrdersTable.createdAt,
+ itemId: shopItemsTable.id,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image
+ }).from(shopOrdersTable).innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id)).where(and(eq(shopOrdersTable.userId, user2.id), isNull(shopOrdersTable.shippingAddress))).orderBy(desc(shopOrdersTable.createdAt));
+ return orders;
+});
+shop.post("/orders/:id/address", async ({ params, body, headers }) => {
+ const user2 = await getUserFromSession(headers);
+ if (!user2) {
+ return { error: "Unauthorized" };
+ }
+ const orderId = parseInt(params.id);
+ if (!Number.isInteger(orderId)) {
+ return { error: "Invalid order id" };
+ }
+ const { shippingAddress } = body;
+ if (!shippingAddress) {
+ return { error: "Shipping address is required" };
+ }
+ const orders = await db.select().from(shopOrdersTable).where(eq(shopOrdersTable.id, orderId)).limit(1);
+ if (orders.length === 0) {
+ return { error: "Order not found" };
+ }
+ if (orders[0].userId !== user2.id) {
+ return { error: "Unauthorized" };
+ }
+ await db.update(shopOrdersTable).set({
+ shippingAddress,
+ updatedAt: new Date
+ }).where(eq(shopOrdersTable.id, orderId));
+ return { success: true };
+});
var shop_default = shop;
// src/routes/leaderboard.ts
@@ -29045,17 +29609,19 @@ leaderboard.get("/", async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
- scraps: usersTable.scraps,
+ scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as("scraps_earned"),
+ scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as("scraps_spent"),
hours: sql`COALESCE(SUM(${projectsTable.hours}), 0)`.as("total_hours"),
projectCount: sql`COUNT(${projectsTable.id})`.as("project_count")
- }).from(usersTable).leftJoin(projectsTable, eq(projectsTable.userId, usersTable.id)).groupBy(usersTable.id).orderBy(desc(sql`total_hours`)).limit(10);
+ }).from(usersTable).leftJoin(projectsTable, and(eq(projectsTable.userId, usersTable.id), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)))).groupBy(usersTable.id).orderBy(desc(sql`total_hours`)).limit(10);
return results2.map((user2, index) => ({
rank: index + 1,
id: user2.id,
username: user2.username,
avatar: user2.avatar,
hours: Number(user2.hours),
- scraps: user2.scraps,
+ scraps: Number(user2.scrapsEarned) - Number(user2.scrapsSpent),
+ scrapsEarned: Number(user2.scrapsEarned),
projectCount: Number(user2.projectCount)
}));
}
@@ -29063,17 +29629,19 @@ leaderboard.get("/", async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
- scraps: usersTable.scraps,
+ scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as("scraps_earned"),
+ scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as("scraps_spent"),
hours: sql`COALESCE(SUM(${projectsTable.hours}), 0)`.as("total_hours"),
projectCount: sql`COUNT(${projectsTable.id})`.as("project_count")
- }).from(usersTable).leftJoin(projectsTable, eq(projectsTable.userId, usersTable.id)).groupBy(usersTable.id).orderBy(desc(usersTable.scraps)).limit(10);
+ }).from(usersTable).leftJoin(projectsTable, and(eq(projectsTable.userId, usersTable.id), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)))).groupBy(usersTable.id).orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`)).limit(10);
return results.map((user2, index) => ({
rank: index + 1,
id: user2.id,
username: user2.username,
avatar: user2.avatar,
hours: Number(user2.hours),
- scraps: user2.scraps,
+ scraps: Number(user2.scrapsEarned) - Number(user2.scrapsSpent),
+ scrapsEarned: Number(user2.scrapsEarned),
projectCount: Number(user2.projectCount)
}));
}, {
@@ -29081,6 +29649,70 @@ leaderboard.get("/", async ({ query }) => {
sortBy: t.Optional(t.Union([t.Literal("hours"), t.Literal("scraps")]))
})
});
+leaderboard.get("/probability-leaders", async () => {
+ const items2 = await db.select({
+ id: shopItemsTable.id,
+ name: shopItemsTable.name,
+ image: shopItemsTable.image,
+ baseProbability: shopItemsTable.baseProbability
+ }).from(shopItemsTable);
+ const allBoosts = await db.select({
+ userId: refineryOrdersTable.userId,
+ shopItemId: refineryOrdersTable.shopItemId,
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ }).from(refineryOrdersTable).groupBy(refineryOrdersTable.userId, refineryOrdersTable.shopItemId);
+ const allPenalties = await db.select({
+ userId: shopPenaltiesTable.userId,
+ shopItemId: shopPenaltiesTable.shopItemId,
+ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier
+ }).from(shopPenaltiesTable);
+ const boostMap = new Map;
+ for (const b of allBoosts) {
+ boostMap.set(`${b.userId}-${b.shopItemId}`, Number(b.boostPercent));
+ }
+ const penaltyMap = new Map;
+ for (const p of allPenalties) {
+ penaltyMap.set(`${p.userId}-${p.shopItemId}`, p.probabilityMultiplier);
+ }
+ const userIds = new Set;
+ for (const b of allBoosts)
+ userIds.add(b.userId);
+ for (const p of allPenalties)
+ userIds.add(p.userId);
+ const users = userIds.size > 0 ? await db.select({
+ id: usersTable.id,
+ username: usersTable.username,
+ avatar: usersTable.avatar
+ }).from(usersTable) : [];
+ const userMap = new Map(users.map((u) => [u.id, u]));
+ const result = items2.map((item) => {
+ let topUser = null;
+ let topProbability = item.baseProbability;
+ for (const userId of userIds) {
+ const boost = boostMap.get(`${userId}-${item.id}`) ?? 0;
+ const penaltyMultiplier = penaltyMap.get(`${userId}-${item.id}`) ?? 100;
+ const adjustedBase = Math.floor(item.baseProbability * penaltyMultiplier / 100);
+ const effectiveProbability = Math.min(adjustedBase + boost, 100);
+ if (effectiveProbability > topProbability) {
+ topProbability = effectiveProbability;
+ topUser = userMap.get(userId) ?? null;
+ }
+ }
+ return {
+ itemId: item.id,
+ itemName: item.name,
+ itemImage: item.image,
+ baseProbability: item.baseProbability,
+ topUser: topUser ? {
+ id: topUser.id,
+ username: topUser.username,
+ avatar: topUser.avatar
+ } : null,
+ effectiveProbability: topProbability
+ };
+ });
+ return result;
+});
var leaderboard_default = leaderboard;
// src/routes/hackatime.ts
@@ -29127,7 +29759,7 @@ var hackatime_default = hackatime;
// src/routes/upload.ts
var HCCDN_URL = "https://cdn.hackclub.com/api/v4/upload";
-var HCCDN_KEY = process.env.HCCDN_KEY;
+var HCCDN_KEY = config.hccdnKey;
var upload = new Elysia({ prefix: "/upload" });
upload.post("/image", async ({ body, headers }) => {
const user2 = await getUserFromSession(headers);
@@ -29202,9 +29834,22 @@ admin.get("/users", async ({ headers, query }) => {
const page = parseInt(query.page) || 1;
const limit = Math.min(parseInt(query.limit) || 20, 100);
const offset = (page - 1) * limit;
+ const search = query.search?.trim() || "";
+ const searchCondition = search ? or(sql`${usersTable.username} ILIKE ${"%" + search + "%"}`, sql`${usersTable.email} ILIKE ${"%" + search + "%"}`, sql`${usersTable.slackId} ILIKE ${"%" + search + "%"}`) : undefined;
const [users, countResult] = await Promise.all([
- db.select().from(usersTable).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
- db.select({ count: sql`count(*)` }).from(usersTable)
+ db.select({
+ id: usersTable.id,
+ username: usersTable.username,
+ email: usersTable.email,
+ avatar: usersTable.avatar,
+ slackId: usersTable.slackId,
+ role: usersTable.role,
+ internalNotes: usersTable.internalNotes,
+ createdAt: usersTable.createdAt,
+ scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as("scraps_earned"),
+ scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as("scraps_spent")
+ }).from(usersTable).where(searchCondition).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
+ db.select({ count: sql`count(*)` }).from(usersTable).where(searchCondition)
]);
const total = Number(countResult[0]?.count || 0);
return {
@@ -29214,7 +29859,7 @@ admin.get("/users", async ({ headers, query }) => {
email: user2.role === "admin" ? u.email : undefined,
avatar: u.avatar,
slackId: u.slackId,
- scraps: u.scraps,
+ scraps: Number(u.scrapsEarned) - Number(u.scrapsSpent),
role: u.role,
internalNotes: u.internalNotes,
createdAt: u.createdAt
@@ -29231,10 +29876,11 @@ admin.get("/users/:id", async ({ params, headers }) => {
const user2 = await requireReviewer(headers);
if (!user2)
return { error: "Unauthorized" };
- const targetUser = await db.select().from(usersTable).where(eq(usersTable.id, parseInt(params.id))).limit(1);
+ const targetUserId = parseInt(params.id);
+ const targetUser = await db.select().from(usersTable).where(eq(usersTable.id, targetUserId)).limit(1);
if (!targetUser[0])
return { error: "User not found" };
- const projects2 = await db.select().from(projectsTable).where(eq(projectsTable.userId, parseInt(params.id))).orderBy(desc(projectsTable.updatedAt));
+ const projects2 = await db.select().from(projectsTable).where(eq(projectsTable.userId, targetUserId)).orderBy(desc(projectsTable.updatedAt));
const projectStats = {
total: projects2.length,
shipped: projects2.filter((p) => p.status === "shipped").length,
@@ -29242,7 +29888,8 @@ admin.get("/users/:id", async ({ params, headers }) => {
waitingForReview: projects2.filter((p) => p.status === "waiting_for_review").length,
rejected: projects2.filter((p) => p.status === "permanently_rejected").length
};
- const totalHours = projects2.reduce((sum, p) => sum + (p.hoursOverride ?? p.hours), 0);
+ const totalHours = projects2.reduce((sum, p) => sum + (p.hoursOverride ?? p.hours ?? 0), 0);
+ const scrapsBalance = await getUserScrapsBalance(targetUserId);
return {
user: {
id: targetUser[0].id,
@@ -29250,7 +29897,7 @@ admin.get("/users/:id", async ({ params, headers }) => {
email: user2.role === "admin" ? targetUser[0].email : undefined,
avatar: targetUser[0].avatar,
slackId: targetUser[0].slackId,
- scraps: targetUser[0].scraps,
+ scraps: scrapsBalance.balance,
role: targetUser[0].role,
internalNotes: targetUser[0].internalNotes,
createdAt: targetUser[0].createdAt
@@ -29355,6 +30002,12 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
const project = await db.select().from(projectsTable).where(eq(projectsTable.id, projectId)).limit(1);
if (!project[0])
return { error: "Project not found" };
+ if (project[0].deleted) {
+ return { error: "Cannot review a deleted project" };
+ }
+ if (project[0].status !== "waiting_for_review") {
+ return { error: "Project is not marked for review" };
+ }
await db.insert(reviewsTable).values({
projectId,
reviewerId: user2.id,
@@ -29383,7 +30036,20 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
if (hoursOverride !== undefined) {
updateData.hoursOverride = hoursOverride;
}
+ let scrapsAwarded = 0;
+ if (action === "approved") {
+ const hours = hoursOverride ?? project[0].hours ?? 0;
+ scrapsAwarded = calculateScrapsFromHours(hours);
+ updateData.scrapsAwarded = scrapsAwarded;
+ }
await db.update(projectsTable).set(updateData).where(eq(projectsTable.id, projectId));
+ if (action === "approved" && scrapsAwarded > 0) {
+ await db.insert(activityTable).values({
+ userId: project[0].userId,
+ projectId,
+ action: `earned ${scrapsAwarded} scraps`
+ });
+ }
if (userInternalNotes !== undefined) {
if (userInternalNotes.length <= 2500) {
await db.update(usersTable).set({ internalNotes: userInternalNotes, updatedAt: new Date }).where(eq(usersTable.id, project[0].userId));
@@ -29402,20 +30068,27 @@ admin.post("/shop/items", async ({ headers, body }) => {
const user2 = await requireAdmin(headers);
if (!user2)
return { error: "Unauthorized" };
- const { name, image, description, price, category, count } = body;
+ const { name, image, description, price, category, count, baseProbability, baseUpgradeCost, costMultiplier, boostAmount } = body;
if (!name?.trim() || !image?.trim() || !description?.trim() || !category?.trim()) {
return { error: "All fields are required" };
}
if (typeof price !== "number" || price < 0) {
return { error: "Invalid price" };
}
+ if (baseProbability !== undefined && (typeof baseProbability !== "number" || baseProbability < 0 || baseProbability > 100)) {
+ return { error: "baseProbability must be between 0 and 100" };
+ }
const inserted = await db.insert(shopItemsTable).values({
name: name.trim(),
image: image.trim(),
description: description.trim(),
price,
category: category.trim(),
- count: count || 0
+ count: count || 0,
+ baseProbability: baseProbability ?? 50,
+ baseUpgradeCost: baseUpgradeCost ?? 10,
+ costMultiplier: costMultiplier ?? 115,
+ boostAmount: boostAmount ?? 1
}).returning();
return inserted[0];
});
@@ -29423,7 +30096,10 @@ admin.put("/shop/items/:id", async ({ params, headers, body }) => {
const user2 = await requireAdmin(headers);
if (!user2)
return { error: "Unauthorized" };
- const { name, image, description, price, category, count } = body;
+ const { name, image, description, price, category, count, baseProbability, baseUpgradeCost, costMultiplier, boostAmount } = body;
+ if (baseProbability !== undefined && (typeof baseProbability !== "number" || baseProbability < 0 || baseProbability > 100)) {
+ return { error: "baseProbability must be between 0 and 100" };
+ }
const updateData = { updatedAt: new Date };
if (name !== undefined)
updateData.name = name.trim();
@@ -29437,6 +30113,14 @@ admin.put("/shop/items/:id", async ({ params, headers, body }) => {
updateData.category = category.trim();
if (count !== undefined)
updateData.count = count;
+ if (baseProbability !== undefined)
+ updateData.baseProbability = baseProbability;
+ if (baseUpgradeCost !== undefined)
+ updateData.baseUpgradeCost = baseUpgradeCost;
+ if (costMultiplier !== undefined)
+ updateData.costMultiplier = costMultiplier;
+ if (boostAmount !== undefined)
+ updateData.boostAmount = boostAmount;
const updated = await db.update(shopItemsTable).set(updateData).where(eq(shopItemsTable.id, parseInt(params.id))).returning();
return updated[0] || { error: "Not found" };
});
@@ -29444,7 +30128,9 @@ admin.delete("/shop/items/:id", async ({ params, headers }) => {
const user2 = await requireAdmin(headers);
if (!user2)
return { error: "Unauthorized" };
- await db.delete(shopItemsTable).where(eq(shopItemsTable.id, parseInt(params.id)));
+ const itemId = parseInt(params.id);
+ await db.delete(shopHeartsTable).where(eq(shopHeartsTable.shopItemId, itemId));
+ await db.delete(shopItemsTable).where(eq(shopItemsTable.id, itemId));
return { success: true };
});
admin.get("/news", async ({ headers }) => {
@@ -29491,12 +30177,55 @@ admin.delete("/news/:id", async ({ params, headers }) => {
await db.delete(newsTable).where(eq(newsTable.id, parseInt(params.id)));
return { success: true };
});
+admin.get("/orders", async ({ headers, query }) => {
+ const user2 = await requireAdmin(headers);
+ if (!user2)
+ return { error: "Unauthorized" };
+ const status2 = query.status;
+ let ordersQuery = db.select({
+ id: shopOrdersTable.id,
+ quantity: shopOrdersTable.quantity,
+ pricePerItem: shopOrdersTable.pricePerItem,
+ totalPrice: shopOrdersTable.totalPrice,
+ status: shopOrdersTable.status,
+ shippingAddress: shopOrdersTable.shippingAddress,
+ notes: shopOrdersTable.notes,
+ createdAt: shopOrdersTable.createdAt,
+ itemId: shopItemsTable.id,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image,
+ userId: usersTable.id,
+ username: usersTable.username,
+ userEmail: usersTable.email
+ }).from(shopOrdersTable).innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id)).innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id)).orderBy(desc(shopOrdersTable.createdAt));
+ if (status2) {
+ ordersQuery = ordersQuery.where(eq(shopOrdersTable.status, status2));
+ }
+ return await ordersQuery;
+});
+admin.patch("/orders/:id", async ({ params, body, headers }) => {
+ const user2 = await requireAdmin(headers);
+ if (!user2)
+ return { error: "Unauthorized" };
+ const { status: status2, notes } = body;
+ const validStatuses = ["pending", "processing", "shipped", "delivered", "cancelled"];
+ if (status2 && !validStatuses.includes(status2)) {
+ return { error: "Invalid status" };
+ }
+ const updateData = { updatedAt: new Date };
+ if (status2)
+ updateData.status = status2;
+ if (notes !== undefined)
+ updateData.notes = notes;
+ const updated = await db.update(shopOrdersTable).set(updateData).where(eq(shopOrdersTable.id, parseInt(params.id))).returning();
+ return updated[0] || { error: "Not found" };
+});
var admin_default = admin;
// src/index.ts
-var api = new Elysia({ prefix: "/api" }).use(auth_default).use(projects_default).use(news_default).use(items_default).use(user_default).use(shop_default).use(leaderboard_default).use(hackatime_default).use(upload_default).use(admin_default).get("/", () => "if you dm @notaroomba abt finding this you may get cool stickers");
+var api = new Elysia().use(auth_default).use(projects_default).use(news_default).use(items_default).use(user_default).use(shop_default).use(leaderboard_default).use(hackatime_default).use(upload_default).use(admin_default).get("/", () => "if you dm @notaroomba abt finding this you may get cool stickers");
var app = new Elysia().use(cors({
- origin: [process.env.FRONTEND_URL],
+ origin: [config.frontendUrl],
credentials: true
-})).use(api).listen(3000);
+})).use(api).listen(config.port);
console.log(`\uD83E\uDD8A Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
diff --git a/backend/src/config.ts b/backend/src/config.ts
new file mode 100644
index 0000000..8f64641
--- /dev/null
+++ b/backend/src/config.ts
@@ -0,0 +1,31 @@
+import 'dotenv/config'
+
+const isDev = process.env.NODE_ENV !== 'production'
+
+export const config = {
+ isDev,
+ port: 3000,
+
+ // Database (always from .env)
+ databaseUrl: process.env.DATABASE_URL!,
+
+ // Frontend
+ frontendUrl: isDev
+ ? 'http://localhost:5173'
+ : process.env.FRONTEND_URL!,
+
+ // HackClub Auth
+ hcauth: {
+ clientId: process.env.HCAUTH_CLIENT_ID!,
+ clientSecret: process.env.HCAUTH_CLIENT_SECRET!,
+ redirectUri: isDev
+ ? 'http://localhost:3000/auth/callback'
+ : process.env.HCAUTH_REDIRECT_URI!
+ },
+
+ // Slack
+ slackBotToken: process.env.SLACK_BOT_TOKEN,
+
+ // HCCDN
+ hccdnKey: process.env.HCCDN_KEY
+}
diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts
index 28ea3a3..bed1d46 100644
--- a/backend/src/db/index.ts
+++ b/backend/src/db/index.ts
@@ -1,4 +1,4 @@
-import 'dotenv/config'
import { drizzle } from 'drizzle-orm/node-postgres'
+import { config } from '../config'
-export const db = drizzle(process.env.DATABASE_URL!)
+export const db = drizzle(config.databaseUrl)
diff --git a/backend/src/index.ts b/backend/src/index.ts
index c549266..2c2b71c 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -1,5 +1,6 @@
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
+import { config } from './config'
import projects from './routes/projects'
import news from './routes/news'
import items from './routes/items'
@@ -26,11 +27,11 @@ const api = new Elysia()
const app = new Elysia()
.use(cors({
- origin: [process.env.FRONTEND_URL as string],
+ origin: [config.frontendUrl],
credentials: true
}))
.use(api)
- .listen(3000)
+ .listen(config.port)
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts
index 1b637e7..62497b6 100644
--- a/backend/src/lib/auth.ts
+++ b/backend/src/lib/auth.ts
@@ -3,11 +3,12 @@ import { db } from "../db"
import { usersTable } from "../schemas/users"
import { sessionsTable } from "../schemas/sessions"
import { getSlackProfile, getAvatarUrl } from "./slack"
+import { config } from "../config"
const HACKCLUB_AUTH_URL = "https://auth.hackclub.com"
-const CLIENT_ID = process.env.HCAUTH_CLIENT_ID!
-const CLIENT_SECRET = process.env.HCAUTH_CLIENT_SECRET!
-const REDIRECT_URI = process.env.HCAUTH_REDIRECT_URI || "http://localhost:3000/api/auth/callback"
+const CLIENT_ID = config.hcauth.clientId
+const CLIENT_SECRET = config.hcauth.clientSecret
+const REDIRECT_URI = config.hcauth.redirectUri
interface OIDCTokenResponse {
access_token: string
@@ -95,8 +96,8 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
let username: string | null = null
let avatarUrl: string | null = null
- if (identity.slack_id && process.env.SLACK_BOT_TOKEN) {
- const slackProfile = await getSlackProfile(identity.slack_id, process.env.SLACK_BOT_TOKEN)
+ if (identity.slack_id && config.slackBotToken) {
+ const slackProfile = await getSlackProfile(identity.slack_id, config.slackBotToken)
if (slackProfile) {
username = slackProfile.display_name || slackProfile.real_name || null
avatarUrl = getAvatarUrl(slackProfile)
diff --git a/backend/src/lib/scraps.ts b/backend/src/lib/scraps.ts
new file mode 100644
index 0000000..47075d8
--- /dev/null
+++ b/backend/src/lib/scraps.ts
@@ -0,0 +1,62 @@
+import { eq, sql } from 'drizzle-orm'
+import { db } from '../db'
+import { projectsTable } from '../schemas/projects'
+import { shopOrdersTable, refineryOrdersTable } from '../schemas/shop'
+import { userBonusesTable } from '../schemas/users'
+
+export const PHI = (1 + Math.sqrt(5)) / 2
+export const MULTIPLIER = 120
+
+export function calculateScrapsFromHours(hours: number): number {
+ return Math.floor(hours * PHI * MULTIPLIER)
+}
+
+export async function getUserScrapsBalance(userId: number): Promise<{
+ earned: number
+ spent: number
+ balance: number
+}> {
+ const earnedResult = await db
+ .select({
+ total: sql
`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`
+ })
+ .from(projectsTable)
+ .where(eq(projectsTable.userId, userId))
+
+ const bonusResult = await db
+ .select({
+ total: sql`COALESCE(SUM(${userBonusesTable.amount}), 0)`
+ })
+ .from(userBonusesTable)
+ .where(eq(userBonusesTable.userId, userId))
+
+ const spentResult = await db
+ .select({
+ total: sql`COALESCE(SUM(${shopOrdersTable.totalPrice}), 0)`
+ })
+ .from(shopOrdersTable)
+ .where(eq(shopOrdersTable.userId, userId))
+
+ // Calculate scraps spent on probability upgrades from refinery_orders
+ const upgradeSpentResult = await db
+ .select({
+ total: sql`COALESCE(SUM(${refineryOrdersTable.cost}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .where(eq(refineryOrdersTable.userId, userId))
+
+ const projectEarned = Number(earnedResult[0]?.total) || 0
+ const bonusEarned = Number(bonusResult[0]?.total) || 0
+ const earned = projectEarned + bonusEarned
+ const shopSpent = Number(spentResult[0]?.total) || 0
+ const upgradeSpent = Number(upgradeSpentResult[0]?.total) || 0
+ const spent = shopSpent + upgradeSpent
+ const balance = earned - spent
+
+ return { earned, spent, balance }
+}
+
+export async function canAfford(userId: number, cost: number): Promise {
+ const { balance } = await getUserScrapsBalance(userId)
+ return balance >= cost
+}
diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts
index 4c4a5c7..7380d88 100644
--- a/backend/src/routes/admin.ts
+++ b/backend/src/routes/admin.ts
@@ -4,9 +4,11 @@ import { db } from '../db'
import { usersTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { reviewsTable } from '../schemas/reviews'
-import { shopItemsTable } from '../schemas/shop'
+import { shopItemsTable, shopOrdersTable, shopHeartsTable } from '../schemas/shop'
import { newsTable } from '../schemas/news'
+import { activityTable } from '../schemas/activity'
import { getUserFromSession } from '../lib/auth'
+import { calculateScrapsFromHours, getUserScrapsBalance } from '../lib/scraps'
const admin = new Elysia({ prefix: '/admin' })
@@ -43,7 +45,18 @@ admin.get('/users', async ({ headers, query }) => {
: undefined
const [users, countResult] = await Promise.all([
- db.select().from(usersTable).where(searchCondition).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
+ db.select({
+ id: usersTable.id,
+ username: usersTable.username,
+ email: usersTable.email,
+ avatar: usersTable.avatar,
+ slackId: usersTable.slackId,
+ role: usersTable.role,
+ internalNotes: usersTable.internalNotes,
+ createdAt: usersTable.createdAt,
+ scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
+ scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent')
+ }).from(usersTable).where(searchCondition).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
db.select({ count: sql`count(*)` }).from(usersTable).where(searchCondition)
])
@@ -56,7 +69,7 @@ admin.get('/users', async ({ headers, query }) => {
email: user.role === 'admin' ? u.email : undefined,
avatar: u.avatar,
slackId: u.slackId,
- scraps: u.scraps,
+ scraps: Number(u.scrapsEarned) - Number(u.scrapsSpent),
role: u.role,
internalNotes: u.internalNotes,
createdAt: u.createdAt
@@ -75,10 +88,12 @@ admin.get('/users/:id', async ({ params, headers }) => {
const user = await requireReviewer(headers as Record)
if (!user) return { error: 'Unauthorized' }
+ const targetUserId = parseInt(params.id)
+
const targetUser = await db
.select()
.from(usersTable)
- .where(eq(usersTable.id, parseInt(params.id)))
+ .where(eq(usersTable.id, targetUserId))
.limit(1)
if (!targetUser[0]) return { error: 'User not found' }
@@ -86,7 +101,7 @@ admin.get('/users/:id', async ({ params, headers }) => {
const projects = await db
.select()
.from(projectsTable)
- .where(eq(projectsTable.userId, parseInt(params.id)))
+ .where(eq(projectsTable.userId, targetUserId))
.orderBy(desc(projectsTable.updatedAt))
const projectStats = {
@@ -99,6 +114,8 @@ admin.get('/users/:id', async ({ params, headers }) => {
const totalHours = projects.reduce((sum, p) => sum + (p.hoursOverride ?? p.hours ?? 0), 0)
+ const scrapsBalance = await getUserScrapsBalance(targetUserId)
+
return {
user: {
id: targetUser[0].id,
@@ -106,7 +123,7 @@ admin.get('/users/:id', async ({ params, headers }) => {
email: user.role === 'admin' ? targetUser[0].email : undefined,
avatar: targetUser[0].avatar,
slackId: targetUser[0].slackId,
- scraps: targetUser[0].scraps,
+ scraps: scrapsBalance.balance,
role: targetUser[0].role,
internalNotes: targetUser[0].internalNotes,
createdAt: targetUser[0].createdAt
@@ -319,11 +336,26 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
updateData.hoursOverride = hoursOverride
}
+ let scrapsAwarded = 0
+ if (action === 'approved') {
+ const hours = hoursOverride ?? project[0].hours ?? 0
+ scrapsAwarded = calculateScrapsFromHours(hours)
+ updateData.scrapsAwarded = scrapsAwarded
+ }
+
await db
.update(projectsTable)
.set(updateData)
.where(eq(projectsTable.id, projectId))
+ if (action === 'approved' && scrapsAwarded > 0) {
+ await db.insert(activityTable).values({
+ userId: project[0].userId,
+ projectId,
+ action: `earned ${scrapsAwarded} scraps`
+ })
+ }
+
// Update user internal notes if provided
if (userInternalNotes !== undefined) {
if (userInternalNotes.length <= 2500) {
@@ -354,13 +386,17 @@ admin.post('/shop/items', async ({ headers, body }) => {
const user = await requireAdmin(headers as Record)
if (!user) return { error: 'Unauthorized' }
- const { name, image, description, price, category, count } = body as {
+ const { name, image, description, price, category, count, baseProbability, baseUpgradeCost, costMultiplier, boostAmount } = body as {
name: string
image: string
description: string
price: number
category: string
count: number
+ baseProbability?: number
+ baseUpgradeCost?: number
+ costMultiplier?: number
+ boostAmount?: number
}
if (!name?.trim() || !image?.trim() || !description?.trim() || !category?.trim()) {
@@ -371,6 +407,10 @@ admin.post('/shop/items', async ({ headers, body }) => {
return { error: 'Invalid price' }
}
+ if (baseProbability !== undefined && (typeof baseProbability !== 'number' || baseProbability < 0 || baseProbability > 100)) {
+ return { error: 'baseProbability must be between 0 and 100' }
+ }
+
const inserted = await db
.insert(shopItemsTable)
.values({
@@ -379,7 +419,11 @@ admin.post('/shop/items', async ({ headers, body }) => {
description: description.trim(),
price,
category: category.trim(),
- count: count || 0
+ count: count || 0,
+ baseProbability: baseProbability ?? 50,
+ baseUpgradeCost: baseUpgradeCost ?? 10,
+ costMultiplier: costMultiplier ?? 115,
+ boostAmount: boostAmount ?? 1
})
.returning()
@@ -390,13 +434,21 @@ admin.put('/shop/items/:id', async ({ params, headers, body }) => {
const user = await requireAdmin(headers as Record)
if (!user) return { error: 'Unauthorized' }
- const { name, image, description, price, category, count } = body as {
+ const { name, image, description, price, category, count, baseProbability, baseUpgradeCost, costMultiplier, boostAmount } = body as {
name?: string
image?: string
description?: string
price?: number
category?: string
count?: number
+ baseProbability?: number
+ baseUpgradeCost?: number
+ costMultiplier?: number
+ boostAmount?: number
+ }
+
+ if (baseProbability !== undefined && (typeof baseProbability !== 'number' || baseProbability < 0 || baseProbability > 100)) {
+ return { error: 'baseProbability must be between 0 and 100' }
}
const updateData: Record = { updatedAt: new Date() }
@@ -407,6 +459,10 @@ admin.put('/shop/items/:id', async ({ params, headers, body }) => {
if (price !== undefined) updateData.price = price
if (category !== undefined) updateData.category = category.trim()
if (count !== undefined) updateData.count = count
+ if (baseProbability !== undefined) updateData.baseProbability = baseProbability
+ if (baseUpgradeCost !== undefined) updateData.baseUpgradeCost = baseUpgradeCost
+ if (costMultiplier !== undefined) updateData.costMultiplier = costMultiplier
+ if (boostAmount !== undefined) updateData.boostAmount = boostAmount
const updated = await db
.update(shopItemsTable)
@@ -421,9 +477,15 @@ admin.delete('/shop/items/:id', async ({ params, headers }) => {
const user = await requireAdmin(headers as Record)
if (!user) return { error: 'Unauthorized' }
+ const itemId = parseInt(params.id)
+
+ await db
+ .delete(shopHeartsTable)
+ .where(eq(shopHeartsTable.shopItemId, itemId))
+
await db
.delete(shopItemsTable)
- .where(eq(shopItemsTable.id, parseInt(params.id)))
+ .where(eq(shopItemsTable.id, itemId))
return { success: true }
})
@@ -503,4 +565,63 @@ admin.delete('/news/:id', async ({ params, headers }) => {
return { success: true }
})
+admin.get('/orders', async ({ headers, query }) => {
+ const user = await requireAdmin(headers as Record)
+ if (!user) return { error: 'Unauthorized' }
+
+ const status = query.status as string | undefined
+
+ let ordersQuery = db
+ .select({
+ id: shopOrdersTable.id,
+ quantity: shopOrdersTable.quantity,
+ pricePerItem: shopOrdersTable.pricePerItem,
+ totalPrice: shopOrdersTable.totalPrice,
+ status: shopOrdersTable.status,
+ shippingAddress: shopOrdersTable.shippingAddress,
+ notes: shopOrdersTable.notes,
+ createdAt: shopOrdersTable.createdAt,
+ itemId: shopItemsTable.id,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image,
+ userId: usersTable.id,
+ username: usersTable.username,
+ userEmail: usersTable.email
+ })
+ .from(shopOrdersTable)
+ .innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
+ .innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id))
+ .orderBy(desc(shopOrdersTable.createdAt))
+
+ if (status) {
+ ordersQuery = ordersQuery.where(eq(shopOrdersTable.status, status)) as typeof ordersQuery
+ }
+
+ return await ordersQuery
+})
+
+admin.patch('/orders/:id', async ({ params, body, headers }) => {
+ const user = await requireAdmin(headers as Record)
+ if (!user) return { error: 'Unauthorized' }
+
+ const { status, notes } = body as { status?: string; notes?: string }
+
+ const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']
+ if (status && !validStatuses.includes(status)) {
+ return { error: 'Invalid status' }
+ }
+
+ const updateData: Record = { updatedAt: new Date() }
+ if (status) updateData.status = status
+ if (notes !== undefined) updateData.notes = notes
+
+ const updated = await db
+ .update(shopOrdersTable)
+ .set(updateData)
+ .where(eq(shopOrdersTable.id, parseInt(params.id)))
+ .returning()
+
+ return updated[0] || { error: 'Not found' }
+})
+
export default admin
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
index cf9d9a0..933669a 100644
--- a/backend/src/routes/auth.ts
+++ b/backend/src/routes/auth.ts
@@ -8,8 +8,10 @@ import {
deleteSession,
getUserFromSession
} from "../lib/auth"
+import { config } from "../config"
+import { getUserScrapsBalance } from "../lib/scraps"
-const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"
+const FRONTEND_URL = config.frontendUrl
const authRoutes = new Elysia({ prefix: "/auth" })
@@ -64,7 +66,7 @@ authRoutes.get("/callback", async ({ query, redirect, cookie }) => {
cookie.session.set({
value: sessionToken,
httpOnly: true,
- secure: process.env.NODE_ENV === "production",
+ secure: !config.isDev,
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/"
@@ -89,6 +91,7 @@ authRoutes.get("/me", async ({ headers }) => {
if (user.role === 'banned') {
return { user: null, banned: true }
}
+ const scrapsBalance = await getUserScrapsBalance(user.id)
return {
user: {
id: user.id,
@@ -96,8 +99,9 @@ authRoutes.get("/me", async ({ headers }) => {
email: user.email,
avatar: user.avatar,
slackId: user.slackId,
- scraps: user.scraps,
- role: user.role
+ scraps: scrapsBalance.balance,
+ role: user.role,
+ tutorialCompleted: user.tutorialCompleted
}
}
})
diff --git a/backend/src/routes/leaderboard.ts b/backend/src/routes/leaderboard.ts
index bf7ea86..115b2a4 100644
--- a/backend/src/routes/leaderboard.ts
+++ b/backend/src/routes/leaderboard.ts
@@ -2,6 +2,7 @@ import { Elysia, t } from 'elysia'
import { db } from '../db'
import { usersTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
+import { shopItemsTable, shopOrdersTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop'
import { sql, desc, eq, and, or, isNull } from 'drizzle-orm'
const leaderboard = new Elysia({ prefix: '/leaderboard' })
@@ -15,7 +16,8 @@ leaderboard.get('/', async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
- scraps: usersTable.scraps,
+ scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
+ scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
hours: sql`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql`COUNT(${projectsTable.id})`.as('project_count')
})
@@ -34,7 +36,8 @@ leaderboard.get('/', async ({ query }) => {
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
- scraps: user.scraps,
+ scraps: Number(user.scrapsEarned) - Number(user.scrapsSpent),
+ scrapsEarned: Number(user.scrapsEarned),
projectCount: Number(user.projectCount)
}))
}
@@ -44,7 +47,8 @@ leaderboard.get('/', async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
- scraps: usersTable.scraps,
+ scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
+ scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
hours: sql`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql`COUNT(${projectsTable.id})`.as('project_count')
})
@@ -54,7 +58,7 @@ leaderboard.get('/', async ({ query }) => {
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted))
))
.groupBy(usersTable.id)
- .orderBy(desc(usersTable.scraps))
+ .orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`))
.limit(10)
return results.map((user, index) => ({
@@ -63,7 +67,8 @@ leaderboard.get('/', async ({ query }) => {
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
- scraps: user.scraps,
+ scraps: Number(user.scrapsEarned) - Number(user.scrapsSpent),
+ scrapsEarned: Number(user.scrapsEarned),
projectCount: Number(user.projectCount)
}))
}, {
@@ -72,4 +77,90 @@ leaderboard.get('/', async ({ query }) => {
})
})
+leaderboard.get('/probability-leaders', async () => {
+ const items = await db
+ .select({
+ id: shopItemsTable.id,
+ name: shopItemsTable.name,
+ image: shopItemsTable.image,
+ baseProbability: shopItemsTable.baseProbability
+ })
+ .from(shopItemsTable)
+
+ const allBoosts = await db
+ .select({
+ userId: refineryOrdersTable.userId,
+ shopItemId: refineryOrdersTable.shopItemId,
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .groupBy(refineryOrdersTable.userId, refineryOrdersTable.shopItemId)
+
+ const allPenalties = await db
+ .select({
+ userId: shopPenaltiesTable.userId,
+ shopItemId: shopPenaltiesTable.shopItemId,
+ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier
+ })
+ .from(shopPenaltiesTable)
+
+ const boostMap = new Map()
+ for (const b of allBoosts) {
+ boostMap.set(`${b.userId}-${b.shopItemId}`, Number(b.boostPercent))
+ }
+
+ const penaltyMap = new Map()
+ for (const p of allPenalties) {
+ penaltyMap.set(`${p.userId}-${p.shopItemId}`, p.probabilityMultiplier)
+ }
+
+ const userIds = new Set()
+ for (const b of allBoosts) userIds.add(b.userId)
+ for (const p of allPenalties) userIds.add(p.userId)
+
+ const users = userIds.size > 0
+ ? await db
+ .select({
+ id: usersTable.id,
+ username: usersTable.username,
+ avatar: usersTable.avatar
+ })
+ .from(usersTable)
+ : []
+
+ const userMap = new Map(users.map(u => [u.id, u]))
+
+ const result = items.map(item => {
+ let topUser: { id: number; username: string; avatar: string | null } | null = null
+ let topProbability = item.baseProbability
+
+ for (const userId of userIds) {
+ const boost = boostMap.get(`${userId}-${item.id}`) ?? 0
+ const penaltyMultiplier = penaltyMap.get(`${userId}-${item.id}`) ?? 100
+ const adjustedBase = Math.floor(item.baseProbability * penaltyMultiplier / 100)
+ const effectiveProbability = Math.min(adjustedBase + boost, 100)
+
+ if (effectiveProbability > topProbability) {
+ topProbability = effectiveProbability
+ topUser = userMap.get(userId) ?? null
+ }
+ }
+
+ return {
+ itemId: item.id,
+ itemName: item.name,
+ itemImage: item.image,
+ baseProbability: item.baseProbability,
+ topUser: topUser ? {
+ id: topUser.id,
+ username: topUser.username,
+ avatar: topUser.avatar
+ } : null,
+ effectiveProbability: topProbability
+ }
+ })
+
+ return result
+})
+
export default leaderboard
diff --git a/backend/src/routes/shop.ts b/backend/src/routes/shop.ts
index 62b9d16..cdd4b0d 100644
--- a/backend/src/routes/shop.ts
+++ b/backend/src/routes/shop.ts
@@ -1,8 +1,10 @@
import { Elysia } from 'elysia'
-import { eq, sql, and } from 'drizzle-orm'
+import { eq, sql, and, desc, isNull } from 'drizzle-orm'
import { db } from '../db'
-import { shopItemsTable, shopHeartsTable } from '../schemas/shop'
+import { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop'
+import { usersTable } from '../schemas/users'
import { getUserFromSession } from '../lib/auth'
+import { getUserScrapsBalance, canAfford } from '../lib/scraps'
const shop = new Elysia({ prefix: '/shop' })
@@ -18,6 +20,9 @@ shop.get('/items', async ({ headers }) => {
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
+ baseProbability: shopItemsTable.baseProbability,
+ baseUpgradeCost: shopItemsTable.baseUpgradeCost,
+ costMultiplier: shopItemsTable.costMultiplier,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
@@ -30,17 +35,48 @@ shop.get('/items', async ({ headers }) => {
.from(shopHeartsTable)
.where(eq(shopHeartsTable.userId, user.id))
- const heartedIds = new Set(userHearts.map(h => h.shopItemId))
+ const userBoosts = await db
+ .select({
+ shopItemId: refineryOrdersTable.shopItemId,
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .where(eq(refineryOrdersTable.userId, user.id))
+ .groupBy(refineryOrdersTable.shopItemId)
- return items.map(item => ({
- ...item,
- hearted: heartedIds.has(item.id)
- }))
+ const userPenalties = await db
+ .select({
+ shopItemId: shopPenaltiesTable.shopItemId,
+ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier
+ })
+ .from(shopPenaltiesTable)
+ .where(eq(shopPenaltiesTable.userId, user.id))
+
+ const heartedIds = new Set(userHearts.map(h => h.shopItemId))
+ const boostMap = new Map(userBoosts.map(b => [b.shopItemId, Number(b.boostPercent)]))
+ const penaltyMap = new Map(userPenalties.map(p => [p.shopItemId, p.probabilityMultiplier]))
+
+ return items.map(item => {
+ const userBoostPercent = boostMap.get(item.id) ?? 0
+ const penaltyMultiplier = penaltyMap.get(item.id) ?? 100
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
+ return {
+ ...item,
+ heartCount: Number(item.heartCount) || 0,
+ userBoostPercent,
+ adjustedBaseProbability,
+ effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100),
+ userHearted: heartedIds.has(item.id)
+ }
+ })
}
return items.map(item => ({
...item,
- hearted: false
+ heartCount: Number(item.heartCount) || 0,
+ userBoostPercent: 0,
+ effectiveProbability: Math.min(item.baseProbability, 100),
+ userHearted: false
}))
})
@@ -57,6 +93,9 @@ shop.get('/items/:id', async ({ params, headers }) => {
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
+ baseProbability: shopItemsTable.baseProbability,
+ baseUpgradeCost: shopItemsTable.baseUpgradeCost,
+ costMultiplier: shopItemsTable.costMultiplier,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
@@ -71,6 +110,8 @@ shop.get('/items/:id', async ({ params, headers }) => {
const item = items[0]
let hearted = false
+ let userBoostPercent = 0
+ let penaltyMultiplier = 100
if (user) {
const heart = await db
@@ -83,9 +124,41 @@ shop.get('/items/:id', async ({ params, headers }) => {
.limit(1)
hearted = heart.length > 0
+
+ const boost = await db
+ .select({
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .where(and(
+ eq(refineryOrdersTable.userId, user.id),
+ eq(refineryOrdersTable.shopItemId, itemId)
+ ))
+
+ userBoostPercent = boost.length > 0 ? Number(boost[0].boostPercent) : 0
+
+ const penalty = await db
+ .select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
+ .from(shopPenaltiesTable)
+ .where(and(
+ eq(shopPenaltiesTable.userId, user.id),
+ eq(shopPenaltiesTable.shopItemId, itemId)
+ ))
+ .limit(1)
+
+ penaltyMultiplier = penalty.length > 0 ? penalty[0].probabilityMultiplier : 100
}
- return { ...item, hearted }
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
+
+ return {
+ ...item,
+ heartCount: Number(item.heartCount) || 0,
+ userBoostPercent,
+ adjustedBaseProbability,
+ effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100),
+ userHearted: hearted
+ }
})
shop.post('/items/:id/heart', async ({ params, headers }) => {
@@ -95,6 +168,9 @@ shop.post('/items/:id/heart', async ({ params, headers }) => {
}
const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
const item = await db
.select()
@@ -106,32 +182,27 @@ shop.post('/items/:id/heart', async ({ params, headers }) => {
return { error: 'Item not found' }
}
- const existingHeart = await db
- .select()
- .from(shopHeartsTable)
+ const deleted = await db
+ .delete(shopHeartsTable)
.where(and(
eq(shopHeartsTable.userId, user.id),
eq(shopHeartsTable.shopItemId, itemId)
))
- .limit(1)
+ .returning({ userId: shopHeartsTable.userId })
- if (existingHeart.length > 0) {
- await db
- .delete(shopHeartsTable)
- .where(and(
- eq(shopHeartsTable.userId, user.id),
- eq(shopHeartsTable.shopItemId, itemId)
- ))
+ if (deleted.length > 0) {
return { hearted: false }
- } else {
- await db
- .insert(shopHeartsTable)
- .values({
- userId: user.id,
- shopItemId: itemId
- })
- return { hearted: true }
}
+
+ await db
+ .insert(shopHeartsTable)
+ .values({
+ userId: user.id,
+ shopItemId: itemId
+ })
+ .onConflictDoNothing()
+
+ return { hearted: true }
})
shop.get('/categories', async () => {
@@ -142,4 +213,564 @@ shop.get('/categories', async () => {
return result.map(r => r.category)
})
+shop.get('/balance', async ({ headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ return await getUserScrapsBalance(user.id)
+})
+
+shop.post('/items/:id/purchase', async ({ params, body, headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
+
+ const { quantity = 1, shippingAddress } = body as { quantity?: number; shippingAddress?: string }
+
+ if (quantity < 1 || !Number.isInteger(quantity)) {
+ return { error: 'Invalid quantity' }
+ }
+
+ const items = await db
+ .select()
+ .from(shopItemsTable)
+ .where(eq(shopItemsTable.id, itemId))
+ .limit(1)
+
+ if (items.length === 0) {
+ return { error: 'Item not found' }
+ }
+
+ const item = items[0]
+
+ if (item.count < quantity) {
+ return { error: 'Not enough stock available' }
+ }
+
+ const totalPrice = item.price * quantity
+
+ const affordable = await canAfford(user.id, totalPrice)
+ if (!affordable) {
+ const { balance } = await getUserScrapsBalance(user.id)
+ return { error: 'Insufficient scraps', required: totalPrice, available: balance }
+ }
+
+ const order = await db.transaction(async (tx) => {
+ const currentItem = await tx
+ .select()
+ .from(shopItemsTable)
+ .where(eq(shopItemsTable.id, itemId))
+ .limit(1)
+
+ if (currentItem.length === 0 || currentItem[0].count < quantity) {
+ throw new Error('Not enough stock')
+ }
+
+ await tx
+ .update(shopItemsTable)
+ .set({
+ count: currentItem[0].count - quantity,
+ updatedAt: new Date()
+ })
+ .where(eq(shopItemsTable.id, itemId))
+
+ const newOrder = await tx
+ .insert(shopOrdersTable)
+ .values({
+ userId: user.id,
+ shopItemId: itemId,
+ quantity,
+ pricePerItem: item.price,
+ totalPrice,
+ shippingAddress: shippingAddress || null,
+ status: 'pending'
+ })
+ .returning()
+
+ return newOrder[0]
+ })
+
+ return {
+ success: true,
+ order: {
+ id: order.id,
+ itemName: item.name,
+ quantity: order.quantity,
+ totalPrice: order.totalPrice,
+ status: order.status
+ }
+ }
+})
+
+shop.get('/orders', async ({ headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const orders = await db
+ .select({
+ id: shopOrdersTable.id,
+ quantity: shopOrdersTable.quantity,
+ pricePerItem: shopOrdersTable.pricePerItem,
+ totalPrice: shopOrdersTable.totalPrice,
+ status: shopOrdersTable.status,
+ createdAt: shopOrdersTable.createdAt,
+ itemId: shopItemsTable.id,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image
+ })
+ .from(shopOrdersTable)
+ .innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
+ .where(eq(shopOrdersTable.userId, user.id))
+ .orderBy(desc(shopOrdersTable.createdAt))
+
+ return orders
+})
+
+shop.post('/items/:id/try-luck', async ({ params, headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
+
+ const items = await db
+ .select()
+ .from(shopItemsTable)
+ .where(eq(shopItemsTable.id, itemId))
+ .limit(1)
+
+ if (items.length === 0) {
+ return { error: 'Item not found' }
+ }
+
+ const item = items[0]
+
+ if (item.count < 1) {
+ return { error: 'Out of stock' }
+ }
+
+ const boostResult = await db
+ .select({
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .where(and(
+ eq(refineryOrdersTable.userId, user.id),
+ eq(refineryOrdersTable.shopItemId, itemId)
+ ))
+
+ const penaltyResult = await db
+ .select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
+ .from(shopPenaltiesTable)
+ .where(and(
+ eq(shopPenaltiesTable.userId, user.id),
+ eq(shopPenaltiesTable.shopItemId, itemId)
+ ))
+ .limit(1)
+
+ const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
+ const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
+ const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100)
+
+ const affordable = await canAfford(user.id, item.price)
+ if (!affordable) {
+ const { balance } = await getUserScrapsBalance(user.id)
+ return { error: 'Insufficient scraps', required: item.price, available: balance }
+ }
+
+ const rolled = Math.floor(Math.random() * 100) + 1
+ const won = rolled <= effectiveProbability
+
+ // Record the roll
+ await db.insert(shopRollsTable).values({
+ userId: user.id,
+ shopItemId: itemId,
+ rolled,
+ threshold: effectiveProbability,
+ won
+ })
+
+ if (won) {
+ const order = await db.transaction(async (tx) => {
+ const currentItem = await tx
+ .select()
+ .from(shopItemsTable)
+ .where(eq(shopItemsTable.id, itemId))
+ .limit(1)
+
+ if (currentItem.length === 0 || currentItem[0].count < 1) {
+ throw new Error('Out of stock')
+ }
+
+ await tx
+ .update(shopItemsTable)
+ .set({
+ count: currentItem[0].count - 1,
+ updatedAt: new Date()
+ })
+ .where(eq(shopItemsTable.id, itemId))
+
+ const newOrder = await tx
+ .insert(shopOrdersTable)
+ .values({
+ userId: user.id,
+ shopItemId: itemId,
+ quantity: 1,
+ pricePerItem: item.price,
+ totalPrice: item.price,
+ shippingAddress: null,
+ status: 'pending',
+ orderType: 'luck_win'
+ })
+ .returning()
+
+ await tx
+ .delete(refineryOrdersTable)
+ .where(and(
+ eq(refineryOrdersTable.userId, user.id),
+ eq(refineryOrdersTable.shopItemId, itemId)
+ ))
+
+ const existingPenalty = await tx
+ .select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
+ .from(shopPenaltiesTable)
+ .where(and(
+ eq(shopPenaltiesTable.userId, user.id),
+ eq(shopPenaltiesTable.shopItemId, itemId)
+ ))
+ .limit(1)
+
+ if (existingPenalty.length > 0) {
+ const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2))
+ await tx
+ .update(shopPenaltiesTable)
+ .set({
+ probabilityMultiplier: newMultiplier,
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(shopPenaltiesTable.userId, user.id),
+ eq(shopPenaltiesTable.shopItemId, itemId)
+ ))
+ } else {
+ await tx
+ .insert(shopPenaltiesTable)
+ .values({
+ userId: user.id,
+ shopItemId: itemId,
+ probabilityMultiplier: 50
+ })
+ }
+
+ return newOrder[0]
+ })
+
+ return { success: true, won: true, orderId: order.id, effectiveProbability, rolled, refineryReset: true, probabilityHalved: true }
+ }
+
+ return { success: true, won: false, effectiveProbability, rolled }
+})
+
+shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
+
+ const items = await db
+ .select()
+ .from(shopItemsTable)
+ .where(eq(shopItemsTable.id, itemId))
+ .limit(1)
+
+ if (items.length === 0) {
+ return { error: 'Item not found' }
+ }
+
+ const item = items[0]
+
+ const boostResult = await db
+ .select({
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .where(and(
+ eq(refineryOrdersTable.userId, user.id),
+ eq(refineryOrdersTable.shopItemId, itemId)
+ ))
+
+ const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
+
+ const penaltyResult = await db
+ .select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
+ .from(shopPenaltiesTable)
+ .where(and(
+ eq(shopPenaltiesTable.userId, user.id),
+ eq(shopPenaltiesTable.shopItemId, itemId)
+ ))
+ .limit(1)
+
+ const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
+ const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
+
+ const maxBoost = 100 - adjustedBaseProbability
+ if (currentBoost >= maxBoost) {
+ return { error: 'Already at maximum probability' }
+ }
+
+ const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost))
+
+ const affordable = await canAfford(user.id, cost)
+ if (!affordable) {
+ const { balance } = await getUserScrapsBalance(user.id)
+ return { error: 'Insufficient scraps', required: cost, available: balance }
+ }
+
+ const newBoost = currentBoost + 1
+
+ // Record the refinery order
+ await db.insert(refineryOrdersTable).values({
+ userId: user.id,
+ shopItemId: itemId,
+ cost,
+ boostAmount: 1
+ })
+
+ const nextCost = newBoost >= maxBoost
+ ? null
+ : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost))
+
+ return {
+ boostPercent: newBoost,
+ nextCost,
+ effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100)
+ }
+})
+
+shop.get('/items/:id/leaderboard', async ({ params }) => {
+ const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
+
+ const items = await db
+ .select()
+ .from(shopItemsTable)
+ .where(eq(shopItemsTable.id, itemId))
+ .limit(1)
+
+ if (items.length === 0) {
+ return { error: 'Item not found' }
+ }
+
+ const item = items[0]
+
+ const leaderboard = await db
+ .select({
+ userId: refineryOrdersTable.userId,
+ username: usersTable.username,
+ avatar: usersTable.avatar,
+ boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .innerJoin(usersTable, eq(refineryOrdersTable.userId, usersTable.id))
+ .where(eq(refineryOrdersTable.shopItemId, itemId))
+ .groupBy(refineryOrdersTable.userId, usersTable.username, usersTable.avatar)
+ .orderBy(desc(sql`SUM(${refineryOrdersTable.boostAmount})`))
+ .limit(20)
+
+ return leaderboard.map(entry => ({
+ ...entry,
+ boostPercent: Number(entry.boostPercent),
+ effectiveProbability: Math.min(item.baseProbability + Number(entry.boostPercent), 100)
+ }))
+})
+
+shop.get('/items/:id/buyers', async ({ params }) => {
+ const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
+
+ const buyers = await db
+ .select({
+ userId: shopOrdersTable.userId,
+ username: usersTable.username,
+ avatar: usersTable.avatar,
+ quantity: shopOrdersTable.quantity,
+ createdAt: shopOrdersTable.createdAt
+ })
+ .from(shopOrdersTable)
+ .innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id))
+ .where(eq(shopOrdersTable.shopItemId, itemId))
+ .orderBy(desc(shopOrdersTable.createdAt))
+ .limit(20)
+
+ return buyers
+})
+
+shop.get('/items/:id/hearts', async ({ params }) => {
+ const itemId = parseInt(params.id)
+ if (!Number.isInteger(itemId)) {
+ return { error: 'Invalid item id' }
+ }
+
+ const hearts = await db
+ .select({
+ userId: shopHeartsTable.userId,
+ username: usersTable.username,
+ avatar: usersTable.avatar,
+ createdAt: shopHeartsTable.createdAt
+ })
+ .from(shopHeartsTable)
+ .innerJoin(usersTable, eq(shopHeartsTable.userId, usersTable.id))
+ .where(eq(shopHeartsTable.shopItemId, itemId))
+ .orderBy(desc(shopHeartsTable.createdAt))
+ .limit(20)
+
+ return hearts
+})
+
+shop.get('/addresses', async ({ headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const userData = await db
+ .select({ accessToken: usersTable.accessToken })
+ .from(usersTable)
+ .where(eq(usersTable.id, user.id))
+ .limit(1)
+
+ if (userData.length === 0 || !userData[0].accessToken) {
+ return []
+ }
+
+ try {
+ const response = await fetch('https://identity.hackclub.com/api/v1/me', {
+ headers: {
+ Authorization: `Bearer ${userData[0].accessToken}`
+ }
+ })
+
+ if (!response.ok) {
+ return []
+ }
+
+ const data = await response.json() as {
+ identity?: {
+ addresses?: Array<{
+ id: string
+ first_name: string
+ last_name: string
+ line_1: string
+ line_2: string
+ city: string
+ state: string
+ postal_code: string
+ country: string
+ phone_number: string
+ primary: boolean
+ }>
+ }
+ }
+
+ return data.identity?.addresses ?? []
+ } catch {
+ return []
+ }
+})
+
+shop.get('/orders/pending-address', async ({ headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const orders = await db
+ .select({
+ id: shopOrdersTable.id,
+ quantity: shopOrdersTable.quantity,
+ pricePerItem: shopOrdersTable.pricePerItem,
+ totalPrice: shopOrdersTable.totalPrice,
+ status: shopOrdersTable.status,
+ orderType: shopOrdersTable.orderType,
+ createdAt: shopOrdersTable.createdAt,
+ itemId: shopItemsTable.id,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image
+ })
+ .from(shopOrdersTable)
+ .innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
+ .where(and(
+ eq(shopOrdersTable.userId, user.id),
+ isNull(shopOrdersTable.shippingAddress)
+ ))
+ .orderBy(desc(shopOrdersTable.createdAt))
+
+ return orders
+})
+
+shop.post('/orders/:id/address', async ({ params, body, headers }) => {
+ const user = await getUserFromSession(headers as Record)
+ if (!user) {
+ return { error: 'Unauthorized' }
+ }
+
+ const orderId = parseInt(params.id)
+ if (!Number.isInteger(orderId)) {
+ return { error: 'Invalid order id' }
+ }
+
+ const { shippingAddress } = body as { shippingAddress?: string }
+ if (!shippingAddress) {
+ return { error: 'Shipping address is required' }
+ }
+
+ const orders = await db
+ .select()
+ .from(shopOrdersTable)
+ .where(eq(shopOrdersTable.id, orderId))
+ .limit(1)
+
+ if (orders.length === 0) {
+ return { error: 'Order not found' }
+ }
+
+ if (orders[0].userId !== user.id) {
+ return { error: 'Unauthorized' }
+ }
+
+ await db
+ .update(shopOrdersTable)
+ .set({
+ shippingAddress,
+ updatedAt: new Date()
+ })
+ .where(eq(shopOrdersTable.id, orderId))
+
+ return { success: true }
+})
+
export default shop
diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts
index 2fcd079..afac264 100644
--- a/backend/src/routes/upload.ts
+++ b/backend/src/routes/upload.ts
@@ -1,8 +1,9 @@
import { Elysia } from 'elysia'
import { getUserFromSession } from '../lib/auth'
+import { config } from '../config'
const HCCDN_URL = 'https://cdn.hackclub.com/api/v4/upload'
-const HCCDN_KEY = process.env.HCCDN_KEY
+const HCCDN_KEY = config.hccdnKey
interface CDNUploadResponse {
id: string
diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts
index 043626c..42f073f 100644
--- a/backend/src/routes/user.ts
+++ b/backend/src/routes/user.ts
@@ -1,10 +1,11 @@
import { Elysia } from 'elysia'
-import { eq, inArray } from 'drizzle-orm'
+import { eq, inArray, sql } from 'drizzle-orm'
import { getUserFromSession, checkUserEligibility } from '../lib/auth'
import { db } from '../db'
-import { usersTable } from '../schemas/users'
+import { usersTable, userBonusesTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
-import { shopHeartsTable, shopItemsTable } from '../schemas/shop'
+import { shopHeartsTable, shopItemsTable, refineryOrdersTable } from '../schemas/shop'
+import { getUserScrapsBalance } from '../lib/scraps'
const user = new Elysia({ prefix: '/user' })
@@ -23,18 +24,45 @@ user.get('/me', async ({ headers }) => {
}
}
+ const scrapsBalance = await getUserScrapsBalance(userData.id)
+
return {
id: userData.id,
username: userData.username,
email: userData.email,
avatar: userData.avatar,
slackId: userData.slackId,
- scraps: userData.scraps,
+ scraps: scrapsBalance.balance,
+ scrapsEarned: scrapsBalance.earned,
+ scrapsSpent: scrapsBalance.spent,
yswsEligible,
- verificationStatus
+ verificationStatus,
+ tutorialCompleted: userData.tutorialCompleted
}
})
+user.post('/complete-tutorial', async ({ headers }) => {
+ const userData = await getUserFromSession(headers as Record)
+ if (!userData) return { error: 'Unauthorized' }
+
+ if (userData.tutorialCompleted) {
+ return { success: true, alreadyCompleted: true }
+ }
+
+ await db
+ .update(usersTable)
+ .set({ tutorialCompleted: true, updatedAt: new Date() })
+ .where(eq(usersTable.id, userData.id))
+
+ await db.insert(userBonusesTable).values({
+ userId: userData.id,
+ type: 'tutorial_bonus',
+ amount: 10
+ })
+
+ return { success: true, bonusAwarded: 10 }
+})
+
// Public profile - anyone logged in can view
user.get('/profile/:id', async ({ params, headers }) => {
const currentUser = await getUserFromSession(headers as Record)
@@ -85,12 +113,28 @@ user.get('/profile/:id', async ({ params, headers }) => {
.where(inArray(shopItemsTable.id, heartedItemIds))
}
+ const scrapsBalance = await getUserScrapsBalance(parseInt(params.id))
+
+ // Get user's refinery boosts
+ const refinements = await db
+ .select({
+ shopItemId: refineryOrdersTable.shopItemId,
+ itemName: shopItemsTable.name,
+ itemImage: shopItemsTable.image,
+ baseProbability: shopItemsTable.baseProbability,
+ totalBoost: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
+ })
+ .from(refineryOrdersTable)
+ .innerJoin(shopItemsTable, eq(refineryOrdersTable.shopItemId, shopItemsTable.id))
+ .where(eq(refineryOrdersTable.userId, parseInt(params.id)))
+ .groupBy(refineryOrdersTable.shopItemId, shopItemsTable.name, shopItemsTable.image, shopItemsTable.baseProbability)
+
return {
user: {
id: targetUser[0].id,
username: targetUser[0].username,
avatar: targetUser[0].avatar,
- scraps: targetUser[0].scraps,
+ scraps: scrapsBalance.balance,
createdAt: targetUser[0].createdAt
},
projects: visibleProjects.map(p => ({
@@ -104,6 +148,14 @@ user.get('/profile/:id', async ({ params, headers }) => {
createdAt: p.createdAt
})),
heartedItems,
+ refinements: refinements.map(r => ({
+ shopItemId: r.shopItemId,
+ itemName: r.itemName,
+ itemImage: r.itemImage,
+ baseProbability: r.baseProbability,
+ totalBoost: Number(r.totalBoost),
+ effectiveProbability: Math.min(r.baseProbability + Number(r.totalBoost), 100)
+ })),
stats: {
projectCount: shippedCount,
inProgressCount,
diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts
index aa07ff5..b360045 100644
--- a/backend/src/schemas/index.ts
+++ b/backend/src/schemas/index.ts
@@ -2,6 +2,6 @@ export { usersTable } from './users'
export { projectsTable } from './projects'
export { reviewsTable } from './reviews'
export { sessionsTable } from './sessions'
-export { shopItemsTable, shopHeartsTable } from './shop'
+export { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from './shop'
export { newsTable } from './news'
export { activityTable } from './activity'
diff --git a/backend/src/schemas/projects.ts b/backend/src/schemas/projects.ts
index b24232c..520781e 100644
--- a/backend/src/schemas/projects.ts
+++ b/backend/src/schemas/projects.ts
@@ -17,6 +17,7 @@ export const projectsTable = pgTable('projects', {
hoursOverride: real('hours_override'),
status: varchar().notNull().default('in_progress'),
deleted: integer('deleted').default(0),
+ scrapsAwarded: integer('scraps_awarded').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
diff --git a/backend/src/schemas/shop.ts b/backend/src/schemas/shop.ts
index 4cacad3..0c49ba2 100644
--- a/backend/src/schemas/shop.ts
+++ b/backend/src/schemas/shop.ts
@@ -1,4 +1,4 @@
-import { integer, pgTable, varchar, timestamp, unique } from 'drizzle-orm/pg-core'
+import { integer, pgTable, varchar, timestamp, unique, text, boolean } from 'drizzle-orm/pg-core'
import { usersTable } from './users'
export const shopItemsTable = pgTable('shop_items', {
@@ -9,6 +9,10 @@ export const shopItemsTable = pgTable('shop_items', {
price: integer().notNull(),
category: varchar().notNull(),
count: integer().notNull().default(0),
+ baseProbability: integer('base_probability').notNull().default(50),
+ baseUpgradeCost: integer('base_upgrade_cost').notNull().default(10),
+ costMultiplier: integer('cost_multiplier').notNull().default(115),
+ boostAmount: integer('boost_amount').notNull().default(1),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
@@ -21,3 +25,48 @@ export const shopHeartsTable = pgTable('shop_hearts', {
}, (table) => [
unique().on(table.userId, table.shopItemId)
])
+
+export const shopOrdersTable = pgTable('shop_orders', {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
+ quantity: integer().notNull().default(1),
+ pricePerItem: integer('price_per_item').notNull(),
+ totalPrice: integer('total_price').notNull(),
+ status: varchar().notNull().default('pending'),
+ orderType: varchar('order_type').notNull().default('purchase'),
+ shippingAddress: text('shipping_address'),
+ notes: text(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull()
+})
+
+export const shopRollsTable = pgTable('shop_rolls', {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
+ rolled: integer().notNull(),
+ threshold: integer().notNull(),
+ won: boolean().notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull()
+})
+
+export const refineryOrdersTable = pgTable('refinery_orders', {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
+ cost: integer().notNull(),
+ boostAmount: integer('boost_amount').notNull().default(1),
+ createdAt: timestamp('created_at').defaultNow().notNull()
+})
+
+export const shopPenaltiesTable = pgTable('shop_penalties', {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
+ probabilityMultiplier: integer('probability_multiplier').notNull().default(100),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull()
+}, (table) => [
+ unique().on(table.userId, table.shopItemId)
+])
diff --git a/backend/src/schemas/users.ts b/backend/src/schemas/users.ts
index 3de5025..0840e00 100644
--- a/backend/src/schemas/users.ts
+++ b/backend/src/schemas/users.ts
@@ -1,4 +1,4 @@
-import { integer, pgTable, varchar, text, timestamp } from 'drizzle-orm/pg-core'
+import { integer, pgTable, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
export const usersTable = pgTable('users', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
@@ -17,11 +17,21 @@ export const usersTable = pgTable('users', {
refreshToken: text('refresh_token'),
idToken: text('id_token'),
- // scraps info
- scraps: integer().notNull().default(0),
+ // user role
role: varchar().notNull().default('member'),
internalNotes: text('internal_notes'),
+ // tutorial status
+ tutorialCompleted: boolean('tutorial_completed').notNull().default(false),
+
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
+
+export const userBonusesTable = pgTable('user_bonuses', {
+ id: integer().primaryKey().generatedAlwaysAsIdentity(),
+ userId: integer('user_id').notNull().references(() => usersTable.id),
+ type: varchar().notNull(),
+ amount: integer().notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull()
+})
diff --git a/frontend/src/lib/auth-client.ts b/frontend/src/lib/auth-client.ts
index 12b3551..c2d54ae 100644
--- a/frontend/src/lib/auth-client.ts
+++ b/frontend/src/lib/auth-client.ts
@@ -1,4 +1,5 @@
import { API_URL } from '$lib/config'
+import { writable } from 'svelte/store'
export interface User {
id: number
@@ -8,8 +9,11 @@ export interface User {
slackId: string | null
scraps: number
role: string
+ tutorialCompleted: boolean
}
+export const userScrapsStore = writable(0)
+
let cachedUser: User | null | undefined = undefined
let fetchPromise: Promise | null = null
@@ -27,8 +31,8 @@ export async function logout() {
window.location.href = "/"
}
-export async function getUser(): Promise {
- if (cachedUser !== undefined) return cachedUser
+export async function getUser(forceRefresh = false): Promise {
+ if (!forceRefresh && cachedUser !== undefined) return cachedUser
if (fetchPromise) return fetchPromise
@@ -48,6 +52,9 @@ export async function getUser(): Promise {
return null
}
cachedUser = (data.user as User) || null
+ if (cachedUser) {
+ userScrapsStore.set(cachedUser.scraps)
+ }
return cachedUser
} catch {
cachedUser = null
@@ -59,3 +66,8 @@ export async function getUser(): Promise {
return fetchPromise
}
+
+export async function refreshUserScraps(): Promise {
+ const user = await getUser(true)
+ return user?.scraps ?? null
+}
diff --git a/frontend/src/lib/components/AddressSelectModal.svelte b/frontend/src/lib/components/AddressSelectModal.svelte
new file mode 100644
index 0000000..a4c2400
--- /dev/null
+++ b/frontend/src/lib/components/AddressSelectModal.svelte
@@ -0,0 +1,342 @@
+
+
+ e.key === 'Escape' && onClose()}
+ role="dialog"
+ tabindex="-1"
+>
+
+
+
shipping address
+
+
+
+
+
🎉 congratulations!
+
you won {itemName}! enter your shipping address to receive it.
+
+
+ {#if error}
+
+ {error}
+
+ {/if}
+
+
+ {#if loadingAddresses}
+
loading addresses...
+ {:else if addresses.length > 0}
+
+
+
+
+
+ {#if showDropdown}
+
+ {#each addresses as addr}
+
+ {/each}
+
+
+ {/if}
+
+
+ {/if}
+
+ {#if useNewAddress || selectedAddressId}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ConfirmModal.svelte b/frontend/src/lib/components/ConfirmModal.svelte
new file mode 100644
index 0000000..5dfb27c
--- /dev/null
+++ b/frontend/src/lib/components/ConfirmModal.svelte
@@ -0,0 +1,72 @@
+
+
+ e.key === 'Escape' && onCancel()}
+ role="dialog"
+ tabindex="-1"
+>
+
+
{title}
+
+ {@html message}
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/CreateProjectModal.svelte b/frontend/src/lib/components/CreateProjectModal.svelte
index b9d8390..d346b12 100644
--- a/frontend/src/lib/components/CreateProjectModal.svelte
+++ b/frontend/src/lib/components/CreateProjectModal.svelte
@@ -2,6 +2,8 @@
import { X, ChevronDown, Upload, Check } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
+ import { tutorialProjectIdStore } from '$lib/stores'
+ import { goto } from '$app/navigation'
interface Project {
id: number
@@ -25,15 +27,25 @@
let {
open,
onClose,
- onCreated
+ onCreated,
+ tutorialMode = false
}: {
open: boolean
onClose: () => void
onCreated: (project: Project) => void
+ tutorialMode?: boolean
} = $props()
let name = $state('')
let description = $state('')
+
+ // Pre-fill values when modal opens in tutorial mode
+ $effect(() => {
+ if (open && tutorialMode && name === '' && description === '') {
+ name = 'my first scrap'
+ description = "this is my first project on scraps! i'm excited to start building and earning rewards."
+ }
+ })
let githubUrl = $state('')
let imageUrl = $state(null)
let imagePreview = $state(null)
@@ -180,7 +192,14 @@
const newProject = await response.json()
resetForm()
- onCreated(newProject)
+ if (tutorialMode) {
+ tutorialProjectIdStore.set(newProject.id)
+ window.dispatchEvent(new CustomEvent('tutorial:project-created'))
+ onCreated(newProject)
+ goto(`/projects/${newProject.id}`, { invalidateAll: false })
+ } else {
+ onCreated(newProject)
+ }
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project'
} finally {
@@ -194,6 +213,7 @@
}
function handleBackdropClick(e: MouseEvent) {
+ if (tutorialMode) return
if (e.target === e.currentTarget) {
handleClose()
}
@@ -202,13 +222,13 @@
{#if open}
e.key === 'Escape' && handleClose()}
+ onkeydown={(e) => !tutorialMode && e.key === 'Escape' && handleClose()}
role="dialog"
tabindex="-1"
>
-
+
new project
diff --git a/frontend/src/routes/admin/news/+page.svelte b/frontend/src/routes/admin/news/+page.svelte
index 8e53230..bfb39d0 100644
--- a/frontend/src/routes/admin/news/+page.svelte
+++ b/frontend/src/routes/admin/news/+page.svelte
@@ -31,6 +31,7 @@
let formContent = $state('')
let formActive = $state(true)
let formError = $state
(null)
+ let deleteConfirmId = $state(null)
onMount(async () => {
user = await getUser()
@@ -136,11 +137,15 @@
}
}
- async function deleteItem(id: number) {
- if (!confirm('Are you sure you want to delete this news item?')) return
+ function requestDelete(id: number) {
+ deleteConfirmId = id
+ }
+
+ async function confirmDelete() {
+ if (!deleteConfirmId) return
try {
- const response = await fetch(`${API_URL}/admin/news/${id}`, {
+ const response = await fetch(`${API_URL}/admin/news/${deleteConfirmId}`, {
method: 'DELETE',
credentials: 'include'
})
@@ -149,6 +154,8 @@
}
} catch (e) {
console.error('Failed to delete:', e)
+ } finally {
+ deleteConfirmId = null
}
}
@@ -162,7 +169,7 @@
- news editor | admin | scraps
+ news editor - admin - scraps
@@ -218,7 +225,7 @@
{/if}
+
+{#if deleteConfirmId}
+
e.target === e.currentTarget && (deleteConfirmId = null)}
+ onkeydown={(e) => e.key === 'Escape' && (deleteConfirmId = null)}
+ role="dialog"
+ tabindex="-1"
+ >
+
+
confirm delete
+
+ are you sure you want to delete this news item? this action cannot be undone.
+
+
+
+
+
+
+
+{/if}
diff --git a/frontend/src/routes/admin/reviews/+page.svelte b/frontend/src/routes/admin/reviews/+page.svelte
index 886b187..5bde902 100644
--- a/frontend/src/routes/admin/reviews/+page.svelte
+++ b/frontend/src/routes/admin/reviews/+page.svelte
@@ -73,7 +73,7 @@
- reviews | admin | scraps
+ reviews - admin - scraps
diff --git a/frontend/src/routes/admin/reviews/[id]/+page.svelte b/frontend/src/routes/admin/reviews/[id]/+page.svelte
index a0fa263..2a9fa34 100644
--- a/frontend/src/routes/admin/reviews/[id]/+page.svelte
+++ b/frontend/src/routes/admin/reviews/[id]/+page.svelte
@@ -230,7 +230,7 @@
- review {project?.name || 'project'} | admin | scraps
+ review {project?.name || 'project'} - admin - scraps
diff --git a/frontend/src/routes/admin/shop/+page.svelte b/frontend/src/routes/admin/shop/+page.svelte
index c1d24df..6d6e78f 100644
--- a/frontend/src/routes/admin/shop/+page.svelte
+++ b/frontend/src/routes/admin/shop/+page.svelte
@@ -13,6 +13,10 @@
price: number
category: string
count: number
+ baseProbability: number
+ baseUpgradeCost: number
+ costMultiplier: number
+ boostAmount: number
createdAt: string
updatedAt: string
}
@@ -36,7 +40,12 @@
let formPrice = $state(0)
let formCategory = $state('')
let formCount = $state(0)
+ let formBaseProbability = $state(50)
+ let formBaseUpgradeCost = $state(10)
+ let formCostMultiplier = $state(115)
+ let formBoostAmount = $state(1)
let formError = $state
(null)
+ let deleteConfirmId = $state(null)
onMount(async () => {
user = await getUser()
@@ -72,6 +81,10 @@
formPrice = 0
formCategory = ''
formCount = 0
+ formBaseProbability = 50
+ formBaseUpgradeCost = 10
+ formCostMultiplier = 115
+ formBoostAmount = 1
formError = null
showModal = true
}
@@ -84,6 +97,10 @@
formPrice = item.price
formCategory = item.category
formCount = item.count
+ formBaseProbability = item.baseProbability
+ formBaseUpgradeCost = item.baseUpgradeCost
+ formCostMultiplier = item.costMultiplier
+ formBoostAmount = item.boostAmount ?? 1
formError = null
showModal = true
}
@@ -117,7 +134,11 @@
description: formDescription,
price: formPrice,
category: formCategory,
- count: formCount
+ count: formCount,
+ baseProbability: formBaseProbability,
+ baseUpgradeCost: formBaseUpgradeCost,
+ costMultiplier: formCostMultiplier,
+ boostAmount: formBoostAmount
})
})
@@ -135,11 +156,15 @@
}
}
- async function deleteItem(id: number) {
- if (!confirm('Are you sure you want to delete this item?')) return
+ function requestDelete(id: number) {
+ deleteConfirmId = id
+ }
+
+ async function confirmDelete() {
+ if (!deleteConfirmId) return
try {
- const response = await fetch(`${API_URL}/admin/shop/items/${id}`, {
+ const response = await fetch(`${API_URL}/admin/shop/items/${deleteConfirmId}`, {
method: 'DELETE',
credentials: 'include'
})
@@ -148,12 +173,14 @@
}
} catch (e) {
console.error('Failed to delete:', e)
+ } finally {
+ deleteConfirmId = null
}
}
- shop editor | admin | scraps
+ shop editor - admin - scraps
@@ -193,6 +220,14 @@
{cat}
{/each}
{item.count} in stock
+ •
+ {item.baseProbability}% base chance
+ •
+ {item.baseUpgradeCost} upgrade cost
+ •
+ {item.costMultiplier / 100}x multiplier
+ •
+ +{item.boostAmount ?? 1}% per upgrade
@@ -203,7 +238,7 @@
+
+
+
+
+
+
+
+
+
+
probability increase per upgrade
+
+
+
+
+
+
+
+
+
+
+
+
115 = 1.15x cost increase per upgrade
+
+
@@ -316,3 +400,34 @@
{/if}
+
+{#if deleteConfirmId}
+ e.target === e.currentTarget && (deleteConfirmId = null)}
+ onkeydown={(e) => e.key === 'Escape' && (deleteConfirmId = null)}
+ role="dialog"
+ tabindex="-1"
+ >
+
+
confirm delete
+
+ are you sure you want to delete this item? this action cannot be undone.
+
+
+
+
+
+
+
+{/if}
diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte
index bc13de6..cc20831 100644
--- a/frontend/src/routes/admin/users/+page.svelte
+++ b/frontend/src/routes/admin/users/+page.svelte
@@ -148,7 +148,7 @@
- users | admin | scraps
+ users - admin - scraps
diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte
index d4e6fc7..d3ace11 100644
--- a/frontend/src/routes/admin/users/[id]/+page.svelte
+++ b/frontend/src/routes/admin/users/[id]/+page.svelte
@@ -168,7 +168,7 @@
- {targetUser?.username || 'user'} | admin | scraps
+ {targetUser?.username || 'user'} - admin - scraps
diff --git a/frontend/src/routes/auth/callback/+page.svelte b/frontend/src/routes/auth/callback/+page.svelte
index 02df4ba..1a9a24e 100644
--- a/frontend/src/routes/auth/callback/+page.svelte
+++ b/frontend/src/routes/auth/callback/+page.svelte
@@ -34,7 +34,7 @@
- authenticating... | scraps
+ authenticating... - scraps
diff --git a/frontend/src/routes/auth/error/+page.svelte b/frontend/src/routes/auth/error/+page.svelte
index 29882da..ff0bd11 100644
--- a/frontend/src/routes/auth/error/+page.svelte
+++ b/frontend/src/routes/auth/error/+page.svelte
@@ -23,7 +23,7 @@
- error | scraps
+ error - scraps
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte
index bcd1d26..e8e2fba 100644
--- a/frontend/src/routes/dashboard/+page.svelte
+++ b/frontend/src/routes/dashboard/+page.svelte
@@ -11,6 +11,7 @@
projectsLoading,
fetchProjects,
addProject,
+ tutorialActiveStore,
type Project
} from '$lib/stores'
import { formatHours } from '$lib/utils'
@@ -52,7 +53,7 @@
- dashboard | scraps
+ dashboard - scraps
@@ -96,6 +97,7 @@
-
showCreateModal = false} onCreated={handleProjectCreated} />
+ showCreateModal = false} onCreated={handleProjectCreated} tutorialMode={$tutorialActiveStore} />