Compare commits

7 Commits
Author SHA1 Message Date
cody 2b6644e0a4 add login form 2026-02-24 20:07:20 -05:00
cody 0d5cc4d21f add auth to backend and frontend 2026-02-24 10:42:36 -05:00
cody 11eb192e2a add auth middleware 2026-02-23 16:39:04 -05:00
cody af9c23c521 start implementing auth 2026-02-23 14:34:19 -05:00
cody 715c122eb7 implement atlas for db migration 2026-02-22 15:06:16 -07:00
cody 09804829bd add postgres docker compose for local dev 2026-02-22 15:05:48 -07:00
cody f83a8b0697 update git ignore 2026-02-22 15:05:22 -07:00
27 changed files with 878 additions and 75 deletions
+10
View File
@@ -0,0 +1,10 @@
export OPENWEATHERMAP_API_KEY=b553614cb0047997aa62ea65b5f75fbf
export WEATHER_CITY=Salem
export DB_HOST=localhost
export DB_PORT=5433
export DB_NAME=go_weather
export DB_USER=postgres
export DB_PASSWORD=postgres
export JWT_SECRET=helloiamajwtsecretmyguy
+1
View File
@@ -1,3 +1,4 @@
.env
.dockerenv
node_modules
tmp
+11
View File
@@ -0,0 +1,11 @@
env "local" {
src = "file://migrations/schema.sql"
url = "postgres://postgres:postgres@localhost:5433/go_weather?sslmode=disable"
dev = "docker://postgres/16/alpine/dev" # for schema validation
}
env "production" {
src = "file://migrations/schema.sql"
url = "postgres://${env("DB_USER")}:${env("DB_PASSWORD")}@${env("DB_HOST")}:${env("DB_PORT")}/${env("DB_NAME")}?sslmode=require"
dev = "docker://postgres/16/alpine/dev"
}
+139
View File
@@ -6,9 +6,34 @@
"dependencies": {
"recharts": "^3.7.0",
},
"devDependencies": {
"eslint": "^10.0.2",
},
},
},
"packages": {
"@eslint-community/eslint-utils": ["@eslint-community/[email protected]", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/[email protected]", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/object-schema": "^3.0.2", "debug": "^4.3.1", "minimatch": "^10.2.1" } }, "sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A=="],
"@eslint/config-helpers": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="],
"@eslint/core": ["@eslint/[email protected]", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
"@eslint/object-schema": ["@eslint/[email protected]", "", {}, "sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw=="],
"@eslint/plugin-kit": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="],
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/[email protected]", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/[email protected]", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@reduxjs/toolkit": ["@reduxjs/[email protected]", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -33,10 +58,28 @@
"@types/d3-timer": ["@types/[email protected]", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/esrecurse": ["@types/[email protected]", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/[email protected]", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/use-sync-external-store": ["@types/[email protected]", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
"clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"d3-array": ["[email protected]", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["[email protected]", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
@@ -59,16 +102,96 @@
"d3-timer": ["[email protected]", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["[email protected]", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["[email protected]", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"es-toolkit": ["[email protected]", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["[email protected]", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.2", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.1", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw=="],
"eslint-scope": ["[email protected]", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw=="],
"eslint-visitor-keys": ["[email protected]", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ=="],
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["[email protected]", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["[email protected]", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["[email protected]", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["[email protected]", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"file-entry-cache": ["[email protected]", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["[email protected]", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["[email protected]", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["[email protected]", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"ignore": ["[email protected]", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["[email protected]", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"imurmurhash": ["[email protected]", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internmap": ["[email protected]", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"json-buffer": ["[email protected]", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["[email protected]", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["[email protected]", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["[email protected]", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-exists": ["[email protected]", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"prelude-ls": ["[email protected]", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
@@ -87,12 +210,28 @@
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"tiny-invariant": ["[email protected]", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["[email protected]", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["[email protected]", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@reduxjs/toolkit/immer": ["[email protected]", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
}
}
+2
View File
@@ -13,6 +13,7 @@ type Config struct {
DBUser string
DBPassword string
DBName string
JWTSecret string
}
func GetEnv(key string) string {
@@ -32,5 +33,6 @@ func Load() *Config {
DBUser: GetEnv("DB_USER"),
DBPassword: GetEnv("DB_PASSWORD"),
DBName: GetEnv("DB_NAME"),
JWTSecret: GetEnv("JWT_SECRET"),
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.2.3",
"@types/react": "^19.2.7",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
+3
View File
@@ -19,5 +19,8 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'react-refresh/only-export-components': 'off',
},
},
])
+1 -1
View File
@@ -25,7 +25,7 @@
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.2.3",
"@types/react": "^19.2.7",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
+26 -4
View File
@@ -3,23 +3,45 @@ import { ThemeProvider } from "@/components/theme-provider"
import LatestTemperature from "@/components/latest-temperature"
import TemperatureHistory from "@/components/temperature-history"
import TemperaturePlot from "@/components/temperature-plot"
// import { Button } from "@/components/ui/button"
import RegisterForm from './components/register-form'
import { AuthProvider } from './contexts/auth-context'
import { useAuth } from './hooks/use-auth'
import UserMenu from './components/user-menu'
import LoginForm from './components/login-form'
function AppContent() {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return <div className="p-6">Loading...</div>
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<main className="min-h-screen bg-background p-6 w-full">
{isAuthenticated ? <UserMenu /> :
<div className="flex">
<LoginForm />
<RegisterForm />
</div>
}
<div className="w-full mx-auto space-y-2">
<TemperaturePlot />
<div className="grid grid-cols-4 gap-6">
<LatestTemperature />
<TemperatureHistory />
{isAuthenticated && <TemperatureHistory />}
</div>
</div>
</main>
)
}
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<AuthProvider>
<AppContent />
</AuthProvider>
</ThemeProvider>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from "react"
import { useAuth } from "@/hooks/use-auth"
import { Button } from "./ui/button"
export default function LoginForm() {
const { login, isLoading } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [formError, setFormError] = useState<string | null>("")
async function handleSubmit(e: React.SubmitEvent) {
e.preventDefault()
setFormError(null)
try {
await login(email, password)
}
catch (err) {
setFormError(err instanceof Error ? err.message : "Login failed")
}
}
return (
<form onSubmit={handleSubmit} className="p-4 m-6 flex flex-col gap-3 border rounded-lg bg-card w-80">
<h2 className="text-lg font-sembibold">Login</h2>
{formError && <p className="text-red-500 text-sm">{formError}</p>}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<Button type="submit" className="w-full">
{isLoading ? "Logging in..." : "Login"}
</Button>
</form>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
export default function RegisterForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.SubmitEvent) {
e.preventDefault()
setError(null)
setSuccess(false)
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 8) {
setError("Passwords must be at least 8 characters")
return
}
setLoading(true)
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const msg = await res.text()
setError(msg || "Registration failed")
return
}
setSuccess(true)
setEmail("")
setPassword("")
setConfirmPassword("")
}
catch {
setError("Network error")
}
finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="p-4 m-6 flex flex-col gap-3 border rounded-lg bg-card w-80">
<h2>Register</h2>
{error && <p>{error}</p>}
{success && <p>Account created</p>}
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<input
type="password"
placeholder="Confirm password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="p-2 border rounded bg-background"
/>
<Button type="submit" className="w-full">
{loading ? "Creating ..." : "Register"}
</Button>
</form>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { useAuth } from "@/hooks/use-auth";
import { Button } from "./ui/button";
export default function UserMenu() {
const { user, logout } = useAuth()
return (
<div className="p-4 m-6 flex items-center gap-4 border rounded-lg bg-card">
<span className="text-sm">{user?.email}</span>
<Button onClick={logout} varient="outline" size="sm">
Logout
</Button>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import {
createContext,
useState,
useEffect,
} from "react"
import type { ReactNode } from "react"
type User = {
id: number
email: string
create_at: string
}
type AuthContextType = {
user: User | null
isAuthenticated: boolean
login: (email: string, passwor: string) => Promise<void>
logout: () => void
isLoading: boolean
}
export const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
checkAuth()
}, [])
async function checkAuth() {
try {
const res = await fetch("/api/me", {
credentials: "include",
})
const data = await res.json()
// api response will be null if user is not authenticated
setUser(data.user)
}
catch {
setUser(null)
}
finally {
setIsLoading(false)
}
}
async function login(email: string, password: string) {
const res = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
})
if (!res.ok) {
const msg = await res.text()
throw new Error(msg || "Invalid credentials")
}
const data = await res.json()
setUser(data.user)
}
async function logout() {
await fetch("api/logout", {
method: "POST",
credentials: "include",
})
setUser(null)
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
)
}
+8
View File
@@ -0,0 +1,8 @@
import { AuthContext } from "@/contexts/auth-context"
import { useContext } from "react"
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error("useAuth must be used within AuthProvider")
return ctx
}
-18
View File
@@ -72,24 +72,6 @@ h1 {
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
+5 -1
View File
@@ -2,4 +2,8 @@ module go-weather
go 1.25.6
require github.com/lib/pq v1.11.1
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/lib/pq v1.11.1
golang.org/x/crypto v0.48.0
)
+4
View File
@@ -1,2 +1,6 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+142
View File
@@ -0,0 +1,142 @@
package handlers
import (
"encoding/json"
"go-weather/middleware"
"go-weather/models"
"go-weather/services"
"log/slog"
"net/http"
"strings"
)
type UserHandler struct {
userService *services.UserService
authService *services.AuthService
}
func NewUserHandler(us *services.UserService, as *services.AuthService) *UserHandler {
return &UserHandler{userService: us, authService: as}
}
func (h *UserHandler) Me(w http.ResponseWriter, r *http.Request) {
claims := middleware.GetClaims(r)
w.Header().Set("Content-Type", "application/json")
if claims == nil {
json.NewEncoder(w).Encode(map[string]any{"user": nil})
return
}
user, err := h.userService.GetUserByID(claims.UserID)
if err != nil {
json.NewEncoder(w).Encode(map[string]any{"user": nil})
return
}
json.NewEncoder(w).Encode(map[string]any{"user": user})
}
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req models.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || req.Password == "" {
http.Error(w, "email and password are required", http.StatusBadRequest)
return
}
if len(req.Password) < 4 {
http.Error(w, "password must be at least 4 characters", http.StatusBadRequest)
return
}
exists, err := h.userService.EmailExists(req.Email)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if exists {
http.Error(w, "email already registered", http.StatusConflict)
return
}
user, err := h.userService.CreateUser(req.Email, req.Password)
if err != nil {
http.Error(w, "Failed to create user", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req models.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
user, err := h.userService.GetUserByEmail(req.Email)
if err != nil || user == nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if !h.userService.ValidatePassword(user, req.Password) {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
token, err := h.authService.GenerateToken(user.ID, user.Email)
if err != nil {
slog.Error("failed to generate token", "user_id", user.ID, "error", err)
http.Error(w, "failed to generate token", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true, // HTTPS only
SameSite: http.SameSiteStrictMode,
MaxAge: 86400, // 24 hours
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.LoginResponse{User: *user})
}
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "token",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
MaxAge: -1,
})
w.WriteHeader(http.StatusOK)
}
+23 -8
View File
@@ -3,15 +3,18 @@ package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
"go-weather/config"
"go-weather/handlers"
"go-weather/middleware"
"go-weather/services"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
_ "github.com/lib/pq"
)
func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) {
@@ -47,6 +50,11 @@ func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, in
}
func main() {
config := config.Load()
// Setup logger
var handler slog.Handler
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
slog.SetDefault(slog.New(handler))
// Connect to DB
connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
@@ -58,28 +66,35 @@ func main() {
log.Fatalf("failed to connect to db: %v", err)
}
defer database.Close()
databaseService := services.NewDatabaseService(database)
// Setup weather_readings table
if err := databaseService.CreateTable(); err != nil {
log.Fatalf("failed to create table: %v", err)
}
// Create services
databaseService := services.NewDatabaseService(database)
weatherService := services.NewWeatherService(config.APIKey)
userService := services.NewUserService(database)
authService := services.NewAuthService(config.JWTSecret)
// Create route handlers
weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
userHandler := handlers.NewUserHandler(userService, authService)
// Start background poller
go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
// Register routes
http.HandleFunc("/weather/fetch", weatherHandler.FetchWeather)
http.HandleFunc("/login", userHandler.Login)
http.HandleFunc("/logout", userHandler.Logout)
http.Handle("/weather/fetch", middleware.AuthMiddleware(authService)(
http.HandlerFunc(weatherHandler.FetchWeather)),
)
http.Handle("/me", http.HandlerFunc(userHandler.Me))
http.HandleFunc("/weather/history", weatherHandler.GetHistory)
http.HandleFunc("/health", handlers.Health)
http.HandleFunc("/latest-temperature", handlers.LatestTemperature)
http.HandleFunc("/test", handlers.Test)
http.HandleFunc("/register", userHandler.Register)
// Start server
go func() {
fmt.Println("server listening on :8080")
+39
View File
@@ -0,0 +1,39 @@
package middleware
import (
"context"
"go-weather/services"
"net/http"
)
type contextKey string
const ClaimsKey contextKey = "claims"
func AuthMiddleware(authService *services.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("token")
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, err := authService.ValidateToken(cookie.Value)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetClaims(r *http.Request) *services.Claims {
if claims, ok := r.Context().Value(ClaimsKey).(*services.Claims); ok {
return claims
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS weather_readings (
id SERIAL PRIMARY KEY,
city TEXT NOT NULL,
temp DOUBLE PRECISION NOT NULL,
feels_like DOUBLE PRECISION NOT NULL,
humidity INTEGER NOT NULL,
conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
+26
View File
@@ -0,0 +1,26 @@
package models
import (
"time"
)
type User struct {
ID int `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
User User `json:"user"`
}
+3
View File
@@ -1,5 +1,8 @@
{
"dependencies": {
"recharts": "^3.7.0"
},
"devDependencies": {
"eslint": "^10.0.2"
}
}
+23
View File
@@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16-alpine
container_name: postgres-dev
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: go_weather
ports:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
cloudbeaver:
image: dbeaver/cloudbeaver:latest
container_name: cloudbeaver-dev
ports:
- "8978:8978"
depends_on:
- postgres
volumes:
pgdata:
+57
View File
@@ -0,0 +1,57 @@
package services
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
type AuthService struct {
secretKey []byte
}
func NewAuthService(secret string) *AuthService {
return &AuthService{secretKey: []byte(secret)}
}
func (s *AuthService) GenerateToken(userID int, email string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secretKey)
}
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.secretKey, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
-21
View File
@@ -5,7 +5,6 @@ import (
"fmt"
_ "github.com/lib/pq"
"go-weather/models"
"log"
"time"
)
@@ -32,26 +31,6 @@ func NewDatabaseService(db *sql.DB) *DatabaseService {
return &DatabaseService{db: db}
}
func (s *DatabaseService) CreateTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS weather_readings (
id SERIAL PRIMARY KEY,
city TEXT NOT NULL,
temp DOUBLE PRECISION NOT NULL,
feels_like DOUBLE PRECISION NOT NULL,
humidity INTEGER NOT NULL,
conditions TEXT NOT NULL,
fetched_at TIMESTAMP WITH TIME ZONE NOT NULL
)
`)
if err != nil {
log.Printf("failed to create table: %v", err)
}
fmt.Println("weather_readings table ready")
return nil
}
func (s *DatabaseService) SaveReading(weather models.WeatherResponse) (int, error) {
conditions := ""
if len(weather.Weather) > 0 {
+80
View File
@@ -0,0 +1,80 @@
package services
import (
"database/sql"
"go-weather/models"
"time"
"golang.org/x/crypto/bcrypt"
)
type UserService struct {
db *sql.DB
}
func NewUserService(db *sql.DB) *UserService {
return &UserService{db: db}
}
func (s *UserService) GetUserByID(id int) (*models.User, error) {
var user models.User
err := s.db.QueryRow(
`SELECT id, email, created_at FROM users WHERE id = $1`, id,
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) CreateUser(email string, password string) (*models.User, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
var user models.User
err = s.db.QueryRow(
`INSERT INTO users (email, password_hash, created_at)
VALUES ($1, $2, $3)
RETURNING id, email, created_at`,
email, string(hashedPassword), time.Now(),
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
var user models.User
err := s.db.QueryRow(
`SELECT id, email, password_hash, created_at
FROM users
WHERE email = $1`, email,
).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt)
if err != nil {
return nil, nil
}
return &user, nil
}
func (s *UserService) EmailExists(email string) (bool, error) {
var exists bool
err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists)
return exists, err
}
func (s *UserService) ValidatePassword(user *models.User, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
return err == nil
}