Skip to main content

Overview

The PC Fix backend is a RESTful API built with Express 5, featuring a modular architecture organized by domain. It uses Prisma 6 as the ORM for PostgreSQL database access and implements industry-standard security practices.
The API is deployed on Railway and serves as the data layer for the Astro frontend.

Core Technologies

Express 5.2.1

Modern web application framework with async middleware support

Prisma 6.18

Type-safe ORM with automatic migrations and client generation

PostgreSQL

Robust relational database hosted on Railway

TypeScript 5.9

End-to-end type safety from database to API responses

Project Structure

packages/api/
├── src/
   ├── modules/              # Feature modules (domain-driven)
   ├── auth/            # Authentication & authorization
   ├── auth.routes.ts
   ├── auth.controller.ts
   └── auth.service.ts
   ├── products/        # Product management
   ├── categories/      # Category management
   ├── brands/          # Brand management
   ├── banners/         # Banner management
   ├── cart/            # Shopping cart
   ├── sales/           # Sales & orders
   ├── users/           # User management
   ├── favorites/       # Product favorites
   ├── technical/       # Technical support
   ├── stats/           # Analytics & statistics
   └── config/          # System configuration
   ├── shared/              # Shared utilities
   ├── database/        # Prisma client singleton
   └── prismaClient.ts
   ├── middlewares/     # Express middlewares
   ├── authMiddleware.ts
   ├── errorMiddleware.ts
   ├── rateLimitMiddleware.ts
   └── uploadMiddleware.ts
   ├── services/        # Shared services
   └── cron.service.ts
   └── utils/           # Utility functions
       └── AppError.ts
   └── server.ts            # Express app entry point
├── prisma/
   ├── schema.prisma        # Database schema
   ├── seed.ts              # Database seeder
   └── migrations/          # Database migrations
├── uploads/                 # Local file uploads (dev only)
├── tsconfig.json
└── package.json

Server Setup

server.ts

src/server.ts
import 'dotenv/config';
import * as Sentry from '@sentry/node';
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import path from 'path';
import { prisma } from './shared/database/prismaClient';

// Initialize Sentry
if (process.env.SENTRY_DSN) {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || 'development',
    tracesSampleRate: 1.0,
  });
}

// Import routes
import authRoutes from './modules/auth/auth.routes';
import productsRoutes from './modules/products/products.routes';
import categoriesRoutes from './modules/categories/categories.routes';
import brandsRoutes from './modules/brands/brands.routes';
import bannersRoutes from './modules/banners/banners.routes';
import statsRoutes from './modules/stats/stats.routes';
import usersRoutes from './modules/users/users.routes';
import salesRoutes from './modules/sales/sales.routes';
import configRoutes from './modules/config/config.routes';
import favoritesRoutes from './modules/favorites/favorites.routes';
import technicalRoutes from './modules/technical/technical.routes';
import cartRoutes from './modules/cart/cart.routes';

import { AppError } from './shared/utils/AppError';
import { globalErrorHandler } from './shared/middlewares/errorMiddleware';
import { CronService } from './shared/services/cron.service';

const app = express();
app.set('trust proxy', 1);
const PORT = process.env.PORT || 3002;

// CORS configuration
const FRONTEND_URL = process.env.FRONTEND_URL;
const whitelist = [
  'http://localhost:4321',
  'http://localhost:4322',
  'http://localhost:4323',
  'http://localhost:3002',
  'https://pcfixbaru.com.ar',
  'https://www.pcfixbaru.com.ar',
];

if (FRONTEND_URL && FRONTEND_URL !== '*') {
  const urls = FRONTEND_URL.split(',');
  urls.forEach(url => {
    if (!whitelist.includes(url.trim())) whitelist.push(url.trim());
  });
}

const corsOptions: cors.CorsOptions = {
  origin: function (origin, callback) {
    if (FRONTEND_URL === '*') {
      return callback(null, true);
    }
    if (!origin || whitelist.indexOf(origin) !== -1 || origin.endsWith('.vercel.app')) {
      callback(null, true);
    } else {
      console.error(`🚫 Blocked by CORS: ${origin}`);
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization']
};

// Middleware stack
app.use(cors(corsOptions));
app.use(helmet({ crossOriginResourcePolicy: false }));
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));

// Rate limiting
import { authLimiter, apiLimiter } from './shared/middlewares/rateLimitMiddleware';
app.use('/api/', apiLimiter);
app.use('/api/auth', authLimiter);

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/products', productsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/brands', brandsRoutes);
app.use('/api/banners', bannersRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/sales', salesRoutes);
app.use('/api/config', configRoutes);
app.use('/api/favorites', favoritesRoutes);
app.use('/api/technical', technicalRoutes);
app.use('/api/cart', cartRoutes);

// Health check
app.get('/health', async (req: Request, res: Response) => {
  try {
    await prisma.$queryRaw`SELECT 1`;
    res.status(200).json({
      success: true,
      message: 'API is healthy',
      database: 'Connected',
      environment: process.env.NODE_ENV || 'development'
    });
  } catch (error) {
    console.error('Database Check Failed:', error);
    res.status(500).json({ 
      success: false, 
      message: 'API Error', 
      database: 'Disconnected' 
    });
  }
});

// 404 handler
app.use((req: Request, res: Response, next: NextFunction) => {
  next(new AppError(`Route ${req.originalUrl} not found`, 404));
});

// Global error handler
app.use(globalErrorHandler);

// Start server
app.listen(PORT, async () => {
  console.log(`🚀 Server running on port ${PORT}`);
  console.log(`🌍 Environment: ${process.env.NODE_ENV || 'development'}`);
  console.log(`📡 API_URL: ${process.env.API_URL || 'NOT SET'}`);
  
  try {
    new CronService().start();
  } catch (e) {
    console.error('❌ Error starting Cron Jobs:', e);
  }
});

export default app;

Middleware Stack

Authentication Middleware

src/shared/middlewares/authMiddleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError } from '../utils/AppError';
import { prisma } from '../database/prismaClient';

interface JwtPayload {
  userId: number;
  role: string;
}

export const authenticate = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new AppError('No token provided', 401);
    }
    
    const token = authHeader.split(' ')[1];
    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET as string
    ) as JwtPayload;
    
    const user = await prisma.user.findUnique({
      where: { id: decoded.userId },
      select: { id: true, email: true, role: true },
    });
    
    if (!user) {
      throw new AppError('User not found', 401);
    }
    
    req.user = user;
    next();
  } catch (error) {
    if (error instanceof jwt.JsonWebTokenError) {
      next(new AppError('Invalid token', 401));
    } else {
      next(error);
    }
  }
};

export const requireAdmin = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (req.user?.role !== 'ADMIN') {
    throw new AppError('Admin access required', 403);
  }
  next();
};

Rate Limiting Middleware

src/shared/middlewares/rateLimitMiddleware.ts
import rateLimit from 'express-rate-limit';

export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: 'Too many requests, please try again later.',
  standardHeaders: true,
  legacyHeaders: false,
});

export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // 5 login attempts per window
  message: 'Too many authentication attempts, please try again later.',
  skipSuccessfulRequests: true,
});

Error Handling Middleware

src/shared/middlewares/errorMiddleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/AppError';
import * as Sentry from '@sentry/node';

export const globalErrorHandler = (
  err: Error | AppError,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Log error to Sentry in production
  if (process.env.NODE_ENV === 'production') {
    Sentry.captureException(err);
  }
  
  // Handle AppError
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      message: err.message,
      ...(err.errors && { errors: err.errors }),
    });
  }
  
  // Handle Prisma errors
  if (err.name === 'PrismaClientKnownRequestError') {
    return res.status(400).json({
      success: false,
      message: 'Database error',
    });
  }
  
  // Generic error
  console.error('Error:', err);
  res.status(500).json({
    success: false,
    message: 'Internal server error',
  });
};

Upload Middleware (Cloudinary)

src/shared/middlewares/uploadMiddleware.ts
import multer from 'multer';
import { v2 as cloudinary } from 'cloudinary';
import { CloudinaryStorage } from 'multer-storage-cloudinary';

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});

const storage = new CloudinaryStorage({
  cloudinary: cloudinary,
  params: async (req, file) => {
    return {
      folder: 'pcfix-products',
      allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
      transformation: [{ width: 800, height: 800, crop: 'limit' }],
    };
  },
});

export const upload = multer({ 
  storage,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});

Module Structure (Example: Products)

Routes

src/modules/products/products.routes.ts
import { Router } from 'express';
import * as productController from './products.controller';
import { authenticate, requireAdmin } from '../../shared/middlewares/authMiddleware';
import { upload } from '../../shared/middlewares/uploadMiddleware';

const router = Router();

// Public routes
router.get('/', productController.getAllProducts);
router.get('/:id', productController.getProductById);
router.get('/category/:categoryId', productController.getProductsByCategory);
router.get('/featured', productController.getFeaturedProducts);

// Admin routes
router.post('/', authenticate, requireAdmin, upload.single('foto'), productController.createProduct);
router.put('/:id', authenticate, requireAdmin, upload.single('foto'), productController.updateProduct);
router.delete('/:id', authenticate, requireAdmin, productController.deleteProduct);

export default router;

Controller

src/modules/products/products.controller.ts
import { Request, Response, NextFunction } from 'express';
import * as productService from './products.service';
import { AppError } from '../../shared/utils/AppError';

export const getAllProducts = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { category, search, minPrice, maxPrice, page = 1, limit = 20 } = req.query;
    
    const products = await productService.getAllProducts({
      category: category as string,
      search: search as string,
      minPrice: minPrice ? Number(minPrice) : undefined,
      maxPrice: maxPrice ? Number(maxPrice) : undefined,
      page: Number(page),
      limit: Number(limit),
    });
    
    res.status(200).json({
      success: true,
      data: products,
    });
  } catch (error) {
    next(error);
  }
};

export const getProductById = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { id } = req.params;
    const product = await productService.getProductById(Number(id));
    
    if (!product) {
      throw new AppError('Product not found', 404);
    }
    
    res.status(200).json({
      success: true,
      data: product,
    });
  } catch (error) {
    next(error);
  }
};

export const createProduct = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const productData = req.body;
    
    if (req.file) {
      productData.foto = req.file.path; // Cloudinary URL
    }
    
    const product = await productService.createProduct(productData);
    
    res.status(201).json({
      success: true,
      data: product,
    });
  } catch (error) {
    next(error);
  }
};

Service

src/modules/products/products.service.ts
import { prisma } from '../../shared/database/prismaClient';
import { Prisma } from '@prisma/client';

interface GetProductsOptions {
  category?: string;
  search?: string;
  minPrice?: number;
  maxPrice?: number;
  page: number;
  limit: number;
}

export const getAllProducts = async (options: GetProductsOptions) => {
  const { category, search, minPrice, maxPrice, page, limit } = options;
  const skip = (page - 1) * limit;
  
  const where: Prisma.ProductoWhereInput = {
    deletedAt: null, // Exclude soft-deleted products
    ...(category && { categoriaId: Number(category) }),
    ...(search && {
      OR: [
        { nombre: { contains: search, mode: 'insensitive' } },
        { descripcion: { contains: search, mode: 'insensitive' } },
      ],
    }),
    ...(minPrice && { precio: { gte: minPrice } }),
    ...(maxPrice && { precio: { lte: maxPrice } }),
  };
  
  const [products, total] = await Promise.all([
    prisma.producto.findMany({
      where,
      skip,
      take: limit,
      include: {
        categoria: true,
        marca: true,
      },
      orderBy: { createdAt: 'desc' },
    }),
    prisma.producto.count({ where }),
  ]);
  
  return {
    products,
    total,
    page,
    totalPages: Math.ceil(total / limit),
  };
};

export const getProductById = async (id: number) => {
  return prisma.producto.findFirst({
    where: { id, deletedAt: null },
    include: {
      categoria: true,
      marca: true,
    },
  });
};

export const createProduct = async (data: any) => {
  return prisma.producto.create({
    data: {
      nombre: data.nombre,
      descripcion: data.descripcion,
      precio: data.precio,
      stock: data.stock,
      foto: data.foto,
      categoriaId: Number(data.categoriaId),
      marcaId: data.marcaId ? Number(data.marcaId) : null,
      isFeatured: data.isFeatured || false,
    },
  });
};

Prisma Client Setup

src/shared/database/prismaClient.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

Background Jobs (Cron Service)

src/shared/services/cron.service.ts
import cron from 'node-cron';
import { prisma } from '../database/prismaClient';

export class CronService {
  start() {
    // Delete expired reset tokens daily at 2 AM
    cron.schedule('0 2 * * *', async () => {
      console.log('🧹 Cleaning expired reset tokens...');
      await prisma.user.updateMany({
        where: {
          resetTokenExpires: { lt: new Date() },
        },
        data: {
          resetToken: null,
          resetTokenExpires: null,
        },
      });
    });
    
    // Check for abandoned carts every hour
    cron.schedule('0 * * * *', async () => {
      console.log('🛒 Checking for abandoned carts...');
      const abandonedCarts = await prisma.cart.findMany({
        where: {
          abandonedEmailSent: false,
          updatedAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
          items: { some: {} },
        },
        include: { user: true, items: true },
      });
      
      // Send abandoned cart emails (implement with email service)
      for (const cart of abandonedCarts) {
        // await emailService.sendAbandonedCartEmail(cart);
        await prisma.cart.update({
          where: { id: cart.id },
          data: { abandonedEmailSent: true },
        });
      }
    });
    
    console.log('✅ Cron jobs started');
  }
}

API Modules Overview

  • User registration and login
  • JWT token generation and refresh
  • Google OAuth integration
  • Password reset functionality
  • Token blacklisting
  • CRUD operations for products
  • Filtering, searching, pagination
  • Image upload to Cloudinary
  • Soft delete functionality
  • Featured products
  • Hierarchical category structure
  • Parent-child relationships
  • Category-based product filtering
  • Order creation and management
  • Payment processing (MercadoPago)
  • Order status workflow
  • Shipping integration (Zipnova)
  • Invoice generation
  • Add/remove items
  • Update quantities
  • Abandoned cart tracking
  • Cart persistence
  • Support ticket creation
  • Admin ticket management
  • Service items catalog
  • Response system
  • Sales analytics
  • Revenue reports
  • Product performance metrics
  • User statistics

Security Features

JWT Authentication

Stateless authentication with access and refresh tokens

Password Hashing

Bcrypt hashing with salt rounds for secure password storage

Rate Limiting

Protection against brute force and DDoS attacks

Helmet.js

HTTP header security (XSS, clickjacking, etc.)

CORS

Configurable cross-origin resource sharing

Input Validation

Zod schema validation on all endpoints

Testing

src/modules/products/products.test.ts
import { describe, it, expect, vi } from 'vitest';
import request from 'supertest';
import app from '../../server';

describe('Products API', () => {
  it('GET /api/products returns products list', async () => {
    const response = await request(app)
      .get('/api/products')
      .expect(200);
    
    expect(response.body.success).toBe(true);
    expect(Array.isArray(response.body.data.products)).toBe(true);
  });
  
  it('GET /api/products/:id returns single product', async () => {
    const response = await request(app)
      .get('/api/products/1')
      .expect(200);
    
    expect(response.body.success).toBe(true);
    expect(response.body.data).toHaveProperty('nombre');
  });
});

Next Steps

Database Schema

Explore the Prisma schema

Frontend

Connect to the Astro frontend

Deployment

Deploy to Railway

Build docs developers (and LLMs) love