diff --git a/.env.sample b/.env.sample index a5e05e8..4c1bf72 100644 --- a/.env.sample +++ b/.env.sample @@ -25,4 +25,12 @@ NODE_ENV=test # Default methods: GET, POST, PUT, DELETE ALLOWED_ORIGINS= ALLOWED_METHODS= -ALLOWED_HEADERS= \ No newline at end of file +ALLOWED_HEADERS= + +# === JWT Configuration === +# JWT (JSON Web Token) secret key for signing tokens +# A cryptographically secure secret used to sign and verify JSON Web Tokens (JWTs). +# This is required for authentication to work correctly. +# šŸ” Use a long, random string—at least 32 characters, ideally generated using a password manager or Node.js: +# $ node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" +JWT_SECRET= \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5c5bb8f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +.github \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..204110e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid" +} diff --git a/.vscode/settings.json b/.vscode/settings.json index f4e35a9..5294c53 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { - "accessibility.signals.chatRequestSent": { - "sound": "off", - "announcement": "off" - } -} \ No newline at end of file + "accessibility.signals.chatRequestSent": { + "sound": "off", + "announcement": "off" + } +} diff --git a/README.md b/README.md index 76fe8b5..acb2acb 100755 --- a/README.md +++ b/README.md @@ -1,29 +1,45 @@ # Node.js and Express Backend + [![Backend API - CI Tests](https://github.com/pakeku/backend-api/actions/workflows/tests.yml/badge.svg)](https://github.com/pakeku/backend-api/actions/workflows/tests.yml) [![Known Vulnerabilities](https://snyk.io/test/github/pakeku/backend-api/badge.svg)](https://snyk.io/test/github/pakeku/backend-api) ## Requirements + Identify your MongoDB URL. Visit MongoDB to sign up and get started. Environmental Variables: + 1. MONGO_URL 2. PORT (optional) 3. ALLOWED_ORIGINS (optional) 4. ALLOWED_METHODS (optional) 5. ALLOWED_HEADERS (optional) -6. NODE_ENV=test --- When set to ***"test"***, a `mongodb-memory-server` test URI is used, and no `MONGO_URL` is required. This allows for out-of-the-box testing without a live database. +6. NODE\*ENV=test --- When set to \*\*\*"test"\_\*\*, a `mongodb-memory-server` test URI is used, and no `MONGO_URL` is required. This allows for out-of-the-box testing without a live database. +7. JWT_SECRET --- A cryptographically secure secret used to sign and verify JSON Web Tokens (JWTs). This is required for authentication to work correctly. + Use a long, random string—at least 32 characters, ideally generated using a password manager or Node.js: `bash node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"` +8. ## Getting Started + 1. Copy this file to .env and fill in the actual values -```bash + +```bash cp .env.sample .env ``` 1. Run a script: -```json + +```json "scripts": { + "prebuild":"rm -rf dist", + "build":"tsc", "start": "node ./src/index.js", - "dev": "env-cmd nodemon ./src/index.js", - "test": "jest" + "dev": "env-cmd nodemon ./src/index.ts", + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts --no-ignore", + "format": "prettier --write ." } -``` \ No newline at end of file +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..56a2c19 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +// @ts-check + +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import perfectionist from 'eslint-plugin-perfectionist'; + +export default tseslint.config( + { + ignores: ['**/*.js'], + }, + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + perfectionist.configs['recommended-natural'] +); diff --git a/jest.config.js b/jest.config.js index 9fc603e..7c534e1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ export default { - testEnvironment: "node", + testEnvironment: 'node', transform: { - "^.+\.tsx?$": ["ts-jest",{}], + '^.+\.tsx?$': ['ts-jest', {}], }, -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index 34686b5..d325ae9 100755 --- a/package.json +++ b/package.json @@ -4,11 +4,21 @@ "description": "", "main": "./src/index.ts", "type": "module", + "private": true, "scripts": { + "prebuild": "rm -rf dist", "build": "tsc", "start": "node dist/index.js", - "dev": "env-cmd ts-node --esm src/index.ts", - "test": "jest" + "dev": "env-cmd ts-node src/index.ts", + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts --no-ignore", + "format": "prettier --write ." + }, + "imports": { + "#*": "./src/*" }, "keywords": [ "mongodb", @@ -33,6 +43,7 @@ "nodemon": "^3.1.10" }, "devDependencies": { + "@eslint/js": "^9.27.0", "@types/bcrypt": "^5.0.2", "@types/compression": "^1.7.5", "@types/cors": "^2.8.18", @@ -43,11 +54,19 @@ "@types/morgan": "^1.9.9", "@types/node": "^22.15.17", "@types/supertest": "^6.0.3", + "@typescript-eslint/eslint-plugin": "^8.32.1", + "@typescript-eslint/parser": "^8.32.1", + "eslint": "^9.27.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-perfectionist": "^4.13.0", + "eslint-plugin-prettier": "^5.4.0", "jest": "^29.7.0", "mongodb-memory-server": "^10.1.4", + "prettier": "^3.5.3", "supertest": "^7.1.0", "ts-jest": "^29.3.2", "ts-node": "^10.9.2", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "typescript-eslint": "^8.32.1" } } diff --git a/src/app.ts b/src/app.ts index 5eb7e6a..3ac052c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,18 +1,17 @@ import express, { Application } from 'express'; -import errorHandler from './midleware/errorHandler'; -import rateLimiter from './midleware/rateLimiter'; import compression from './midleware/compression'; +import cors from './midleware/cors'; +import errorHandler from './midleware/errorHandler'; import helmet from './midleware/helmet'; import json from './midleware/json'; -import cors from './midleware/cors'; import morgan from './midleware/morgan'; - -import notFoundRouter from './routes/notFoundRoute'; +import rateLimiter from './midleware/rateLimiter'; +import authRouter from './routes/authRoute'; import healthRouter from './routes/healthRoute'; -import storesRouter from './routes/storesRoute'; +import notFoundRouter from './routes/notFoundRoute'; import rootRouter from './routes/rootRoute'; -import authRouter from './routes/authRoute'; +import storesRouter from './routes/storesRoute'; const app: Application = express(); @@ -35,4 +34,4 @@ app.use('/stores', storesRouter); app.use('/auth', authRouter); app.use('*', notFoundRouter); -export default app; \ No newline at end of file +export default app; diff --git a/src/database/mongo-common.ts b/src/database/mongo-common.ts index ba1092b..f01cac5 100644 --- a/src/database/mongo-common.ts +++ b/src/database/mongo-common.ts @@ -3,7 +3,7 @@ * Documentation: https://mongodb.github.io/node-mongodb-native/6.16/classes/MongoClient.html */ -import { MongoClient, Db } from 'mongodb'; +import { Db, MongoClient } from 'mongodb'; import { MongoMemoryServer } from 'mongodb-memory-server'; const mongoDBURL = process.env.MONGO_URL; @@ -17,26 +17,31 @@ let database: Db | null = null; let mongoServer: MongoMemoryServer | null = null; // store reference to in-memory server for shutdown const getRightMongoDBURL = async (): Promise => { - const env = process.env.NODE_ENV; + const env = process.env.NODE_ENV ?? 'development'; if (env === 'test') { mongoServer = await MongoMemoryServer.create(); return mongoServer.getUri(); } - if (['development', 'production'].includes(env || '')) { - return mongoDBURL as string; + if (['development', 'production'].includes(env)) { + if (!mongoDBURL) throw new Error('MONGO_URL is not defined'); + return mongoDBURL; } throw new Error(`Unsupported NODE_ENV: ${env}`); }; -export async function startDatabase(uri: string | null = null): Promise { +export async function getDatabase(): Promise { + return database ?? (await startDatabase()); +} + +export async function startDatabase(uri: null | string = null): Promise { if (client && database) { return database; } - const dbURI = uri || await getRightMongoDBURL(); + const dbURI = uri ?? (await getRightMongoDBURL()); client = new MongoClient(dbURI); await client.connect(); @@ -44,11 +49,6 @@ export async function startDatabase(uri: string | null = null): Promise { return database; } - -export async function getDatabase(): Promise { - return database || await startDatabase(); -} - export async function stopDatabase(): Promise { if (client) { await client.close(); diff --git a/src/database/stores.ts b/src/database/stores.ts index 942e98f..a942f68 100644 --- a/src/database/stores.ts +++ b/src/database/stores.ts @@ -1,74 +1,73 @@ -import { getDatabase } from './mongo-common'; import { ObjectId } from 'mongodb'; + import getUserName from '../utils/git-user-name'; +import { getDatabase } from './mongo-common'; // Define the Store interface -interface Store { - _id?: string; - name: string; +export interface Store { + _id: ObjectId; addedBy?: string; metadata?: string; + name: string; } const collectionName = 'stores'; // Create a Store -async function createStore(store: Store): Promise { +async function createStore(store: Store): Promise { const database = await getDatabase(); store.addedBy = getUserName(); - + const storeToInsert = { ...store, _id: store._id ? new ObjectId(store._id) : undefined }; const { insertedId } = await database.collection(collectionName).insertOne(storeToInsert); // Return the store document with the inserted _id - return await database.collection(collectionName).findOne({ _id: insertedId }) as Store | null; -} - -// Get all stores -async function getStores(): Promise { - const database = await getDatabase(); - const stores = await database.collection(collectionName).find({}).toArray(); - return stores.map(store => ({ - _id: store._id?.toString(), - name: store.name, - addedBy: store.addedBy, - })) as Store[]; + return (await database.collection(collectionName).findOne({ _id: insertedId })) as null | Store; } // Delete a store by id async function deleteStore(_id: string): Promise<{ message: string }> { const database = await getDatabase(); - + const result = await database.collection(collectionName).deleteOne({ _id: new ObjectId(_id), }); if (result.deletedCount === 0) { - return { message: "No store found with that id" }; + return { message: 'No store found with that id' }; } - return { message: "Store deleted" }; + return { message: 'Store deleted' }; +} + +// Get all stores +async function getStores(): Promise { + const database = await getDatabase(); + const stores = await database.collection(collectionName).find({}).toArray(); + return stores.map(store => ({ + _id: store._id, + addedBy: store.addedBy, + name: store.name, + })); } // Update a store -async function updateStore(id: string, store: Partial): Promise { +async function updateStore(id: string, store: Partial): Promise { const database = await getDatabase(); delete store._id; - await database.collection(collectionName).updateOne( - { _id: new ObjectId(id) }, - { $set: store } - ); + await database.collection(collectionName).updateOne({ _id: new ObjectId(id) }, { $set: store }); - const updated = await database.collection(collectionName).findOne({ _id: new ObjectId(id) }); + const updated = await database + .collection(collectionName) + .findOne({ _id: new ObjectId(id) }); if (!updated) return null; return { - _id: updated._id?.toString(), - name: updated.name, + _id: new ObjectId(updated._id), addedBy: updated.addedBy, metadata: updated.metadata, - } as Store; + name: updated.name, + }; } - -export { createStore, getStores, deleteStore, updateStore }; \ No newline at end of file +export { createStore, deleteStore, getStores, updateStore }; diff --git a/src/http_tests/app.test.ts b/src/http_tests/app.test.ts index 35d7ab5..f148cfc 100644 --- a/src/http_tests/app.test.ts +++ b/src/http_tests/app.test.ts @@ -1,25 +1,31 @@ import 'dotenv/config'; -import request from 'supertest'; +import request, { Response } from 'supertest'; + import app from '../app'; -describe('Health Check Endpoint', () => { +interface ResponseBody { + message?: string; + status: string; +} +describe('Health Check Endpoint', () => { it('should return 302 and redirect to /health', async () => { - const res = await request(app).get('/'); - expect(res.statusCode).toEqual(302); + const res: Response = await request(app).get('/'); + expect(res.status).toBe(302); expect(res.headers.location).toBe('/health'); }); it('should return 200 and status OK', async () => { - const res = await request(app).get('/health'); + const res: Response = await request(app).get('/health'); + const body = res.body as ResponseBody; + expect(body.status).toBe('OK'); expect(res.statusCode).toEqual(200); - expect(res.body.status).toBe('OK'); }); it('should return 404 for non-existent endpoint', async () => { - const res = await request(app).get('/non-existent'); + const res: Response = await request(app).get('/non-existent'); + const body = res.body as ResponseBody; expect(res.statusCode).toEqual(404); - expect(res.body.message).toBe('Route not found'); + expect(body.message).toBe('Route not found'); }); - -}); \ No newline at end of file +}); diff --git a/src/http_tests/authentication.test.ts b/src/http_tests/authentication.test.ts index 27332e9..986a5fc 100644 --- a/src/http_tests/authentication.test.ts +++ b/src/http_tests/authentication.test.ts @@ -1,34 +1,49 @@ import 'dotenv/config'; -import request from 'supertest'; -import app from '../app'; // Adjust the path as necessary +import request, { Response } from 'supertest'; + +import app from '../app'; import { stopDatabase } from '../database/mongo-common'; +// Define expected response shapes +interface AuthResponse { + message?: string; + token: string; +} + describe('Authentication JWT', () => { - afterAll(async () => { - await stopDatabase(); // Ensure database connection is closed - }); - - const testUser = { - email: 'testuser@example.com', - password: 'SecurePass123!', - }; - - it('should register a new user', async () => { - const res = await request(app) - .post('/auth/register') - .send(testUser) - .expect(201); - - expect(res.body).toHaveProperty('message'); - }); - - it('should login with valid credentials', async () => { - const res = await request(app) - .post('/auth/login') - .send(testUser) - .expect(200); - - expect(res.body).toHaveProperty('token'); - expect(typeof res.body.token).toBe('string'); - }); + afterAll(async () => { + await stopDatabase(); // Ensure database connection is closed + }); + + const testUser = { + email: 'testuser@example.com', + password: 'SecurePass123!', + }; + + it('should register a new user', async () => { + const res: Response = await request(app).post('/auth/register').send(testUser).expect(201); + const body = res.body as AuthResponse; + expect(body).toHaveProperty('message'); + }); + + it('should login with valid credentials', async () => { + const res: Response = await request(app).post('/auth/login').send(testUser).expect(200); + const body = res.body as AuthResponse; + expect(body).toHaveProperty('token'); + expect(typeof body.token).toBe('string'); + }); + + it('should return user profile with valid token', async () => { + const loginRes: Response = await request(app).post('/auth/login').send(testUser).expect(200); + const body = loginRes.body as AuthResponse; + + const res: Response = await request(app) + .get('/auth/me') + .set('Authorization', `Bearer ${body.token}`) + .expect(200); + + const userBody = res.body as { email: string }; + expect(res.statusCode).toEqual(200); + expect(userBody).toHaveProperty('email', testUser.email); + }); }); diff --git a/src/http_tests/stores.test.ts b/src/http_tests/stores.test.ts index 5edffa9..9de28b4 100644 --- a/src/http_tests/stores.test.ts +++ b/src/http_tests/stores.test.ts @@ -1,73 +1,80 @@ import 'dotenv/config'; import request, { Response } from 'supertest'; + import app from '../app'; // Adjust the path as necessary import { stopDatabase } from '../database/mongo-common'; interface Store { - store_profile: string; - shipping_address: string; - _id?: string; // Optionally include the ID in responses - metadata?: string; + _id?: string; // Optionally include the ID in responses + metadata?: string; + shipping_address: string; + store_profile: string; } describe('Store "Collections" Endpoint', () => { + afterAll(async () => { + await stopDatabase(); // Ensure database connection is closed + }); - afterAll(async () => { - await stopDatabase(); // Ensure database connection is closed - }); - - // Test POST /stores to create a new store - it('should create a new store', async () => { - const storeData: Store = { - store_profile: 'Nevada Golf Emprium', - shipping_address: '99 Nowhere Drive, Nevada', - }; - - const res: Response = await request(app) - .post('/stores') - .send(storeData) - .set('Content-Type', 'application/json'); - - expect(res.statusCode).toEqual(201); // Expecting 201 Created - expect(res.body.store_profile).toBe(storeData.store_profile); - expect(res.body.shipping_address).toBe(storeData.shipping_address); - }); + // Test POST /stores to create a new store + it('should create a new store', async () => { + const storeData: Store = { + shipping_address: '99 Nowhere Drive, Nevada', + store_profile: 'Nevada Golf Emprium', + }; - // Test GET /stores to fetch all stores - it('should return a list of stores', async () => { - const res: Response = await request(app).get('/stores'); - expect(res.statusCode).toEqual(200); - expect(Array.isArray(res.body)).toBe(true); // Expecting an array of stores - expect(res.body.length).toBeGreaterThan(0); // Should contain at least one store - }); + const res: Response = await request(app) + .post('/stores') + .send(storeData) + .set('Content-Type', 'application/json'); - // Test PUT /stores/:id to update an existing store - it('should update an existing store', async () => { - const stores = await request(app).get('/stores'); - const storeId = stores.body[0]._id; - const updatedData: Partial = { - metadata: '68203238d1857e2fae0b6093', - }; + const body = res.body as Store; + expect(res.statusCode).toEqual(201); // Expecting 201 Created + expect(body.store_profile).toBe(storeData.store_profile); + expect(body.shipping_address).toBe(storeData.shipping_address); + }); - const res: Response = await request(app) - .put(`/stores/${storeId}`) - .send(updatedData) - .set('Content-Type', 'application/json'); + // Test GET /stores to fetch all stores + it('should return a list of stores', async () => { + const res: Response = await request(app).get('/stores'); + const body = res.body as Store[]; + expect(res.statusCode).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); // Expecting an array of stores + expect(body.length).toBeGreaterThan(0); // Should contain at least one store + }); - expect(res.statusCode).toEqual(200); - expect(res.body.metadata).toBe(updatedData.metadata); - }); + // Test PUT /stores/:id to update an existing store + it('should update an existing store', async () => { + const stores: Response = await request(app).get('/stores'); + const storesBody = stores.body as Store[]; + const storeId = storesBody[0]._id; + const updatedData: Partial = { + metadata: '68203238d1857e2fae0b6093', + }; - // Test DELETE /stores/:id to delete a store - it('should delete a store', async () => { - const stores = await request(app).get('/stores'); - const storeId = stores.body[0]._id; // Get the ID of the first store - expect(stores.body.length).toBeGreaterThan(0); + if (storeId) { + const res: Response = await request(app) + .put(`/stores/${storeId}`) + .send(updatedData) + .set('Content-Type', 'application/json'); - const res: Response = await request(app).delete(`/stores/${storeId}`); + const body = res.body as Store; + expect(res.statusCode).toEqual(200); + expect(body.metadata).toBe(updatedData.metadata); + } + }); - expect(res.statusCode).toEqual(200); - expect(res.body.message).toBe('Store deleted'); // Ensure that the response contains the message - }); + // Test DELETE /stores/:id to delete a store + it('should delete a store', async () => { + const stores: Response = await request(app).get('/stores'); + const storesBody = stores.body as Store[]; + const storeId = storesBody[0]._id; // Get the ID of the first store + if (storeId) { + const res: Response = await request(app).delete(`/stores/${storeId}`); + const body = res.body as { message: string }; + expect(res.statusCode).toEqual(200); + expect(body.message).toBe('Store deleted'); // Ensure that the response contains the message + } + }); }); diff --git a/src/index.ts b/src/index.ts index e488b5b..42dbbc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,35 @@ // src/server.ts +import 'dotenv/config'; +import { Server } from 'http'; + import app from './app'; import { startDatabase, stopDatabase } from './database/mongo-common'; -import { Server } from 'http'; -const PORT: number = parseInt(process.env.PORT || '3001', 10); +const PORT: number = parseInt(process.env.PORT ?? '3001', 10); const MONGO_URL: string | undefined = process.env.MONGO_URL; let server: Server | undefined; +function gracefulShutdown(signal: string): void { + console.log(`\nReceived ${signal}, shutting down...`); + if (server) { + server.close(() => { + console.log('HTTP server closed'); + stopDatabase() + .then(() => { + console.log('Database connection closed'); + process.exit(0); + }) + .catch((err: unknown) => { + console.error('Error during shutdown:', err); + process.exit(1); + }); + }); + } else { + process.exit(0); + } +} + async function startServer(): Promise { if (!MONGO_URL) { // Gracefully handle missing DB config @@ -18,7 +40,7 @@ async function startServer(): Promise { }); server = app.listen(PORT, () => { - console.log(`Server running without DB on port ${PORT}`); + console.log(`Server running without DB on port ${PORT.toString()}`); }); return; @@ -28,30 +50,18 @@ async function startServer(): Promise { await startDatabase(); server = app.listen(PORT, () => { - console.log(`Server started on port ${PORT}`); + console.log(`Server started on port ${PORT.toString()}`); }); - } catch (err) { + } catch (err: unknown) { console.error('Failed to start database:', err); process.exit(1); } } -function gracefulShutdown(signal: string): void { - console.log(`\nReceived ${signal}, shutting down...`); - if (server) { - server.close(async () => { - console.log('HTTP server closed'); - await stopDatabase(); - console.log('Database connection closed'); - process.exit(0); - }); - } else { - process.exit(0); - } -} - ['SIGINT', 'SIGTERM'].forEach(signal => { - process.on(signal, () => gracefulShutdown(signal)); + process.on(signal, () => { + gracefulShutdown(signal); + }); }); -startServer(); \ No newline at end of file +void startServer(); diff --git a/src/midleware/compression.ts b/src/midleware/compression.ts index 8ef7cfe..968c3ae 100644 --- a/src/midleware/compression.ts +++ b/src/midleware/compression.ts @@ -2,4 +2,4 @@ import compression from 'compression'; const compressionMiddleware = compression(); -export default compressionMiddleware; \ No newline at end of file +export default compressionMiddleware; diff --git a/src/midleware/cors.ts b/src/midleware/cors.ts index 9bc0aca..04f2ffb 100644 --- a/src/midleware/cors.ts +++ b/src/midleware/cors.ts @@ -1,23 +1,29 @@ import cors, { CorsOptions } from 'cors'; // Load environment variables with fallback values -const ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS || ''; -const ALLOWED_METHODS = process.env.CORS_ALLOWED_METHODS || 'GET,POST,PUT,DELETE'; -const ALLOWED_HEADERS = process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization'; +const ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS ?? ''; +const ALLOWED_METHODS = process.env.CORS_ALLOWED_METHODS ?? 'GET,POST,PUT,DELETE'; +const ALLOWED_HEADERS = process.env.CORS_ALLOWED_HEADERS ?? 'Content-Type,Authorization'; // Type-safe CORS options const corsOptions: CorsOptions = { + allowedHeaders: ALLOWED_HEADERS.split(',') + .map(h => h.trim()) + .filter(Boolean), + credentials: true, + methods: ALLOWED_METHODS.split(',') + .map(m => m.trim()) + .filter(Boolean), origin: (origin: string | undefined, callback: (error: Error | null, allow: boolean) => void) => { - const allowedOrigins = ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean); + const allowedOrigins = ALLOWED_ORIGINS.split(',') + .map(o => o.trim()) + .filter(Boolean); if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS'), false); } }, - methods: ALLOWED_METHODS.split(',').map(m => m.trim()).filter(Boolean), - allowedHeaders: ALLOWED_HEADERS.split(',').map(h => h.trim()).filter(Boolean), - credentials: true, }; -export default cors(corsOptions); \ No newline at end of file +export default cors(corsOptions); diff --git a/src/midleware/errorHandler.ts b/src/midleware/errorHandler.ts index e010727..c2f4b60 100644 --- a/src/midleware/errorHandler.ts +++ b/src/midleware/errorHandler.ts @@ -1,7 +1,9 @@ -import { Request, Response, NextFunction } from 'express'; +import { NextFunction, Request, Response } from 'express'; -const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction): void => { - console.error(`[ERROR] ${err.stack}`); +// using _ to indicate that the parameter is not used +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction): void => { + console.error(`[ERROR] ${err.stack ?? 'No stack trace available'}`); const response = { message: 'Internal Server Error', @@ -11,4 +13,4 @@ const errorHandler = (err: Error, req: Request, res: Response, next: NextFunctio res.status(500).json(response); }; -export default errorHandler; \ No newline at end of file +export default errorHandler; diff --git a/src/midleware/helmet.ts b/src/midleware/helmet.ts index 04112f0..fc6aab0 100644 --- a/src/midleware/helmet.ts +++ b/src/midleware/helmet.ts @@ -1,18 +1,18 @@ +import { RequestHandler } from 'express'; import helmet from 'helmet'; -import { RequestHandler } from 'express'; const configureHelmet = (): RequestHandler => // Explicitly type as RequestHandler helmet({ contentSecurityPolicy: false, - referrerPolicy: { policy: 'no-referrer' }, expectCt: false, hidePoweredBy: true, hsts: { - maxAge: 63072000, // 2 years includeSubDomains: true, - preload: true, + maxAge: 63072000, // 2 years + preload: true, }, noSniff: true, + referrerPolicy: { policy: 'no-referrer' }, }); -export default configureHelmet(); \ No newline at end of file +export default configureHelmet(); diff --git a/src/midleware/json.ts b/src/midleware/json.ts index ac4eef4..4662242 100644 --- a/src/midleware/json.ts +++ b/src/midleware/json.ts @@ -3,14 +3,14 @@ import { json } from 'express'; // Custom JSON middleware configuration const configuredJson = json({ limit: '1mb', - strict: false, - type: ['application/json', 'application/vnd.api+json'], - reviver: (key: string, value: any): any => { + reviver: (key: string, value: unknown): unknown => { if (key === 'date' && typeof value === 'string') { return new Date(value); } return value; }, + strict: false, + type: ['application/json', 'application/vnd.api+json'], }); -export default configuredJson; \ No newline at end of file +export default configuredJson; diff --git a/src/midleware/morgan.ts b/src/midleware/morgan.ts index c8ae24b..85f2240 100644 --- a/src/midleware/morgan.ts +++ b/src/midleware/morgan.ts @@ -1,6 +1,6 @@ -import morgan, { StreamOptions } from 'morgan'; +import morgan from 'morgan'; // Morgan configuration const configureMorgan = morgan('dev'); -export default configureMorgan; \ No newline at end of file +export default configureMorgan; diff --git a/src/midleware/rateLimiter.ts b/src/midleware/rateLimiter.ts index 064df85..6a3351a 100644 --- a/src/midleware/rateLimiter.ts +++ b/src/midleware/rateLimiter.ts @@ -1,8 +1,8 @@ import rateLimit from 'express-rate-limit'; const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per window + windowMs: 15 * 60 * 1000, // 15 minutes }); -export default limiter; \ No newline at end of file +export default limiter; diff --git a/src/routes/authRoute.ts b/src/routes/authRoute.ts index 7e23a0d..917e946 100644 --- a/src/routes/authRoute.ts +++ b/src/routes/authRoute.ts @@ -1,23 +1,30 @@ -import { Request, Response, Router } from 'express'; import bcrypt from 'bcrypt'; +import { Request, Response, Router } from 'express'; import jwt from 'jsonwebtoken'; +import { ObjectId } from 'mongodb'; + import { getDatabase } from '../database/mongo-common'; const router: Router = Router(); -const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret'; +const JWT_SECRET = process.env.JWT_SECRET; + +if (!JWT_SECRET) { + throw new Error('JWT_SECRET is not defined'); +} interface User { - _id?: string; // Make _id optional + _id?: ObjectId; email: string; password: string; } // Register endpoint router.post('/register', async (req: Request, res: Response): Promise => { - const { email, password } = req.body; + const { email, password } = req.body as User; if (!email || !password) { res.status(400).json({ message: 'Email and password are required' }); + return; } try { @@ -27,21 +34,24 @@ router.post('/register', async (req: Request, res: Response): Promise => { const existingUser = await usersCollection.findOne({ email }); if (existingUser) { res.status(409).json({ message: 'Email already taken' }); + return; } const hashedPassword = await bcrypt.hash(password, 10); await usersCollection.insertOne({ email, password: hashedPassword }); res.status(201).json({ message: 'User registered successfully' }); + return; } catch (error) { console.error('Error registering user:', error); res.status(500).json({ message: 'Internal server error' }); + return; } }); // Login endpoint router.post('/login', async (req: Request, res: Response): Promise => { - const { email, password } = req.body; + const { email, password } = req.body as User; try { const db = await getDatabase(); @@ -58,7 +68,7 @@ router.post('/login', async (req: Request, res: Response): Promise => { res.status(401).json({ message: 'Invalid credentials' }); } - const token = jwt.sign({ userId: user._id, email: user.email }, JWT_SECRET, { + const token = jwt.sign({ email: user.email, userId: user._id }, JWT_SECRET, { expiresIn: '1h', }); @@ -69,4 +79,32 @@ router.post('/login', async (req: Request, res: Response): Promise => { } }); +// responds with user data +router.get('/me', async (req: Request, res: Response): Promise => { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + res.status(401).json({ message: 'No token provided' }); + return; + } + + try { + const decoded = jwt.verify(token, JWT_SECRET) as { email: string; userId: string }; + const db = await getDatabase(); + const usersCollection = db.collection('users'); + + const user = await usersCollection.findOne({ _id: new ObjectId(decoded.userId) }); + + if (!user) { + res.status(201).json({ message: 'User not found' }); + return; + } + + res.status(200).json({ email: user.email }); + } catch (error) { + console.error('Error fetching user data:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + export default router; diff --git a/src/routes/healthRoute.ts b/src/routes/healthRoute.ts index 7b5a07b..ea2b486 100644 --- a/src/routes/healthRoute.ts +++ b/src/routes/healthRoute.ts @@ -3,11 +3,11 @@ import { Request, Response, Router } from 'express'; const router: Router = Router(); router.get('/', (req: Request, res: Response): void => { - res.status(200).json({ - status: 'OK', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - }); + res.status(200).json({ + status: 'OK', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }); }); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/notFoundRoute.ts b/src/routes/notFoundRoute.ts index 99897dd..258aef3 100644 --- a/src/routes/notFoundRoute.ts +++ b/src/routes/notFoundRoute.ts @@ -3,12 +3,12 @@ import { Request, Response, Router } from 'express'; const router: Router = Router(); router.all('*', (req: Request, res: Response) => { - res.status(404).json({ - message: 'Route not found', - method: req.method, - endpoint: req.originalUrl, - timestamp: new Date().toISOString(), - }); + res.status(404).json({ + endpoint: req.originalUrl, + message: 'Route not found', + method: req.method, + timestamp: new Date().toISOString(), + }); }); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/rootRoute.ts b/src/routes/rootRoute.ts index 833840c..e140900 100644 --- a/src/routes/rootRoute.ts +++ b/src/routes/rootRoute.ts @@ -6,4 +6,4 @@ router.get('/', (_: Request, res: Response): void => { res.redirect('/health'); }); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/storesRoute.ts b/src/routes/storesRoute.ts index 505bae4..dc0270c 100644 --- a/src/routes/storesRoute.ts +++ b/src/routes/storesRoute.ts @@ -1,5 +1,6 @@ -import { Router, Request, Response } from 'express'; -import { deleteStore, updateStore, createStore, getStores } from '../database/stores'; +import { Request, Response, Router } from 'express'; + +import { createStore, deleteStore, getStores, Store, updateStore } from '../database/stores'; const router: Router = Router(); @@ -9,7 +10,7 @@ router.get('/', async (req: Request, res: Response): Promise => { }); router.post('/', async (req: Request, res: Response): Promise => { - const newStore = req.body; + const newStore = req.body as Store; const createdStore = await createStore(newStore); res.status(201).json(createdStore); }); @@ -22,9 +23,9 @@ router.delete('/:_id', async (req: Request, res: Response): Promise => { // Endpoint to update a Store router.put('/:_id', async (req: Request, res: Response): Promise => { - const updatedStore = req.body; + const updatedStore = req.body as Store; const result = await updateStore(req.params._id, updatedStore); res.json(result); }); -export default router; \ No newline at end of file +export default router; diff --git a/src/utils/git-user-name.ts b/src/utils/git-user-name.ts index 6c6f224..ee4f35b 100644 --- a/src/utils/git-user-name.ts +++ b/src/utils/git-user-name.ts @@ -1,12 +1,13 @@ import { execSync } from 'child_process'; function getGitUserName(): string { - try { - const name = execSync('git config --get user.name', { encoding: 'utf8' }).trim(); - return name || 'unknown'; - } catch (err) { - return 'unknown'; - } + try { + const name = execSync('git config --get user.name', { encoding: 'utf8' }).trim(); + return name || 'unknown'; + } catch (err) { + console.info('Git user name not found, returning "unknown"', err); + return 'unknown'; + } } -export default getGitUserName; \ No newline at end of file +export default getGitUserName; diff --git a/tsconfig.json b/tsconfig.json index 0b62d54..4cf4576 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,24 @@ { "compilerOptions": { - "target": "ES6", // Use ES6 as the output target for better module handling - "module": "CommonJS", // Use CommonJS modules since Node.js uses them - "moduleResolution": "node", // Use Node module resolution for imports - "esModuleInterop": true, // Allow default imports from non-ES modules - "skipLibCheck": true, // Skip type checking of declaration files for faster builds - "strict": true, // Enable strict type-checking options + "target": "ES6", // Use ES6 as the output target for better module handling + "module": "CommonJS", // Use CommonJS modules since Node.js uses them + "moduleResolution": "node", // Use Node module resolution for imports + "esModuleInterop": true, // Allow default imports from non-ES modules + "skipLibCheck": true, // Skip type checking of declaration files for faster builds + "strict": true, // Enable strict type-checking options "forceConsistentCasingInFileNames": true, // Enforce consistent casing in file names - "outDir": "./dist", // Specify where compiled JavaScript files go - "baseUrl": ".", // Base URL to resolve non-relative modules - "types": ["node", "jest"], // Include types for Node.js and Jest for testing - "allowJs": true, // Allow JavaScript files to be included in the compilation - "resolveJsonModule": true, // Allow importing of JSON files + "outDir": "./dist", // Specify where compiled JavaScript files go + "baseUrl": ".", // Base URL to resolve non-relative modules + "types": ["node", "jest"], // Include types for Node.js and Jest for testing + "allowJs": true, // Allow JavaScript files to be included in the compilation + "resolveJsonModule": true // Allow importing of JSON files }, "include": [ - "src/**/*.ts", // Include all TS files in the `src` folder - "tests/**/*.ts" // Include test files in a separate `tests` folder + "src/**/*.ts", // Include all TS files in the `src` folder + "tests/**/*.ts" // Include test files in a separate `tests` folder ], "exclude": [ - "node_modules", // Exclude node_modules - "dist" // Exclude the `dist` folder where compiled JS files are stored + "node_modules", // Exclude node_modules + "dist" // Exclude the `dist` folder where compiled JS files are stored ] }