From 8237c6cd2ae7936fc4466ee6fd48f372500f1226 Mon Sep 17 00:00:00 2001 From: Noah Holt Date: Tue, 11 Aug 2026 00:15:40 -0500 Subject: [PATCH 1/6] feat: add sequelize with postgres UTC timestamp endpoint --- .env.example | 15 + package-lock.json | 339 +++++++++++++++++- package.json | 7 +- src/config/database.ts | 26 ++ src/index.test.ts | 36 +- src/index.ts | 10 + .../001_create_utc_timestamp_function.sql | 9 + src/services/database.ts | 15 + 8 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 src/config/database.ts create mode 100644 src/migrations/001_create_utc_timestamp_function.sql create mode 100644 src/services/database.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b585c71 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# PostgreSQL Database Configuration +# Either DATABASE_URL or individual DB_* variables + +# Full connection URL (takes precedence) +DATABASE_URL=postgresql://user:password@localhost:5432/dbname + +# Or individual variables +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=dbname +DB_USER=user +DB_PASSWORD=password + +# Enable SQL query logging +DB_LOGGING=false diff --git a/package-lock.json b/package-lock.json index bfda689..45be185 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "demo-app", "version": "1.0.0", "dependencies": { - "express": "^5.2.0" + "express": "^5.2.0", + "pg": "^8.23.0", + "pg-hstore": "^2.3.4", + "sequelize": "^6.37.8" }, "devDependencies": { "@commitlint/cli": "^19.5.0", @@ -902,6 +905,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -962,11 +974,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1031,6 +1048,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.67.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", @@ -1994,6 +2017,13 @@ "node": ">=8" } }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2987,6 +3017,15 @@ "node": ">=0.8.19" } }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3503,6 +3542,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -3694,6 +3739,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3910,6 +3976,107 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-hstore": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz", + "integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==", + "license": "MIT", + "dependencies": { + "underscore": "^1.13.1" + }, + "engines": { + "node": ">= 0.8.x" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3959,6 +4126,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4082,6 +4288,12 @@ "node": ">=8" } }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", @@ -4141,7 +4353,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4176,6 +4387,77 @@ "url": "https://opencollective.com/express" } }, + "node_modules/sequelize": { + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -4317,7 +4599,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -4509,6 +4790,12 @@ "node": ">=0.6" } }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4604,11 +4891,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -4643,6 +4935,25 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4853,6 +5164,15 @@ "node": ">=8" } }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4887,6 +5207,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 59e4c8a..3af92be 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "tsc", "start": "node dist/bin/server.js", "test": "vitest run", + "test:integration": "DB_HOST=localhost DB_NAME=test DB_USER=test DB_PASSWORD=test vitest run --config vitest.integration.config.ts", + "migrate": "psql $DATABASE_URL -f src/migrations/001_create_utc_timestamp_function.sql", "lint": "eslint . && tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check ." @@ -16,7 +18,10 @@ "node": ">=22" }, "dependencies": { - "express": "^5.2.0" + "express": "^5.2.0", + "pg": "^8.23.0", + "pg-hstore": "^2.3.4", + "sequelize": "^6.37.8" }, "devDependencies": { "@commitlint/cli": "^19.5.0", diff --git a/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..8a683c8 --- /dev/null +++ b/src/config/database.ts @@ -0,0 +1,26 @@ +import { Sequelize } from "sequelize"; + +const dbUrl = process.env.DATABASE_URL; +const dbHost = process.env.DB_HOST; +const dbPort = Number(process.env.DB_PORT ?? 5432); +const dbName = process.env.DB_NAME; +const dbUser = process.env.DB_USER; +const dbPassword = process.env.DB_PASSWORD; + +let sequelize: Sequelize | null = null; + +if (dbUrl) { + sequelize = new Sequelize(dbUrl, { + dialect: "postgres", + logging: process.env.DB_LOGGING === "true" ? console.log : false, + }); +} else if (dbHost && dbName && dbUser) { + sequelize = new Sequelize(dbName, dbUser, dbPassword, { + host: dbHost, + port: dbPort, + dialect: "postgres", + logging: process.env.DB_LOGGING === "true" ? console.log : false, + }); +} + +export { sequelize }; diff --git a/src/index.test.ts b/src/index.test.ts index 611f603..070e90f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; import { app } from "./index.js"; import { greeting } from "./greeting.js"; +import * as db from "./services/database.js"; + +const getUtcTimestampSpy = vi.spyOn(db, "getUtcTimestamp"); + +beforeEach(() => { + getUtcTimestampSpy.mockReset(); +}); describe("greeting", () => { it("mentions the homelab", () => { @@ -31,3 +38,30 @@ describe("GET /", () => { }); }); }); + +describe("GET /utc-time", () => { + it("returns 503 when database is not configured", async () => { + getUtcTimestampSpy.mockResolvedValueOnce(null); + + const res = await request(app).get("/utc-time"); + expect(res.status).toBe(503); + expect(res.body).toHaveProperty("error"); + }); + + it("returns UTC timestamp when database is available", async () => { + const mockTime = "2026-01-01T00:00:00.000Z"; + getUtcTimestampSpy.mockResolvedValueOnce(mockTime); + + const res = await request(app).get("/utc-time"); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("utc_time", mockTime); + }); + + it("returns 503 when database query fails", async () => { + getUtcTimestampSpy.mockResolvedValueOnce(null); + + const res = await request(app).get("/utc-time"); + expect(res.status).toBe(503); + expect(res.body).toHaveProperty("error"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2af5705..0db612e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import express from "express"; import { hostname } from "node:os"; import { greeting } from "./greeting.js"; +import { getUtcTimestamp } from "./services/database.js"; const app = express(); const port = Number(process.env.PORT ?? 3000); @@ -20,4 +21,13 @@ app.get("/", (_req, res) => { }); }); +app.get("/utc-time", async (_req, res) => { + const utcTime = await getUtcTimestamp(); + if (!utcTime) { + res.status(503).json({ error: "Database not configured or unavailable" }); + return; + } + res.json({ utc_time: utcTime }); +}); + export { app, port }; diff --git a/src/migrations/001_create_utc_timestamp_function.sql b/src/migrations/001_create_utc_timestamp_function.sql new file mode 100644 index 0000000..4546e09 --- /dev/null +++ b/src/migrations/001_create_utc_timestamp_function.sql @@ -0,0 +1,9 @@ +-- Migration: Create stored procedure to get UTC timestamp +-- Run: psql -f 001_create_utc_timestamp_function.sql + +CREATE OR REPLACE FUNCTION get_utc_timestamp() +RETURNS TIMESTAMP WITH TIME ZONE AS $$ +BEGIN + RETURN NOW(); +END; +$$ LANGUAGE plpgsql; diff --git a/src/services/database.ts b/src/services/database.ts new file mode 100644 index 0000000..5de5a3d --- /dev/null +++ b/src/services/database.ts @@ -0,0 +1,15 @@ +import { sequelize } from "../config/database.js"; + +export async function getUtcTimestamp(): Promise { + if (!sequelize) { + return null; + } + + try { + const [results] = await sequelize.query("SELECT NOW() AS utc_time"); + const rows = results as { utc_time: string }[]; + return rows[0]?.utc_time ?? null; + } catch { + return null; + } +} -- 2.51.2 From 472b8cd1c6d7111c71a04222b2649e9140f36741 Mon Sep 17 00:00:00 2001 From: Noah Holt Date: Tue, 11 Aug 2026 00:23:32 -0500 Subject: [PATCH 2/6] Add PR workflow trigger --- .github/workflows/pr.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..147acb1 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,14 @@ +name: PR Workflow + +on: + pull_request: + branches: + - '**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: echo "PR workflow running" -- 2.51.2 From 21cdccb51f5c37205670ee17d1be2002f9941645 Mon Sep 17 00:00:00 2001 From: Noah Holt Date: Tue, 11 Aug 2026 00:26:46 -0500 Subject: [PATCH 3/6] Update PR workflow with explicit triggers --- .github/workflows/pr.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 147acb1..eafb309 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,6 +2,10 @@ name: PR Workflow on: pull_request: + types: + - opened + - synchronize + - reopened branches: - '**' -- 2.51.2 From 74e0c13412020a00f16b8320797a664d54e013e8 Mon Sep 17 00:00:00 2001 From: Noah Holt Date: Tue, 11 Aug 2026 00:27:10 -0500 Subject: [PATCH 4/6] Add push trigger for feature branches --- .github/workflows/pr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index eafb309..a696eec 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,6 +8,9 @@ on: - reopened branches: - '**' + push: + branches: + - 'feat/**' jobs: test: -- 2.51.2 From e471e297ce43640ff0291439c6af8036e041f15a Mon Sep 17 00:00:00 2001 From: Noah Holt Date: Tue, 11 Aug 2026 00:27:28 -0500 Subject: [PATCH 5/6] Update push trigger to all branches --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a696eec..0843a2f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,7 +10,7 @@ on: - '**' push: branches: - - 'feat/**' + - '**' jobs: test: -- 2.51.2 From cb822cfecfedaa20154129331e406adf00b9ab68 Mon Sep 17 00:00:00 2001 From: Noah Holt Date: Tue, 11 Aug 2026 00:27:51 -0500 Subject: [PATCH 6/6] Exclude main/master from push triggers --- .github/workflows/pr.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0843a2f..67b1207 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,6 +11,8 @@ on: push: branches: - '**' + - '!main' + - '!master' jobs: test: -- 2.51.2