diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 50342628..cdf84953 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -79,7 +79,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^0.19.8" } }, "node_modules/@babel/code-frame": { @@ -113,7 +113,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2402,6 +2401,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4853,7 +4890,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4864,7 +4900,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4875,7 +4910,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4939,7 +4973,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5165,7 +5198,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5438,7 +5470,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6032,8 +6063,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -6276,7 +6306,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6581,6 +6610,36 @@ "node": ">=6.0.0" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6612,6 +6671,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8147,6 +8216,16 @@ "node": ">= 0.4" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8577,22 +8656,32 @@ "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8623,7 +8712,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8636,7 +8724,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9000,6 +9087,17 @@ "node": ">=4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -9044,6 +9142,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -9064,27 +9186,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -9140,13 +9241,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-cookie-parser": { @@ -9546,7 +9647,6 @@ "resolved": "https://registry.npmjs.org/supertokens-web-js/-/supertokens-web-js-0.16.0.tgz", "integrity": "sha512-wuIdlVJtOsx4ZX0kCyl8lxmmAodXLlMAniZEHyVhsH2fhersh7bMrHpvgN9WoC470HYNC22qpHdlJngvyh/cSA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@simplewebauthn/browser": "^13.0.0", "supertokens-js-override": "0.0.4", @@ -9653,7 +9753,6 @@ "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "devOptional": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9711,7 +9810,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9881,7 +9979,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10165,7 +10262,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10236,17 +10332,17 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", - "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", + "version": "0.19.8", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.19.8.tgz", + "integrity": "sha512-e1oK0dfhzhDhY3VBuML6c0h8Xfx6EkOVYqolj7g+u8eRfdauZe5RLteCIA/c5gH0CBQ0CNFAuv/AFTx4Z7IXTw==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.6", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", "pretty-bytes": "^6.1.1", - "tinyglobby": "^0.2.10", - "workbox-build": "^7.4.0", - "workbox-window": "^7.4.0" + "workbox-build": "^7.0.0", + "workbox-window": "^7.0.0" }, "engines": { "node": ">=16.0.0" @@ -10255,10 +10351,10 @@ "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@vite-pwa/assets-generator": "^1.0.0", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", - "workbox-build": "^7.4.0", - "workbox-window": "^7.4.0" + "@vite-pwa/assets-generator": "^0.2.4", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0", + "workbox-build": "^7.0.0", + "workbox-window": "^7.0.0" }, "peerDependenciesMeta": { "@vite-pwa/assets-generator": { @@ -10288,7 +10384,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10593,7 +10688,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10648,7 +10742,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -10835,7 +10928,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/client/web/package.json b/client/web/package.json index 288fc56d..88f8df63 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -83,11 +83,15 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^0.19.8" }, "overrides": { "postcss": "^8.4.38", "semver": "^7.5.2", - "minimatch": "^10.2.1" + "minimatch": "^10.2.1", + "serialize-javascript": "^7.0.5", + "vite-plugin-pwa": { + "vite": "$vite" + } } } diff --git a/cmd/api/api.go b/cmd/api/api.go index 76296357..f8e43e6b 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -257,6 +257,9 @@ func (app *application) mount() http.Handler { r.Get("/hackathon-date-range", app.getHackathonDateRange) r.Post("/hackathon-date-range", app.setHackathonDateRange) r.Put("/scan-types", app.updateScanTypesHandler) + r.Get("/meal-groups", app.getMealGroups) + r.Put("/meal-groups", app.updateMealGroups) + r.Get("/meal-groups/stats", app.getMealGroupStats) r.Put("/applications-enabled", app.setApplicationsEnabled) }) diff --git a/cmd/api/applications.go b/cmd/api/applications.go index 56f82824..c50216b9 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -80,6 +80,7 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -141,6 +142,7 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. if err := app.jsonResponse(w, http.StatusOK, application); err != nil { app.internalServerError(w, r, err) + return } } @@ -212,6 +214,7 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. if err := app.jsonResponse(w, http.StatusOK, application); err != nil { app.internalServerError(w, r, err) + return } } @@ -351,6 +354,7 @@ func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *htt if err := app.jsonResponse(w, http.StatusOK, stats); err != nil { app.internalServerError(w, r, err) + return } } @@ -456,6 +460,7 @@ func (app *application) listApplicationsHandler(w http.ResponseWriter, r *http.R if err := app.jsonResponse(w, http.StatusOK, result); err != nil { app.internalServerError(w, r, err) + return } } @@ -525,6 +530,7 @@ func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Requ if err := app.jsonResponse(w, http.StatusOK, ApplicationResponse{Application: application}); err != nil { app.internalServerError(w, r, err) + return } } @@ -573,6 +579,7 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -627,5 +634,6 @@ func (app *application) getApplicantEmailsByStatusHandler(w http.ResponseWriter, if err = app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/auth.go b/cmd/api/auth.go index 0013bbd8..e7d71504 100644 --- a/cmd/api/auth.go +++ b/cmd/api/auth.go @@ -46,6 +46,7 @@ func (app *application) getCurrentUserHandler(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -79,6 +80,7 @@ func (app *application) checkEmailAuthMethodHandler(w http.ResponseWriter, r *ht // Email not registered if err := app.jsonResponse(w, http.StatusOK, CheckEmailResponse{Exists: false}); err != nil { app.internalServerError(w, r, err) + return } return } @@ -92,5 +94,6 @@ func (app *application) checkEmailAuthMethodHandler(w http.ResponseWriter, r *ht AuthMethod: &user.AuthMethod, }); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/reset_hackathon.go b/cmd/api/reset_hackathon.go index fc8902d6..b0c30e4a 100644 --- a/cmd/api/reset_hackathon.go +++ b/cmd/api/reset_hackathon.go @@ -78,5 +78,6 @@ func (app *application) resetHackathonHandler(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/resume.go b/cmd/api/resume.go index 445e9fb5..1574a647 100644 --- a/cmd/api/resume.go +++ b/cmd/api/resume.go @@ -83,6 +83,7 @@ func (app *application) generateResumeUploadURLHandler(w http.ResponseWriter, r ResumePath: objectPath, }); err != nil { app.internalServerError(w, r, err) + return } } @@ -142,6 +143,7 @@ func (app *application) deleteResumeHandler(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, application); err != nil { app.internalServerError(w, r, err) + return } } @@ -199,6 +201,7 @@ func (app *application) getResumeDownloadURLHandler(w http.ResponseWriter, r *ht DownloadURL: downloadURL, }); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/reviews.go b/cmd/api/reviews.go index 3859963c..e8e5c06c 100644 --- a/cmd/api/reviews.go +++ b/cmd/api/reviews.go @@ -64,6 +64,7 @@ func (app *application) getPendingReviews(w http.ResponseWriter, r *http.Request if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -94,6 +95,7 @@ func (app *application) getCompletedReviews(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -130,6 +132,7 @@ func (app *application) getApplicationNotes(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -160,6 +163,7 @@ func (app *application) batchAssignReviews(w http.ResponseWriter, r *http.Reques if err := app.jsonResponse(w, http.StatusOK, result); err != nil { app.internalServerError(w, r, err) + return } } @@ -202,6 +206,7 @@ func (app *application) getNextReview(w http.ResponseWriter, r *http.Request) { if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -259,6 +264,7 @@ func (app *application) submitVote(w http.ResponseWriter, r *http.Request) { if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -317,5 +323,6 @@ func (app *application) setAIPercent(w http.ResponseWriter, r *http.Request) { if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/scans.go b/cmd/api/scans.go index 62064c20..3dac39ae 100644 --- a/cmd/api/scans.go +++ b/cmd/api/scans.go @@ -1,7 +1,9 @@ package main import ( + "context" "errors" + "math/rand" "net/http" "github.com/go-chi/chi" @@ -29,6 +31,11 @@ type UpdateScanTypesPayload struct { ScanTypes []store.ScanType `json:"scan_types" validate:"required,dive"` } +type CreateScanResponse struct { + *store.Scan + MealGroup *string `json:"meal_group,omitempty"` +} + // getScanTypesHandler returns all configured scan types // // @Summary Get scan types (Admin) @@ -50,6 +57,7 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, ScanTypesResponse{ScanTypes: scanTypes}); err != nil { app.internalServerError(w, r, err) + return } } @@ -61,7 +69,7 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque // @Accept json // @Produce json // @Param scan body CreateScanPayload true "Scan to create" -// @Success 201 {object} store.Scan +// @Success 201 {object} CreateScanResponse // @Failure 400 {object} object{error=string} // @Failure 401 {object} object{error=string} // @Failure 403 {object} object{error=string} @@ -148,8 +156,27 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request return } - if err := app.jsonResponse(w, http.StatusCreated, scan); err != nil { + var mealGroup *string + if found.Category == store.ScanCategoryCheckIn { + mealGroup = app.assignMealGroup(r.Context(), req.UserID) + } else { + // Fetch meal group for response (non-fatal) + var err error + mealGroup, err = app.store.Application.GetMealGroupByUserID(r.Context(), req.UserID) + if err != nil && !errors.Is(err, store.ErrNotFound) { + // We don't want to fail the scan if we can't get the meal group info + app.logger.Warnw("failed to fetch meal group for scan response", "user_id", req.UserID, "error", err) + } + } + + response := CreateScanResponse{ + Scan: scan, + MealGroup: mealGroup, + } + + if err := app.jsonResponse(w, http.StatusCreated, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -182,9 +209,43 @@ func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, ScansResponse{Scans: scans}); err != nil { app.internalServerError(w, r, err) + return } } +func (app *application) assignMealGroup(ctx context.Context, userID string) *string { + groups, err := app.store.Settings.GetMealGroups(ctx) + if err != nil { + app.logger.Warnw("failed to fetch meal groups for assignment", "error", err) + return nil + } + + if len(groups) == 0 { + return nil + } + + hackerApp, err := app.store.Application.GetByUserID(ctx, userID) + if err != nil { + // If the user doesn't have an application, we can't assign a group. + if !errors.Is(err, store.ErrNotFound) { + app.logger.Warnw("failed to fetch application for meal group assignment", "user_id", userID, "error", err) + } + return nil + } + + if hackerApp.MealGroup != nil { + return hackerApp.MealGroup // Already assigned + } + + selectedGroup := groups[rand.Intn(len(groups))] + if err := app.store.Application.SetMealGroup(ctx, hackerApp.ID, selectedGroup); err != nil { + app.logger.Warnw("failed to set meal group on application", "app_id", hackerApp.ID, "error", err) + return nil + } + + return &selectedGroup +} + // getScanStatsHandler returns aggregate scan counts grouped by scan type // // @Summary Get scan statistics (Admin) @@ -206,6 +267,7 @@ func (app *application) getScanStatsHandler(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, ScanStatsResponse{Stats: stats}); err != nil { app.internalServerError(w, r, err) + return } } @@ -265,5 +327,6 @@ func (app *application) updateScanTypesHandler(w http.ResponseWriter, r *http.Re if err := app.jsonResponse(w, http.StatusOK, ScanTypesResponse(req)); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/scans_test.go b/cmd/api/scans_test.go index 61f7487e..263727ce 100644 --- a/cmd/api/scans_test.go +++ b/cmd/api/scans_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "net/http" "strings" "testing" @@ -56,8 +57,48 @@ func TestCreateScan(t *testing.T) { app := newTestApplication(t) mockSettings := app.store.Settings.(*store.MockSettingsStore) mockScans := app.store.Scans.(*store.MockScansStore) + mockApps := app.store.Application.(*store.MockApplicationStore) + + groups := []string{"A", "B"} + hackerApp := &store.Application{ID: "app-1", UserID: "user-1", MealGroup: nil} + + mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + mockSettings.On("GetMealGroups").Return(groups, nil).Once() + mockApps.On("GetByUserID", "user-1").Return(hackerApp, nil).Once() + mockApps.On("SetMealGroup", "app-1", mock.AnythingOfType("string")).Return(nil).Once() + mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() + + body := `{"user_id":"user-1","scan_type":"check_in"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) + checkResponseCode(t, http.StatusCreated, rr.Code) + + var resp struct { + Data CreateScanResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.NotNil(t, resp.Data.MealGroup) + assert.Contains(t, groups, *resp.Data.MealGroup) + + mockSettings.AssertExpectations(t) + mockScans.AssertExpectations(t) + mockApps.AssertExpectations(t) + }) + + t.Run("check_in success - meal group assignment failure is non-fatal", func(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + mockScans := app.store.Scans.(*store.MockScansStore) + mockApps := app.store.Application.(*store.MockApplicationStore) mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() + // Simulate error in meal group fetching + mockSettings.On("GetMealGroups").Return(nil, errors.New("db error")).Once() mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() body := `{"user_id":"user-1","scan_type":"check_in"}` @@ -69,18 +110,30 @@ func TestCreateScan(t *testing.T) { rr := executeRequest(req, http.HandlerFunc(app.createScanHandler)) checkResponseCode(t, http.StatusCreated, rr.Code) + var resp struct { + Data CreateScanResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Nil(t, resp.Data.MealGroup) + mockSettings.AssertExpectations(t) mockScans.AssertExpectations(t) + mockApps.AssertExpectations(t) }) t.Run("item scan when checked in", func(t *testing.T) { app := newTestApplication(t) mockSettings := app.store.Settings.(*store.MockSettingsStore) mockScans := app.store.Scans.(*store.MockScansStore) + mockApps := app.store.Application.(*store.MockApplicationStore) + + mealGroup := "A" mockSettings.On("GetScanTypes").Return(scanTypes, nil).Once() mockScans.On("HasCheckIn", "user-1", []string{"check_in"}).Return(true, nil).Once() mockScans.On("Create", mock.AnythingOfType("*store.Scan")).Return(nil).Once() + mockApps.On("GetMealGroupByUserID", "user-1").Return(&mealGroup, nil).Once() body := `{"user_id":"user-1","scan_type":"lunch"}` req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) @@ -93,6 +146,7 @@ func TestCreateScan(t *testing.T) { mockSettings.AssertExpectations(t) mockScans.AssertExpectations(t) + mockApps.AssertExpectations(t) }) t.Run("403 not checked in", func(t *testing.T) { diff --git a/cmd/api/schedule.go b/cmd/api/schedule.go index 4533ad0c..d3a1255d 100644 --- a/cmd/api/schedule.go +++ b/cmd/api/schedule.go @@ -58,6 +58,7 @@ func (app *application) getAdminScheduleDateRange(w http.ResponseWriter, r *http if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -82,6 +83,7 @@ func (app *application) listScheduleHandler(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, ScheduleListResponse{Schedule: items}); err != nil { app.internalServerError(w, r, err) + return } } @@ -138,6 +140,7 @@ func (app *application) createScheduleHandler(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusCreated, ScheduleItemResponse{Schedule: *item}); err != nil { app.internalServerError(w, r, err) + return } } @@ -203,6 +206,7 @@ func (app *application) updateScheduleHandler(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusOK, ScheduleItemResponse{Schedule: *item}); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/settings.go b/cmd/api/settings.go index 053a5ecc..963e9402 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -41,6 +41,7 @@ func (app *application) getApplicationSchema(w http.ResponseWriter, r *http.Requ if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -90,6 +91,7 @@ func (app *application) updateApplicationSchema(w http.ResponseWriter, r *http.R if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -126,6 +128,7 @@ func (app *application) getReviewsPerApp(w http.ResponseWriter, r *http.Request) if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -165,6 +168,7 @@ func (app *application) setReviewsPerApp(w http.ResponseWriter, r *http.Request) if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -258,6 +262,7 @@ func (app *application) setReviewAssignmentToggle(w http.ResponseWriter, r *http if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -286,6 +291,7 @@ func (app *application) getAdminScheduleEditToggle(w http.ResponseWriter, r *htt if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -320,6 +326,7 @@ func (app *application) setAdminScheduleEditToggle(w http.ResponseWriter, r *htt if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -350,6 +357,7 @@ func (app *application) getHackathonDateRange(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } @@ -420,6 +428,117 @@ func (app *application) setHackathonDateRange(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return + } +} + +type UpdateMealGroupsPayload struct { + Groups []string `json:"groups" validate:"max=50,dive,required,min=1,max=50"` +} + +type MealGroupsResponse struct { + Groups []string `json:"groups"` +} + +type MealGroupStatsResponse struct { + Stats map[string]int `json:"stats"` +} + +// getMealGroups returns the configured meal group names +// +// @Summary Get meal groups (Super Admin) +// @Description Returns the configured list of meal group names +// @Tags superadmin/settings +// @Produce json +// @Success 200 {object} MealGroupsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/meal-groups [get] +func (app *application) getMealGroups(w http.ResponseWriter, r *http.Request) { + groups, err := app.store.Settings.GetMealGroups(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, MealGroupsResponse{Groups: groups}); err != nil { + app.internalServerError(w, r, err) + return + } +} + +// updateMealGroups replaces all meal group names +// +// @Summary Update meal groups (Super Admin) +// @Description Replaces the available meal group names with the provided array +// @Tags superadmin/settings +// @Accept json +// @Produce json +// @Param groups body UpdateMealGroupsPayload true "Groups to set" +// @Success 200 {object} MealGroupsResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/meal-groups [put] +func (app *application) updateMealGroups(w http.ResponseWriter, r *http.Request) { + var req UpdateMealGroupsPayload + if err := readJSON(w, r, &req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + if err := Validate.Struct(req); err != nil { + app.badRequestResponse(w, r, err) + return + } + + // Validate unique names + nameMap := make(map[string]bool) + for _, name := range req.Groups { + if nameMap[name] { + app.badRequestResponse(w, r, errors.New("duplicate meal group name: "+name)) + return + } + nameMap[name] = true + } + + if err := app.store.Settings.SetMealGroups(r.Context(), req.Groups); err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, MealGroupsResponse(req)); err != nil { + app.internalServerError(w, r, err) + return + } +} + +// getMealGroupStats returns the number of hackers assigned to each meal group +// +// @Summary Get meal group stats (Super Admin) +// @Description Returns assignment counts for each configured meal group +// @Tags superadmin/settings +// @Produce json +// @Success 200 {object} MealGroupStatsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/meal-groups/stats [get] +func (app *application) getMealGroupStats(w http.ResponseWriter, r *http.Request) { + stats, err := app.store.Settings.GetMealGroupStats(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, MealGroupStatsResponse{Stats: stats}); err != nil { + app.internalServerError(w, r, err) + return } } @@ -482,5 +601,6 @@ func (app *application) setApplicationsEnabled(w http.ResponseWriter, r *http.Re if err := app.jsonResponse(w, http.StatusOK, response); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/settings_test.go b/cmd/api/settings_test.go index a6c44c9e..8a0ccf08 100644 --- a/cmd/api/settings_test.go +++ b/cmd/api/settings_test.go @@ -420,3 +420,87 @@ func TestSetHackathonDateRange(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr.Code) }) } + +func TestGetMealGroups(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + t.Run("should return meal groups", func(t *testing.T) { + groups := []string{"A", "B", "C"} + mockSettings.On("GetMealGroups").Return(groups, nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getMealGroups)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data MealGroupsResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, groups, body.Data.Groups) + + mockSettings.AssertExpectations(t) + }) +} + +func TestUpdateMealGroups(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + t.Run("should update meal groups", func(t *testing.T) { + groups := []string{"Alpha", "Beta"} + mockSettings.On("SetMealGroups", groups).Return(nil).Once() + + body := `{"groups":["Alpha", "Beta"]}` + req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.updateMealGroups)) + checkResponseCode(t, http.StatusOK, rr.Code) + + mockSettings.AssertExpectations(t) + }) + + t.Run("should return 400 for duplicate names", func(t *testing.T) { + body := `{"groups":["A", "A"]}` + req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.updateMealGroups)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) +} + +func TestGetMealGroupStats(t *testing.T) { + app := newTestApplication(t) + mockSettings := app.store.Settings.(*store.MockSettingsStore) + + t.Run("should return stats", func(t *testing.T) { + stats := map[string]int{"A": 10, "B": 20} + mockSettings.On("GetMealGroupStats").Return(stats, nil).Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.getMealGroupStats)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data MealGroupStatsResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, stats, body.Data.Stats) + + mockSettings.AssertExpectations(t) + }) +} diff --git a/cmd/api/sponsors.go b/cmd/api/sponsors.go index 72130d05..e585e200 100644 --- a/cmd/api/sponsors.go +++ b/cmd/api/sponsors.go @@ -57,6 +57,7 @@ func (app *application) listSponsorsHandler(w http.ResponseWriter, r *http.Reque if err := app.jsonResponse(w, http.StatusOK, SponsorListResponse{Sponsors: sponsors}); err != nil { app.internalServerError(w, r, err) + return } } @@ -102,6 +103,7 @@ func (app *application) createSponsorHandler(w http.ResponseWriter, r *http.Requ if err := app.jsonResponse(w, http.StatusCreated, sponsor); err != nil { app.internalServerError(w, r, err) + return } } @@ -160,6 +162,7 @@ func (app *application) updateSponsorHandler(w http.ResponseWriter, r *http.Requ if err := app.jsonResponse(w, http.StatusOK, sponsor); err != nil { app.internalServerError(w, r, err) + return } } @@ -272,5 +275,6 @@ func (app *application) uploadLogoHandler(w http.ResponseWriter, r *http.Request if err := app.jsonResponse(w, http.StatusOK, sponsor); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/api/superadmin_users.go b/cmd/api/superadmin_users.go index a2f2450d..4f7abb40 100644 --- a/cmd/api/superadmin_users.go +++ b/cmd/api/superadmin_users.go @@ -103,6 +103,7 @@ func (app *application) searchUsersHandler(w http.ResponseWriter, r *http.Reques if err := app.jsonResponse(w, http.StatusOK, result); err != nil { app.internalServerError(w, r, err) + return } } @@ -200,6 +201,7 @@ func (app *application) listUsersByRole(w http.ResponseWriter, r *http.Request, } if err := app.jsonResponse(w, http.StatusOK, resp); err != nil { app.internalServerError(w, r, err) + return } } @@ -250,5 +252,6 @@ func (app *application) updateUserRoleHandler(w http.ResponseWriter, r *http.Req if err := app.jsonResponse(w, http.StatusOK, UpdateRoleResponse{User: user}); err != nil { app.internalServerError(w, r, err) + return } } diff --git a/cmd/migrate/migrations/000023_add_meal_groups.down.sql b/cmd/migrate/migrations/000023_add_meal_groups.down.sql new file mode 100644 index 00000000..d78070d6 --- /dev/null +++ b/cmd/migrate/migrations/000023_add_meal_groups.down.sql @@ -0,0 +1,5 @@ +-- Remove the meal_groups setting +DELETE FROM settings WHERE key = 'meal_groups'; + +-- Remove the meal_group column from applications +ALTER TABLE applications DROP COLUMN meal_group; diff --git a/cmd/migrate/migrations/000023_add_meal_groups.up.sql b/cmd/migrate/migrations/000023_add_meal_groups.up.sql new file mode 100644 index 00000000..017c5d34 --- /dev/null +++ b/cmd/migrate/migrations/000023_add_meal_groups.up.sql @@ -0,0 +1,8 @@ +-- Add nullable meal_group column to applications +ALTER TABLE applications ADD COLUMN meal_group TEXT; + +-- Add default meal groups to settings +-- Assumes a settings table with 'key' and 'value' (JSONB) columns +INSERT INTO settings (key, value) +VALUES ('meal_groups', '["A", "B", "C", "D"]'::jsonb) +ON CONFLICT (key) DO NOTHING; diff --git a/docs/docs.go b/docs/docs.go index b6525316..835b6a55 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -859,7 +859,7 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/store.Scan" + "$ref": "#/definitions/main.CreateScanResponse" } }, "400": { @@ -3097,7 +3097,204 @@ const docTemplate = `{ } } }, - "/superadmin/settings/applications-enabled": { + "/superadmin/settings/meal-groups": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns the configured list of meal group names", + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/settings" + ], + "summary": "Get meal groups (Super Admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.MealGroupsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "put": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Replaces the available meal group names with the provided array", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/settings" + ], + "summary": "Update meal groups (Super Admin)", + "parameters": [ + { + "description": "Groups to set", + "name": "groups", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.UpdateMealGroupsPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.MealGroupsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/superadmin/settings/meal-groups/stats": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns assignment counts for each configured meal group", + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/settings" + ], + "summary": "Get meal group stats (Super Admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.MealGroupStatsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/superadmin/settings/review-assignment-toggle": { "put": { "security": [ { @@ -3887,6 +4084,34 @@ const docTemplate = `{ "id": { "type": "string" }, + "last_name": { + "type": "string", + "minLength": 1 + }, + "level_of_study": { + "type": "string", + "minLength": 1 + }, + "linkedin": { + "type": "string" + }, + "major": { + "type": "string", + "minLength": 1 + }, + "meal_group": { + "type": "string" + }, + "opt_in_mlh_emails": { + "type": "boolean" + }, + "phone_e164": { + "type": "string" + }, + "race": { + "type": "string", + "minLength": 1 + }, "reject_votes": { "type": "integer" }, @@ -3967,6 +4192,32 @@ const docTemplate = `{ } } }, + "main.CreateScanResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meal_group": { + "type": "string" + }, + "scan_type": { + "type": "string" + }, + "scanned_at": { + "type": "string" + }, + "scanned_by": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, "main.CreateSchedulePayload": { "type": "object", "required": [ @@ -4028,6 +4279,28 @@ const docTemplate = `{ } } }, + "main.MealGroupStatsResponse": { + "type": "object", + "properties": { + "stats": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, + "main.MealGroupsResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "main.LogoUploadPayload": { "type": "object", "required": [ @@ -4355,6 +4628,21 @@ const docTemplate = `{ "main.UpdateApplicationPayload": { "type": "object" }, + "main.UpdateMealGroupsPayload": { + "type": "object", + "required": [ + "groups" + ], + "properties": { + "groups": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, "main.UpdateApplicationSchemaPayload": { "type": "object", "required": [ @@ -4488,6 +4776,34 @@ const docTemplate = `{ "id": { "type": "string" }, + "last_name": { + "type": "string", + "minLength": 1 + }, + "level_of_study": { + "type": "string", + "minLength": 1 + }, + "linkedin": { + "type": "string" + }, + "major": { + "type": "string", + "minLength": 1 + }, + "meal_group": { + "type": "string" + }, + "opt_in_mlh_emails": { + "type": "boolean" + }, + "phone_e164": { + "type": "string" + }, + "race": { + "type": "string", + "minLength": 1 + }, "reject_votes": { "type": "integer" }, @@ -4568,6 +4884,9 @@ const docTemplate = `{ "major": { "type": "string" }, + "meal_group": { + "type": "string" + }, "phone": { "type": "string" }, diff --git a/internal/store/applications.go b/internal/store/applications.go index 0f569a84..bfc2db48 100644 --- a/internal/store/applications.go +++ b/internal/store/applications.go @@ -79,6 +79,7 @@ type ApplicationListItem struct { ReviewsCompleted int `json:"reviews_completed"` AIPercent *int `json:"ai_percent"` HasResume bool `json:"has_resume"` + MealGroup *string `json:"meal_group"` } // ApplicationListResult contains paginated results @@ -152,6 +153,7 @@ type Application struct { SubmittedAt *time.Time `json:"submitted_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + MealGroup *string `json:"meal_group"` } type ApplicationsStore struct { @@ -366,7 +368,7 @@ func (s *ApplicationsStore) List( NULLIF(a.responses->>'hackathons_attended', '')::smallint AS hackathons_attended, a.submitted_at, a.created_at, a.updated_at, a.accept_votes, a.reject_votes, a.waitlist_votes, a.reviews_assigned, a.reviews_completed, a.ai_percent, - a.resume_path IS NOT NULL AS has_resume + a.resume_path IS NOT NULL AS has_resume, a.meal_group FROM applications a INNER JOIN users u ON a.user_id = u.id` @@ -461,7 +463,7 @@ func (s *ApplicationsStore) List( &item.HackathonsAttended, &item.SubmittedAt, &item.CreatedAt, &item.UpdatedAt, &item.AcceptVotes, &item.RejectVotes, &item.WaitlistVotes, &item.ReviewsAssigned, &item.ReviewsCompleted, &item.AIPercent, - &item.HasResume, + &item.HasResume, &item.MealGroup, ); err != nil { return nil, err } @@ -623,3 +625,43 @@ func (s *ApplicationsStore) GetEmailsByStatus(ctx context.Context, status Applic return users, rows.Err() } + +// SetMealGroup updates the meal group for a specific application +func (s *ApplicationsStore) SetMealGroup(ctx context.Context, id string, mealGroup string) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + UPDATE applications + SET meal_group = $2, updated_at = NOW() + WHERE id = $1 + ` + + result, err := s.db.ExecContext(ctx, query, id, mealGroup) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return ErrNotFound + } + + return nil +} + +// GetMealGroupByUserID returns the assigned meal group for a user +func (s *ApplicationsStore) GetMealGroupByUserID(ctx context.Context, userID string) (*string, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + var mealGroup *string + err := s.db.QueryRowContext(ctx, "SELECT meal_group FROM applications WHERE user_id = $1", userID).Scan(&mealGroup) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return mealGroup, err +} diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index 0469a60e..5c1c60c9 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -145,6 +145,19 @@ func (m *MockApplicationStore) GetEmailsByStatus(ctx context.Context, status App return args.Get(0).([]UserEmailInfo), args.Error(1) } +func (m *MockApplicationStore) SetMealGroup(ctx context.Context, id string, mealGroup string) error { + args := m.Called(id, mealGroup) + return args.Error(0) +} + +func (m *MockApplicationStore) GetMealGroupByUserID(ctx context.Context, userID string) (*string, error) { + args := m.Called(userID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*string), args.Error(1) +} + // mock implementation of the Settings interface type MockSettingsStore struct { mock.Mock @@ -235,6 +248,27 @@ func (m *MockSettingsStore) GetScanStats(ctx context.Context) (map[string]int, e return args.Get(0).(map[string]int), args.Error(1) } +func (m *MockSettingsStore) GetMealGroups(ctx context.Context) ([]string, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockSettingsStore) SetMealGroups(ctx context.Context, groups []string) error { + args := m.Called(groups) + return args.Error(0) +} + +func (m *MockSettingsStore) GetMealGroupStats(ctx context.Context) (map[string]int, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int), args.Error(1) +} + func (m *MockSettingsStore) GetApplicationsEnabled(ctx context.Context) (bool, error) { args := m.Called() return args.Bool(0), args.Error(1) diff --git a/internal/store/settings.go b/internal/store/settings.go index 52e79564..a9676bd0 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -19,6 +19,7 @@ const SettingsKeyScanTypes = "scan_types" const SettingsKeyScanStats = "scan_stats" const SettingsKeyAdminScheduleEditEnabled = "admin_schedule_edit_enabled" const SettingsKeyHackathonDateRange = "hackathon_date_range" +const SettingsKeyMealGroups = "meal_groups" const SettingsKeyApplicationsEnabled = "applications_enabled" type HackathonDateRange struct { @@ -509,6 +510,85 @@ func (s *SettingsStore) SetHackathonDateRange(ctx context.Context, dateRange Hac return err } +// GetMealGroups returns the configured list of meal group names (e.g., ["A", "B", "C", "D"]) +func (s *SettingsStore) GetMealGroups(ctx context.Context) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT value + FROM settings + WHERE key = $1 + ` + + var value []byte + err := s.db.QueryRowContext(ctx, query, SettingsKeyMealGroups).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []string{}, nil + } + return nil, err + } + + var groups []string + if err := json.Unmarshal(value, &groups); err != nil { + return nil, err + } + + return groups, nil +} + +// SetMealGroups updates the available meal group names +func (s *SettingsStore) SetMealGroups(ctx context.Context, groups []string) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + value, err := json.Marshal(groups) + if err != nil { + return err + } + + query := ` + INSERT INTO settings (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + ` + + _, err = s.db.ExecContext(ctx, query, SettingsKeyMealGroups, value) + return err +} + +// GetMealGroupStats returns the number of hackers assigned to each meal group +func (s *SettingsStore) GetMealGroupStats(ctx context.Context) (map[string]int, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT meal_group, COUNT(*) + FROM applications + WHERE meal_group IS NOT NULL + GROUP BY meal_group + ` + + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + stats := make(map[string]int) + for rows.Next() { + var group string + var count int + if err := rows.Scan(&group, &count); err != nil { + return nil, err + } + stats[group] = count + } + + return stats, nil +} + func (s *SettingsStore) GetApplicationsEnabled(ctx context.Context) (bool, error) { ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) defer cancel() diff --git a/internal/store/storage.go b/internal/store/storage.go index 7b396d48..24e3db09 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -37,6 +37,8 @@ type Storage struct { GetStats(ctx context.Context) (*ApplicationStats, error) SetStatus(ctx context.Context, id string, status ApplicationStatus) (*Application, error) GetEmailsByStatus(ctx context.Context, status ApplicationStatus) ([]UserEmailInfo, error) + SetMealGroup(ctx context.Context, id string, mealGroup string) error + GetMealGroupByUserID(ctx context.Context, userID string) (*string, error) } Settings interface { GetApplicationSchema(ctx context.Context) ([]ApplicationSchemaField, error) @@ -53,6 +55,9 @@ type Storage struct { GetScanTypes(ctx context.Context) ([]ScanType, error) UpdateScanTypes(ctx context.Context, scanTypes []ScanType) error GetScanStats(ctx context.Context) (map[string]int, error) + GetMealGroups(ctx context.Context) ([]string, error) + SetMealGroups(ctx context.Context, groups []string) error + GetMealGroupStats(ctx context.Context) (map[string]int, error) GetApplicationsEnabled(ctx context.Context) (bool, error) SetApplicationsEnabled(ctx context.Context, enabled bool) error }