diff --git a/.env.example b/.env.example index 557024d..b8e205c 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # PostgreSQL Database Connection URL DATABASE_URL=postgresql://user:password@ep-sample-12345.eu-central-1.aws.neon.tech/neondb?sslmode=require +# Managed Better Auth (Neon Auth) +NEON_AUTH_BASE_URL=https://ep-sample-12345.neonauth.eu-central-1.aws.neon.tech/neondb/auth +NEON_AUTH_COOKIE_SECRET=your-secure-cookie-secret-at-least-32-characters-long + # Core API Service Configuration PORT=5000 NODE_ENV=development diff --git a/api/package-lock.json b/api/package-lock.json index 540a017..9de3342 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -7,11 +7,12 @@ "": { "name": "api", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.6.2", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", "typeorm": "^1.1.0" @@ -1009,6 +1010,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1210,6 +1230,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/api/package.json b/api/package.json index 8259f28..43c2541 100644 --- a/api/package.json +++ b/api/package.json @@ -18,6 +18,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.6.2", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", "typeorm": "^1.1.0" diff --git a/api/src/controllers/categories.controller.ts b/api/src/controllers/categories.controller.ts new file mode 100644 index 0000000..2a31b82 --- /dev/null +++ b/api/src/controllers/categories.controller.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { categoriesService } from '../services/categories.service'; + +export class CategoriesController { + async getAllCategories(req: Request, res: Response, next: NextFunction) { + try { + const categories = await categoriesService.getAllCategories(); + res.json({ + success: true, + data: categories, + }); + } catch (error) { + next(error); + } + } + + async getCategoryBySlug(req: Request, res: Response, next: NextFunction) { + try { + const { slug } = req.params; + const category = await categoriesService.getCategoryBySlug(slug as string); + res.json({ + success: true, + data: category, + }); + } catch (error) { + next(error); + } + } +} + +export const categoriesController = new CategoriesController(); diff --git a/api/src/controllers/products.controller.ts b/api/src/controllers/products.controller.ts new file mode 100644 index 0000000..720d053 --- /dev/null +++ b/api/src/controllers/products.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response, NextFunction } from 'express'; +import { productsService, ProductQueryParams } from '../services/products.service'; + +export class ProductsController { + async getProducts(req: Request, res: Response, next: NextFunction) { + try { + const { + page, + limit, + search, + category, + manufacturer, + minPrice, + maxPrice, + sortBy, + } = req.query; + + const queryParams: ProductQueryParams = { + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + search: typeof search === 'string' ? search : undefined, + category: typeof category === 'string' ? category : undefined, + manufacturer: typeof manufacturer === 'string' ? manufacturer : undefined, + minPrice: minPrice ? Number(minPrice) : undefined, + maxPrice: maxPrice ? Number(maxPrice) : undefined, + sortBy: sortBy as ProductQueryParams['sortBy'], + }; + + const result = await productsService.getProducts(queryParams); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + next(error); + } + } + + async getProductByIdentifier(req: Request, res: Response, next: NextFunction) { + try { + const { identifier } = req.params; + const product = await productsService.getProductByIdentifier(identifier as string); + + res.json({ + success: true, + data: product, + }); + } catch (error) { + next(error); + } + } +} + +export const productsController = new ProductsController(); diff --git a/api/src/controllers/projects.controller.ts b/api/src/controllers/projects.controller.ts new file mode 100644 index 0000000..c96c97a --- /dev/null +++ b/api/src/controllers/projects.controller.ts @@ -0,0 +1,74 @@ +import { Request, Response, NextFunction } from 'express'; +import { projectsService } from '../services/projects.service'; + +export class ProjectsController { + async getProjects(req: Request, res: Response, next: NextFunction) { + try { + const userId = typeof req.query.userId === 'string' ? req.query.userId : undefined; + const projects = await projectsService.getProjects(userId); + + res.json({ + success: true, + data: projects, + }); + } catch (error) { + next(error); + } + } + + async getProjectById(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const project = await projectsService.getProjectById(id as string); + + res.json({ + success: true, + data: project, + }); + } catch (error) { + next(error); + } + } + + async createProject(req: Request, res: Response, next: NextFunction) { + try { + const { title, userId, data } = req.body; + const project = await projectsService.createProject({ title, userId, data }); + + res.status(201).json({ + success: true, + data: project, + }); + } catch (error) { + next(error); + } + } + + async updateProject(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const { title, data } = req.body; + const project = await projectsService.updateProject(id as string, { title, data }); + + res.json({ + success: true, + data: project, + }); + } catch (error) { + next(error); + } + } + + async deleteProject(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const result = await projectsService.deleteProject(id as string); + + res.json(result); + } catch (error) { + next(error); + } + } +} + +export const projectsController = new ProjectsController(); diff --git a/api/src/index.ts b/api/src/index.ts index c2afdd8..d18482f 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,8 +1,11 @@ -import 'reflect-metadata'; -import express from 'express'; -import cors from 'cors'; -import dotenv from 'dotenv'; -import { AppDataSource } from './data-source'; +import "reflect-metadata"; +import express from "express"; +import cors from "cors"; +import dotenv from "dotenv"; +import { AppDataSource } from "./data-source"; +import routes from "./routes"; +import { apiRateLimiter } from "./middlewares/rateLimiter"; +import { errorHandler } from "./middlewares/errorHandler"; dotenv.config(); @@ -12,31 +15,42 @@ const port = process.env.PORT || 5000; app.use(cors()); app.use(express.json()); -app.get('/health', (req, res) => { +app.use(apiRateLimiter); + +app.get("/health", (req, res) => { res.json({ - status: 'ok', - service: 'core-api', + status: "ok", + service: "core-api", db_connected: AppDataSource.isInitialized, timestamp: new Date().toISOString(), }); }); +app.use("/api/v1/core", routes); +app.use("/", routes); + +app.use(errorHandler); + async function startServer() { try { if (process.env.DATABASE_URL) { await AppDataSource.initialize(); - console.log('[Database] Connected to PostgreSQL via TypeORM'); + console.log("[Database] Connected to PostgreSQL via TypeORM"); } else { - console.warn('[Database] DATABASE_URL not set. Running without database connection.'); + console.warn( + "[Database] DATABASE_URL not set. Running without database connection.", + ); } app.listen(port, () => { console.log(`[Server] Core API Service listening on port ${port}`); }); } catch (error) { - console.error('[Database] Connection failed:', error); + console.error("[Database] Connection failed:", error); app.listen(port, () => { - console.log(`[Server] Core API Service listening on port ${port} (database offline)`); + console.log( + `[Server] Core API Service listening on port ${port} (database offline)`, + ); }); } } diff --git a/api/src/middlewares/auth.ts b/api/src/middlewares/auth.ts new file mode 100644 index 0000000..9284a35 --- /dev/null +++ b/api/src/middlewares/auth.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; + +export interface AuthenticatedRequest extends Request { + user?: { + id: string; + email?: string; + name?: string; + }; +} + +export function optionalAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) { + const userId = req.headers['x-user-id'] as string; + const userEmail = req.headers['x-user-email'] as string; + const userName = req.headers['x-user-name'] as string; + + if (userId) { + req.user = { + id: userId, + email: userEmail, + name: userName, + }; + } + + next(); +} + +export function requireAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) { + const userId = req.headers['x-user-id'] as string; + + if (!userId) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Authentication required. Please sign in to perform this action.', + }); + } + + req.user = { + id: userId, + email: req.headers['x-user-email'] as string, + name: req.headers['x-user-name'] as string, + }; + + next(); +} diff --git a/api/src/middlewares/errorHandler.ts b/api/src/middlewares/errorHandler.ts new file mode 100644 index 0000000..d7a80b6 --- /dev/null +++ b/api/src/middlewares/errorHandler.ts @@ -0,0 +1,19 @@ +import { Request, Response, NextFunction } from 'express'; + +export function errorHandler( + err: any, + req: Request, + res: Response, + next: NextFunction +) { + console.error('[Error]', err); + + const statusCode = err.status || err.statusCode || 500; + const message = err.message || 'Internal Server Error'; + + res.status(statusCode).json({ + error: err.name || 'APIError', + message, + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), + }); +} diff --git a/api/src/middlewares/rateLimiter.ts b/api/src/middlewares/rateLimiter.ts new file mode 100644 index 0000000..ac61657 --- /dev/null +++ b/api/src/middlewares/rateLimiter.ts @@ -0,0 +1,16 @@ +import rateLimit from 'express-rate-limit'; +import { Request, Response } from 'express'; + +export const apiRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute window + max: 60, // Limit each IP to 60 requests per window + standardHeaders: true, // Return rate limit info in `RateLimit-*` headers + legacyHeaders: false, // Disable `X-RateLimit-*` headers + handler: (req: Request, res: Response) => { + res.status(429).json({ + error: 'rate_limit_exceeded', + message: 'Too many requests. Please wait a moment before trying again.', + retry_after_seconds: 60, + }); + }, +}); diff --git a/api/src/migrations/1740441600000-SeedInitialData.ts b/api/src/migrations/1740441600000-SeedInitialData.ts index d64b185..9505154 100644 --- a/api/src/migrations/1740441600000-SeedInitialData.ts +++ b/api/src/migrations/1740441600000-SeedInitialData.ts @@ -53,8 +53,11 @@ export class SeedInitialData1740441600000 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { console.log('[Migration] Reverting initial seed data...'); - await queryRunner.query('DELETE FROM products'); - await queryRunner.query('DELETE FROM categories'); + const productRepo = queryRunner.manager.getRepository(Product); + const categoryRepo = queryRunner.manager.getRepository(Category); + + await productRepo.clear(); + await categoryRepo.clear(); console.log('[Migration] Initial seed data deleted.'); } } diff --git a/api/src/routes/categories.routes.ts b/api/src/routes/categories.routes.ts new file mode 100644 index 0000000..9c6efd9 --- /dev/null +++ b/api/src/routes/categories.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { categoriesController } from '../controllers/categories.controller'; + +const router = Router(); + +router.get('/', (req, res, next) => categoriesController.getAllCategories(req, res, next)); +router.get('/:slug', (req, res, next) => categoriesController.getCategoryBySlug(req, res, next)); + +export default router; diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts new file mode 100644 index 0000000..020a981 --- /dev/null +++ b/api/src/routes/index.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import categoriesRoutes from './categories.routes'; +import productsRoutes from './products.routes'; +import projectsRoutes from './projects.routes'; + +const router = Router(); + +router.use('/categories', categoriesRoutes); +router.use('/products', productsRoutes); +router.use('/projects', projectsRoutes); + +export default router; diff --git a/api/src/routes/products.routes.ts b/api/src/routes/products.routes.ts new file mode 100644 index 0000000..c23bb37 --- /dev/null +++ b/api/src/routes/products.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { productsController } from '../controllers/products.controller'; + +const router = Router(); + +router.get('/', (req, res, next) => productsController.getProducts(req, res, next)); +router.get('/:identifier', (req, res, next) => productsController.getProductByIdentifier(req, res, next)); + +export default router; diff --git a/api/src/routes/projects.routes.ts b/api/src/routes/projects.routes.ts new file mode 100644 index 0000000..710513f --- /dev/null +++ b/api/src/routes/projects.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { projectsController } from '../controllers/projects.controller'; + +const router = Router(); + +router.get('/', (req, res, next) => projectsController.getProjects(req, res, next)); +router.get('/:id', (req, res, next) => projectsController.getProjectById(req, res, next)); +router.post('/', (req, res, next) => projectsController.createProject(req, res, next)); +router.put('/:id', (req, res, next) => projectsController.updateProject(req, res, next)); +router.delete('/:id', (req, res, next) => projectsController.deleteProject(req, res, next)); + +export default router; diff --git a/api/src/services/categories.service.ts b/api/src/services/categories.service.ts new file mode 100644 index 0000000..b69932d --- /dev/null +++ b/api/src/services/categories.service.ts @@ -0,0 +1,29 @@ +import { AppDataSource } from '../data-source'; +import { Category } from '../entities'; + +export class CategoriesService { + private categoryRepo = AppDataSource.getRepository(Category); + + async getAllCategories() { + return await this.categoryRepo.find({ + order: { name: 'ASC' }, + }); + } + + async getCategoryBySlug(slug: string) { + const category = await this.categoryRepo.findOne({ + where: { slug }, + relations: { products: true }, + }); + + if (!category) { + const error: any = new Error(`Category not found with slug: ${slug}`); + error.status = 404; + throw error; + } + + return category; + } +} + +export const categoriesService = new CategoriesService(); diff --git a/api/src/services/products.service.ts b/api/src/services/products.service.ts new file mode 100644 index 0000000..061318d --- /dev/null +++ b/api/src/services/products.service.ts @@ -0,0 +1,136 @@ +import { AppDataSource } from "../data-source"; +import { Product } from "../entities"; +import { + FindOptionsWhere, + FindOptionsOrder, + ILike, + In, + Between, + MoreThanOrEqual, + LessThanOrEqual, +} from "typeorm"; + +export interface ProductQueryParams { + page?: number; + limit?: number; + search?: string; + category?: string; + manufacturer?: string | string[]; + minPrice?: number; + maxPrice?: number; + sortBy?: "price_asc" | "price_desc" | "name_asc" | "name_desc" | "newest"; +} + +export class ProductsService { + private productRepo = AppDataSource.getRepository(Product); + + async getProducts(params: ProductQueryParams) { + const page = Math.max(1, Number(params.page) || 1); + const limit = Math.min(50, Math.max(1, Number(params.limit) || 12)); + const skip = (page - 1) * limit; + + const where: FindOptionsWhere = {}; + + // 1. Search by name using ILike + if (params.search && params.search.trim()) { + where.name = ILike(`%${params.search.trim()}%`); + } + + // 2. Category Filter (by slug or id) + if (params.category) { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + params.category, + ); + if (isUuid) { + where.category = { id: params.category }; + } else { + where.category = { slug: params.category }; + } + } + + // 3. Manufacturer Filter + if (params.manufacturer) { + const manufacturers = Array.isArray(params.manufacturer) + ? params.manufacturer + : params.manufacturer.split(",").map((m) => m.trim()); + where.manufacturer = In(manufacturers); + } + + // 4. Price Filter + if (params.minPrice !== undefined && params.maxPrice !== undefined) { + where.price = Between(Number(params.minPrice), Number(params.maxPrice)); + } else if (params.minPrice !== undefined) { + where.price = MoreThanOrEqual(Number(params.minPrice)); + } else if (params.maxPrice !== undefined) { + where.price = LessThanOrEqual(Number(params.maxPrice)); + } + + // 5. Order + const order: FindOptionsOrder = {}; + switch (params.sortBy) { + case "price_asc": + order.price = "ASC"; + break; + case "price_desc": + order.price = "DESC"; + break; + case "name_asc": + order.name = "ASC"; + break; + case "name_desc": + order.name = "DESC"; + break; + case "newest": + default: + order.createdAt = "DESC"; + break; + } + + const [products, total] = await this.productRepo.findAndCount({ + where, + relations: { category: true }, + order, + skip, + take: limit, + }); + + const totalPages = Math.ceil(total / limit) || 1; + + return { + data: products, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; + } + + async getProductByIdentifier(identifier: string) { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + identifier, + ); + + const product = await this.productRepo.findOne({ + where: isUuid + ? { id: identifier } + : [{ slug: identifier }, { sku: identifier }], + relations: { category: true }, + }); + + if (!product) { + const error: any = new Error(`Product not found: ${identifier}`); + error.status = 404; + throw error; + } + + return product; + } +} + +export const productsService = new ProductsService(); diff --git a/api/src/services/projects.service.ts b/api/src/services/projects.service.ts new file mode 100644 index 0000000..d09a695 --- /dev/null +++ b/api/src/services/projects.service.ts @@ -0,0 +1,84 @@ +import { AppDataSource } from '../data-source'; +import { Project, User } from '../entities'; +import { FindOptionsWhere } from 'typeorm'; + +export interface CreateProjectInput { + title: string; + userId?: string; + data?: Record; +} + +export interface UpdateProjectInput { + title?: string; + data?: Record; +} + +export class ProjectsService { + private projectRepo = AppDataSource.getRepository(Project); + private userRepo = AppDataSource.getRepository(User); + + async getProjects(userId?: string) { + const where: FindOptionsWhere = userId ? { user: { id: userId } } : {}; + + return await this.projectRepo.find({ + where, + relations: { user: true }, + order: { updatedAt: 'DESC' }, + }); + } + + async getProjectById(id: string) { + const project = await this.projectRepo.findOne({ + where: { id }, + relations: { user: true }, + }); + + if (!project) { + const error: any = new Error(`Project not found with id: ${id}`); + error.status = 404; + throw error; + } + + return project; + } + + async createProject(input: CreateProjectInput) { + let user: User | undefined; + if (input.userId) { + user = await this.userRepo.findOne({ where: { id: input.userId } }) || undefined; + } + + const project = this.projectRepo.create({ + title: input.title || 'Untitled Build Project', + data: input.data || {}, + user, + }); + + return await this.projectRepo.save(project); + } + + async updateProject(id: string, input: UpdateProjectInput) { + const project = await this.getProjectById(id); + + if (input.title !== undefined) { + project.title = input.title; + } + + if (input.data !== undefined) { + project.data = { + ...project.data, + ...input.data, + }; + } + + return await this.projectRepo.save(project); + } + + async deleteProject(id: string) { + const project = await this.getProjectById(id); + await this.projectRepo.remove(project); + return { success: true, message: `Project ${id} deleted successfully.` }; + } +} + +export const projectsService = new ProjectsService();