diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..135388e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgres://localhost:5432/generic_typescript_server +PORT=3000 +DEBUG= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deed335 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e23670 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# generic_typescript_server + +Express + TypeScript REST API with PostgreSQL. + +## Setup + +```bash +cp .env.example .env +npm install +createdb generic_typescript_server # one-time, uses your local Postgres user +npm run db:migrate +npm run dev +``` + +Requires a local Postgres install (e.g. `brew install postgresql@16 && brew services start postgresql@16`). + +Update `DATABASE_URL` in `.env` if your user, host, or database name differs. diff --git a/migrations/001_create_users.sql b/migrations/001_create_users.sql new file mode 100644 index 0000000..1572bf5 --- /dev/null +++ b/migrations/001_create_users.sql @@ -0,0 +1,6 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index f232019..de58e4e 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -170,6 +170,18 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -933,6 +945,96 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.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.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "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.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "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/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -970,6 +1072,45 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "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/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -1408,6 +1549,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/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package-lock.json b/package-lock.json index 556cc63..0f12b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "pg": "^8.21.0", "pino": "^10.3.1" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", "ts-node": "^10.9.2", "tsx": "^4.22.1", "typescript": "^6.0.3" @@ -614,6 +616,18 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -1377,6 +1391,96 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.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.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "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.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "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/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -1414,6 +1518,45 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "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/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -1852,6 +1995,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/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 3375b47..cb6025d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "db:migrate": "tsx scripts/migrate.ts" }, "keywords": [], "author": "", @@ -15,12 +16,14 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "pg": "^8.21.0", "pino": "^10.3.1" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", "ts-node": "^10.9.2", "tsx": "^4.22.1", "typescript": "^6.0.3" diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..f8c6b87 --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,48 @@ +import 'dotenv/config'; +import fs from 'fs'; +import path from 'path'; +import { pool } from '../src/db/pool'; + +async function migrate() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + const migrationsDir = path.join(__dirname, '../migrations'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + + for (const file of files) { + const { rows } = await client.query( + 'SELECT 1 FROM schema_migrations WHERE name = $1', + [file], + ); + if (rows.length > 0) continue; + + const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8'); + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]); + await client.query('COMMIT'); + console.log(`Applied: ${file}`); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } + } finally { + client.release(); + await pool.end(); + } +} + +migrate().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/app.ts b/src/app.ts index f7e5809..430831b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,6 +2,7 @@ import express, { type Request, type Response, type NextFunction } from 'express import 'dotenv/config'; import { userRouter } from './routes/userRoutes'; import { errorHandler } from './middleware/errorHandler'; +import { checkConnection } from './db/pool'; import cors from 'cors'; import logger from './logger'; @@ -11,10 +12,6 @@ export function createApp() { app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); - app.use('/users', userRouter); - app.use(errorHandler); - - app.get('/health', (_req, res) => res.json({ status: 'ok' })); app.use((req: Request, res: Response, next: NextFunction) => { const start = Date.now(); @@ -29,5 +26,17 @@ export function createApp() { next(); }); + app.get('/health', async (_req, res) => { + const dbOk = await checkConnection(); + if (!dbOk) { + res.status(503).json({ status: 'error', db: 'disconnected' }); + return; + } + res.json({ status: 'ok', db: 'connected' }); + }); + + app.use('/users', userRouter); + app.use(errorHandler); + return app; -} \ No newline at end of file +} diff --git a/src/contollers/userController.ts b/src/contollers/userController.ts index d83d9bf..4ec3af8 100644 --- a/src/contollers/userController.ts +++ b/src/contollers/userController.ts @@ -3,36 +3,39 @@ import { userService } from '../services/userService'; export const userController = { - getAll(_req: Request, res: Response): void { - res.json(userService.getAll()); + async getAll(_req: Request, res: Response, next: NextFunction): Promise { + try { + const users = await userService.getAll(); + res.json(users); + } catch (err) { next(err); } }, - getById(req: Request, res: Response, next: NextFunction): void { + async getById(req: Request, res: Response, next: NextFunction): Promise { try { - const user = userService.getById(Number(req.params.id)); + const user = await userService.getById(Number(req.params.id)); res.json(user); } catch (err) { next(err); } }, - create(req: Request, res: Response, next: NextFunction): void { + async create(req: Request, res: Response, next: NextFunction): Promise { try { - const user = userService.create(req.body); + const user = await userService.create(req.body); res.status(201).json(user); } catch (err) { next(err); } }, - update(req: Request, res: Response, next: NextFunction): void { + async update(req: Request, res: Response, next: NextFunction): Promise { try { - const user = userService.update(Number(req.params.id), req.body); + const user = await userService.update(Number(req.params.id), req.body); res.json(user); } catch (err) { next(err); } }, - delete(req: Request, res: Response, next: NextFunction): void { + async delete(req: Request, res: Response, next: NextFunction): Promise { try { - userService.delete(Number(req.params.id)); + await userService.delete(Number(req.params.id)); res.status(204).send(); } catch (err) { next(err); } }, -}; \ No newline at end of file +}; diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..e40ca8d --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,36 @@ +import { Pool, type QueryResultRow } from 'pg'; + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required'); +} + +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +export async function query( + text: string, + params?: unknown[], +): Promise { + const result = await pool.query(text, params); + return result.rows; +} + +export async function queryOne( + text: string, + params?: unknown[], +): Promise { + const rows = await query(text, params); + return rows[0]; +} + +export async function checkConnection(): Promise { + try { + await pool.query('SELECT 1'); + return true; + } catch { + return false; + } +} + +export async function closePool(): Promise { + await pool.end(); +} diff --git a/src/index.ts b/src/index.ts index 3976c92..08420b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,35 @@ +import 'dotenv/config'; import { createApp } from './app'; +import { checkConnection, closePool } from './db/pool'; +import logger from './logger'; -const PORT = Number(process.env.PORT) || 3000; -const app = createApp(); +async function main() { + const connected = await checkConnection(); + if (!connected) { + logger.error('Failed to connect to database'); + process.exit(1); + } -const server = app.listen(PORT, () => { - console.log(`Server running on http://localhost:${PORT}`); -}); - -function shutdown(signal: string) { - console.log(`${signal} received — shutting down...`); - server.close(() => { - console.log('All connections closed. Exiting.'); - process.exit(0); + const PORT = Number(process.env.PORT) || 3000; + const app = createApp(); + const server = app.listen(PORT, () => { + logger.info(`Server running on http://localhost:${PORT}`); }); + + function shutdown(signal: string) { + logger.info(`${signal} received — shutting down...`); + server.close(async () => { + await closePool(); + logger.info('All connections closed. Exiting.'); + process.exit(0); + }); + } + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); } -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); \ No newline at end of file + +main().catch(err => { + logger.error(err); + process.exit(1); +}); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index de5b8ea..ccb240b 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,4 +1,6 @@ import { Request, Response, NextFunction } from 'express'; +import { DatabaseError } from 'pg'; +import logger from '../logger'; export class AppError extends Error { constructor(public statusCode: number, message: string) { @@ -11,12 +13,16 @@ export function errorHandler( err: unknown, _req: Request, res: Response, - _next: NextFunction + _next: NextFunction, ): void { if (err instanceof AppError) { res.status(err.statusCode).json({ error: err.message }); return; } - console.error(err); + if (err instanceof DatabaseError && err.code === '23505') { + res.status(409).json({ error: 'Email already exists' }); + return; + } + logger.error(err); res.status(500).json({ error: 'Internal server error' }); -} \ No newline at end of file +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 608679f..f69c9ed 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -1,41 +1,70 @@ import { User, CreateUserDto, UpdateUserDto } from '../models/User'; +import { query, queryOne } from '../db/pool'; + +interface UserRow { + id: number; + name: string; + email: string; + created_at: Date; +} + +function mapRowToUser(row: UserRow): User { + return { + id: row.id, + name: row.name, + email: row.email, + createdAt: row.created_at, + }; +} -const store: User[] = []; -let nextId = 1; export const userRepository = { - findAll(): User[] { - return [...store]; + async findAll(): Promise { + const rows = await query('SELECT * FROM users ORDER BY id'); + return rows.map(mapRowToUser); }, - findById(id: number): User | undefined { - return store.find(u => u.id === id); + async findById(id: number): Promise { + const row = await queryOne('SELECT * FROM users WHERE id = $1', [id]); + return row ? mapRowToUser(row) : undefined; }, - create(dto: CreateUserDto): User { - const user: User = { - id: nextId++, - name: dto.name, - email: dto.email, - createdAt: new Date(), - }; - store.push(user); - return user; + async create(dto: CreateUserDto): Promise { + const row = await queryOne( + 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *', + [dto.name, dto.email], + ); + return mapRowToUser(row!); }, - update(id: number, dto: UpdateUserDto): User | undefined { - const user = store.find(u => u.id === id); - if (!user) return undefined; - if (dto.name) user.name = dto.name; - if (dto.email) user.email = dto.email; - return user; + async update(id: number, dto: UpdateUserDto): Promise { + const sets: string[] = []; + const params: unknown[] = [id]; + let i = 2; + + if (dto.name) { + sets.push(`name = $${i++}`); + params.push(dto.name); + } + if (dto.email) { + sets.push(`email = $${i++}`); + params.push(dto.email); + } + if (sets.length === 0) return this.findById(id); + + const row = await queryOne( + `UPDATE users SET ${sets.join(', ')} WHERE id = $1 RETURNING *`, + params, + ); + return row ? mapRowToUser(row) : undefined; }, - delete(id: number): boolean { - const idx = store.findIndex(u => u.id === id); - if (idx === -1) return false; - store.splice(idx, 1); - return true; + async delete(id: number): Promise { + const row = await queryOne<{ id: number }>( + 'DELETE FROM users WHERE id = $1 RETURNING id', + [id], + ); + return row !== undefined; }, -}; \ No newline at end of file +}; diff --git a/src/services/userService.ts b/src/services/userService.ts index 3f3cc02..5929b72 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,28 +1,32 @@ import { userRepository } from '../repositories/userRepository'; import { CreateUserDto, UpdateUserDto, User } from '../models/User'; import { AppError } from '../middleware/errorHandler'; + export const userService = { - getAll(): User[] { + async getAll(): Promise { return userRepository.findAll(); }, - getById(id: number): User { - const user = userRepository.findById(id); + + async getById(id: number): Promise { + const user = await userRepository.findById(id); if (!user) throw new AppError(404, `User ${id} not found`); return user; }, - create(dto: CreateUserDto): User { + + async create(dto: CreateUserDto): Promise { if (!dto.name?.trim()) throw new AppError(400, 'Name is required'); if (!dto.email?.trim()) throw new AppError(400, 'Email is required'); - // In a real app: check email uniqueness, hash password, etc. return userRepository.create(dto); }, - update(id: number, dto: UpdateUserDto): User { - const updated = userRepository.update(id, dto); + + async update(id: number, dto: UpdateUserDto): Promise { + const updated = await userRepository.update(id, dto); if (!updated) throw new AppError(404, `User ${id} not found`); return updated; }, - delete(id: number): void { - const deleted = userRepository.delete(id); + + async delete(id: number): Promise { + const deleted = await userRepository.delete(id); if (!deleted) throw new AppError(404, `User ${id} not found`); }, -}; \ No newline at end of file +}; diff --git a/tsconfig.json b/tsconfig.json index 64a63a5..400df42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,11 @@ { -"compilerOptions": { -"target": "ES2022", -"module": "commonjs", -"rootDir": "./src", -"outDir": "./dist", -"strict": true, -"esModuleInterop": true + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "rootDir": "./src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true + }, + "include": ["src"] }