Added postgres DB, models. Updated services for ansync DB operations, etc

This commit is contained in:
KS Jannette
2026-05-18 18:03:01 -04:00
parent 5d32d5c537
commit cbb76fd72d
17 changed files with 600 additions and 77 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgres://app:app@localhost:5432/app
PORT=3000
DEBUG=

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.env

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# generic_typescript_server
Express + TypeScript REST API with PostgreSQL.
## Setup
```bash
cp .env.example .env
npm install
```
## Database
Start Postgres:
```bash
npm run db:up
```
Run migrations:
```bash
npm run db:migrate
```
Stop Postgres:
```bash
npm run db:down
```
## Dev server
```bash
npm run dev
```

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
postgres:
image: postgres:16
ports:
- "5432:5432"
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

View File

@@ -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()
);

150
node_modules/.package-lock.json generated vendored
View File

@@ -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",

152
package-lock.json generated
View File

@@ -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",

View File

@@ -5,7 +5,10 @@
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx src/index.ts"
"dev": "tsx src/index.ts",
"db:up": "docker compose up -d",
"db:down": "docker compose down",
"db:migrate": "tsx scripts/migrate.ts"
},
"keywords": [],
"author": "",
@@ -15,12 +18,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"

48
scripts/migrate.ts Normal file
View File

@@ -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);
});

View File

@@ -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;
}
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
try {
userService.delete(Number(req.params.id));
await userService.delete(Number(req.params.id));
res.status(204).send();
} catch (err) { next(err); }
},
};
};

36
src/db/pool.ts Normal file
View File

@@ -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<T extends QueryResultRow>(
text: string,
params?: unknown[],
): Promise<T[]> {
const result = await pool.query<T>(text, params);
return result.rows;
}
export async function queryOne<T extends QueryResultRow>(
text: string,
params?: unknown[],
): Promise<T | undefined> {
const rows = await query<T>(text, params);
return rows[0];
}
export async function checkConnection(): Promise<boolean> {
try {
await pool.query('SELECT 1');
return true;
} catch {
return false;
}
}
export async function closePool(): Promise<void> {
await pool.end();
}

View File

@@ -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'));
main().catch(err => {
logger.error(err);
process.exit(1);
});

View File

@@ -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' });
}
}

View File

@@ -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<User[]> {
const rows = await query<UserRow>('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<User | undefined> {
const row = await queryOne<UserRow>('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<User> {
const row = await queryOne<UserRow>(
'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<User | undefined> {
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<UserRow>(
`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<boolean> {
const row = await queryOne<{ id: number }>(
'DELETE FROM users WHERE id = $1 RETURNING id',
[id],
);
return row !== undefined;
},
};
};

View File

@@ -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<User[]> {
return userRepository.findAll();
},
getById(id: number): User {
const user = userRepository.findById(id);
async getById(id: number): Promise<User> {
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<User> {
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<User> {
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<void> {
const deleted = await userRepository.delete(id);
if (!deleted) throw new AppError(404, `User ${id} not found`);
},
};
};

View File

@@ -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"]
}