Création d'une API avec TypeScript¶
Table des matières¶
- Création d'une API avec TypeScript
Prérequis¶
Avant de commencer, assurez-vous d'avoir installé les éléments suivants sur votre machine :
- Node.js (version 14 ou supérieure)
- npm (généralement inclus avec Node.js)
- Un éditeur de code, tel que Visual Studio Code
Introduction¶
Dans ce guide, nous allons créer une API RESTful simple en utilisant TypeScript avec le framework Express.js. Nous allons couvrir les étapes de configuration du projet, la création de routes, la gestion des requêtes et des réponses, ainsi que l'utilisation de TypeScript pour améliorer la qualité du code.
Tous les exemples de se guide sont basés sur mon API open-source : Econoris API
Création du projet¶
-
créer le dossier du projet et y accéder :
mkdir my-api && cd my-api -
Répondez aux questions pour initialiser le projet
- À la question
entry pointrépondez "src/server.ts".
- À la question
- Une fois le projet initialisé, vous devriez voir un fichier
package.jsondans le répertoire du projet. Si c'est le cas, passez à l'étape suivante.
Configuration du projet¶
- Les dépendances sont à installer en fonction des besoins du projet mais certaine dépendances sont quasi obligatoire pour un projet TypeScript avec Express.js afin de fonctionner correctement et d'avoir une bonne structure de code.
- Liste des dépendances essentielles :
-
Liste des dépendances de développement essentielles :
- @types/node : les types TypeScript pour Node.js.
- @types/express : les types TypeScript pour Express.js.
- @types/jest : les types TypeScript pour Jest.
- typescript : le compilateur TypeScript.
- ts-node : pour exécuter des fichiers TypeScript directement.
- nodemon : pour redémarrer automatiquement le serveur lors de modifications.
- jest : le framework de test.
- ts-jest : pour utiliser Jest avec TypeScript.
- swagger-jsdoc : pour générer la documentation Swagger à partir de commentaires JSDoc.
- swagger-ui-express : pour servir l'interface utilisateur Swagger.
-
Pour installer toutes ces dépendances, exécutez les commandes suivantes :
npm install express zod dotenv npm install --save-dev @types/node @types/express @types/jest typescript ts-node nodemon jest ts-jest swagger-jsdoc swagger-ui-express -
Dans votre
package.json, ajoutez les scripts suivants pour faciliter le développement et les tests :{ ... "scripts": { "build": "tsc", "start": "node dist/server.js", "dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts", "dev-docker": "docker compose -f docker-compose.dev.yml up --build", "clean": "rm -rf dist", "clean-docker": "docker compose -f docker-compose.dev.yml down --volumes --rmi all", "test": "jest" } ... } -
Après avoir installé les dépendances, vous pouvez initialiser TypeScript dans votre projet en exécutant la commande suivante :
npx tsc --init -
Ajoutez ses deux lignes dans le fichier
tsconfig.json{ "include": ["src/**/*.ts"], // Ajoutez cette ligne "exclude": ["node_modules", "dist", "tests", "jest.config.ts"], // Ajoutez cette ligne "compilerOptions": { ... "rootDir": "./src", // Décommentez cette ligne "outDir": "./dist", // Décommentez cette ligne ... } } -
Créer le fichier
jest.config.tsà la racine du projet avec le contenu suivant :import type { Config } from 'jest'; const config: Config = { preset: 'ts-jest', testEnvironment: 'node', // Emplacement des fichiers de tests roots: ['<rootDir>/tests/'], // Motif de nommage des fichiers de tests testRegex: ".*\\.spec\\.ts$", // Mappe les alias TypeScript aux chemins réels moduleNameMapper: { "^@/(.*)$": "<rootDir>/src/$1" // (@ --> src/) }, // Fichier exécuté avant les tests (setup global) setupFilesAfterEnv: ["<rootDir>/tests/setup.ts"], // Options utiles clearMocks: true, // Réinitialise les mocks entre chaque test verbose: true, // Affiche des informations détaillées lors de l'exécution des tests // Configuration de la couverture de code collectCoverage: false, // Active la collecte de couverture de code coverageDirectory: "coverage", // Répertoire de sortie pour les rapports de couverture coverageReporters: ["text", "lcov"], // Formats de rapport de couverture collectCoverageFrom: [ // Fichiers à inclure dans la couverture "<rootDir>/src/**/*.ts", // Inclure tous les fichiers TypeScript dans src "!<rootDir>/src/app.ts", // Exclure le fichier d'entrée principal "!<rootDir>/src/server.ts", // Exclure les fichiers index "!<rootDir>/src/swagger/**", // Exclure les fichiers Swagger "!<rootDir>/src/config/**", // Exclure les fichiers de configuration "!<rootDir>/src/**/*.types.ts", // Exclure les fichiers de types ] }; export default config; -
Créer le fichier
.dockerignoreà la racine du projet avec le contenu suivant :.git .env .env.* -
Créer le fichier
Dockerfileà la racine du projet avec le contenu suivant :- Plus de détails sur la configuration de sécurité Docker dans le guide de déploiement : Mise en production d'une application dockerisée
# Étape 1 : Build avec TypeScript FROM node:24-alpine AS builder WORKDIR /app # Copier le code source COPY tsconfig.json ./ COPY package*.json ./ COPY ./src ./src COPY ./public ./public # Supprimer les fichiers sensibles s'ils existent RUN rm -f .env* public/.env* src/.env* # Installer les dépendances RUN npm ci # Build TypeScript → JavaScript RUN npm run build # Étape 2 : Image finale FROM node:24-alpine AS runner WORKDIR /app # Copier uniquement les fichiers nécessaires à l'exécution COPY package*.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist COPY --from=builder /app/public ./public # Ajout d'un utilisateur non-root avec UID/GID fixes RUN addgroup -g 1800 -S myapigroup && adduser -u 1800 -S myapiuser -G myapigroup USER myapiuser # Par défaut : lance le serveur CMD ["node", "dist/server.js"] -
Créer le fichier
docker-compose.ymlà la racine du projet avec le contenu suivant :- Plus de détails sur la configuration de sécurité Docker dans le guide de déploiement : Mise en production d'une application dockerisée
services: my-api: image: my-api:latest build: context: . dockerfile: Dockerfile restart: always env_file: - .env networks: - my-api-net ports: - ${APP_PORT:-}:80 security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - NET_BIND_SERVICE read_only: true user: "1800:1800" tmpfs: - /tmp:exec,nosuid,nodev - /app/dist/swagger/:rw,uid=1800,gid=1800 healthcheck: test: ["CMD-SHELL", "wget -q --spider http://econoris-server:80/ || exit 1"] interval: 10s timeout: 5s retries: 5 volumes: db_data: networks: econoris-net: driver: bridge -
Créer le fichier
docker-compose.dev.ymlà la racine du projet avec le contenu suivant :- Plus de détails sur la configuration de sécurité Docker dans le guide de déploiement : Mise en production d'une application dockerisée
services: my-api: image: node:24-alpine working_dir: /app env_file: .env volumes: - ./:/app/ - ./node_modules:/app/node_modules command: sh -c "cd /app && npm run dev" networks: - my-api-net ports: - ${APP_PORT}:80 user: "1800:1800" tmpfs: - /tmp:exec,nosuid,nodev - /app/src/swagger/json:rw,uid=1800,gid=1800 volumes: db_data: networks: api-net: driver: bridge -
Créer le fichier
.envà la racine du projet avec le contenu suivant :APP_PORT=3000 NODE_ENV=development -
Créer le dossier
srcà la racine du projet. C'est ici que tout le code source TypeScript de l'API sera placé. - Créer le dossier
publicà la racine du projet. C'est ici que tous les fichiers publics (comme les icônes, etc.) seront placés. - Créer le dossier
testsà la racine du projet. C'est ici que tous les tests unitaires et d'intégration seront placés.
Architecture du projet¶
-
À ce stade, votre structure de projet devrait ressembler à ceci :
my-api ├── node_modules # Généré par npm ├── public # Fichiers publics (ex: icones, etc.) ├── src # Code source TypeScript │ ├── config # Fichiers de configuration │ ├── core # Logique centrale de l'application │ ├── modules # Modules fonctionnels de l'application │ ├── app.ts # Point d'entrée de l'application │ └── server.ts # Serveur Express ├── tests # Tests unitaires et d'intégration | ├── modules # Tests pour les modules | └── setup.ts # Configuration des tests ├── .env # Fichier de configuration des variables d'environnement ├── .dockerignore # Fichier Docker ignore ├── Dockerfile # Fichier Docker ├── docker-compose.dev.yml # Fichier Docker Compose pour le développement ├── docker-compose.yml # Fichier Docker Compose pour la production ├── package.json # Fichier de configuration npm ├── package-lock.json # Généré par npm ├── jest.config.ts # Configuration de Jest ├── tsconfig.json # Configuration de TypeScript └── README.md # Présentation du projet- Les dossiers
tests,srcsont un miroir l'un de l'autre pour faciliter la navigation entre le code source et les tests associés. - Le dossier
configcontient les fichiers de configuration de l'application, comme la configuration de la base de données, les variables d'environnement, etc. - Le dossier
corecontient la logique centrale de l'application, comme la gestion des erreurs, la configuration de la base de données, les middlewares globaux, etc. - Le dossier
modulescontient les différentes fonctionnalités de l'application, chaque fonctionnalité étant organisée dans son propre sous-dossier avec ses routes, contrôleurs, services et modèles. - Le fichier
app.tsest le point d'entrée principal de l'application où l'instance Express est créée et configurée. - Le fichier
server.tsest responsable du démarrage du serveur Express ainsi que de la connexion à la base de données et les possibles cron jobs.
- Les dossiers
Dans le dossier src nous allons opter pour une architecture modulaire par feature aussi appeler domain-driven design (DDD) qui permet de mieux organiser le code en regroupant les fonctionnalités par domaine métier.
- Image complète de l'architecture du projet à la fin du guide (Pour l'exemple d'Econoris).
Econoris_server
├── Database
│ ├── Dockerfile
│ ├── functions
│ │ └── 001-update_updated_at.sql
│ ├── migrations
│ ├── schema
│ │ └── 102-operations.sql
│ ├── seed
│ ├── triggers
│ │ └── 201-update_updated_at.sql
│ └── views
├── public
│ ├── econoris_logo-1024.png
│ ├── econoris_logo-144.png
│ ├── econoris_logo-16.png
│ ├── econoris_logo-192.png
│ ├── econoris_logo-512.png
│ ├── econoris_logo-72.png
│ ├── econoris_logo-96.png
│ ├── favicon.ico
│ └── favicon.png
├── src
│ ├── app.ts
│ ├── config
│ │ └── AppConfig.ts
│ ├── core
│ │ ├── cron
│ │ │ ├── generate_subscription_operations.job.ts
│ │ │ └── validate_operations.job.ts
│ │ ├── email
│ │ │ ├── error.email.ts
│ │ │ ├── mailer.ts
│ │ │ └── mailTemplate.ts
│ │ ├── middlewares
│ │ │ ├── default_route.middleware.ts
│ │ │ ├── error.middleware.ts
│ │ │ ├── helmet_http_headers.middleware.ts
│ │ │ ├── rate_limiter.middleware.ts
│ │ │ └── validators
│ │ │ ├── auth_validator.middleware.ts
│ │ │ ├── body_validator.middleware.ts
│ │ │ └── params_query_validator.middleware.ts
│ │ ├── models
│ │ │ ├── AppError.model.ts
│ │ │ └── Database.model.ts
│ │ └── utils
│ │ └── logger.ts
│ ├── modules
│ │ ├── auth
│ │ │ ├── auth.schema.ts
│ │ │ └── auth.types.ts
│ │ ├── operations
│ │ │ ├── operations.controller.ts
│ │ │ ├── operations.repository.ts
│ │ │ ├── operations.routes.ts
│ │ │ ├── operations.schema.ts
│ │ │ ├── operations.service.ts
│ │ │ ├── operations.swagger.ts
│ │ │ └── operations.types.ts
│ ├── server.ts
│ └── swagger
│ └── swagger.ts
├── tests
│ ├── modules
│ │ ├── auth
│ │ │ └── auth.spec.ts
│ │ └── operations
│ │ └── operations.spec.ts
│ └── setup.ts
├── docker-compose.dev.yml
├── docker-compose.yml
├── Dockerfile
├── tsconfig.json
├── jest.config.ts
├── package.json
├── package-lock.json
└── README.md
Config¶
Dans le dossier config, il y aura tous se qui concerne la configuration de l'application, au minimum il y aura un object qui contiendra les variables d'environnement inscrites dans le fichier .env.
-
Créer le fichier
AppConfig.tsdans le dossierconfigavec le contenu suivant :import dotenv from 'dotenv'; dotenv.config(); interface AppConfigInterface { /* Application configuration */ readonly app_name: string; readonly app_port: number; readonly host_name: string; readonly base_url: string; readonly app_env: string; /* Database */ readonly db_uri: string; /* CORS */ readonly corsOptions: { origin: string[]; methods: string[]; credentials: boolean; allowedHeaders: string[]; }; } const config: AppConfigInterface = { /* Application configuration */ app_name: "Exemple API", app_port: 80, host_name: process.env.HOST_NAME || 'localhost', base_url: process.env.BASE_URL || 'http://localhost:80', app_env: process.env.APP_ENV?.toLowerCase() || 'dev', /* Database */ db_uri: `${process.env.DB_SCHEME}://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:5432/${process.env.DB_NAME}`, /* CORS */ corsOptions: { origin: (process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(origin => origin.trim()), methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], credentials: true, allowedHeaders: ['Content-Type', 'Authorization'], }, }; export default config;
Core¶
Dans ce dossier core, il y aura tous se qui concerne la logique centrale de l'application, comme la gestion des erreurs, la configuration de la base de données, les middlewares globaux, etc.
Dans se dossier, vous pouvez créer des sous-dossiers pour organiser la logique centrale en fonction de vos besoins.
- Les dossiers recommandés sont
- Middlewares
- Utils
- Models
Middlewares¶
Se dossier contiendra les middlewares globaux de l'application.
- Créer le dossier
middlewaresdans le dossiercorepour y placer les middlewares globaux de l'application. - Les middlewares recommandés sont
- Validator Middleware
- Errors Middleware
- Default Route Middleware
- Logger Middleware (Vous pouvez utiliser morgan ou créer votre propre middleware de journalisation simple)
Validator Middleware¶
- Créer le dossier
validatordans le dossiermiddlewarespour y placer le middleware de validation des requêtes. - Les validateurs recommandés sont
- Body Validator Middleware
- Params Validator Middleware
- Query Validator Middleware
Body Validator Middleware¶
- Créer le fichier
body_validator.middleware.tsdans le dossiervalidatorpour y placer le middleware de validation du corps des requêtes. -
Copier le contenu suivant dans le fichier
body_validator.middleware.ts:import { Request, Response, NextFunction } from "express"; import { ZodType } from "zod"; import { AppError } from "../../models/AppError.model"; /** * Middleware générique de validation du corps de la requête avec Zod. * @param schema Schéma Zod à utiliser pour la validation. * @returns Middleware Express avec req.body.validated contenant les données validées. * @throws AppError avec statut 400 si la validation échoue. */ export const bodyValidator = (schema: ZodType) => (req: Request, _res: Response, next: NextFunction) => { try { req.body = { ...req.body, validatedData: { ...req.body.validatedData, body: schema.parse(req.body) } }; next(); } catch (error) { next(new AppError("Invalid request data", 400)); } };
Params Validator Middleware¶
- Créer le fichier
params_validator.middleware.tsdans le dossiervalidatorpour y placer le middleware de validation des paramètres et des requêtes. -
Copier le contenu suivant dans le fichier
params_validator.middleware.ts:import { Request, Response, NextFunction } from "express"; import { ZodType } from "zod"; import { AppError } from "../../models/AppError.model"; /** * Middleware générique de validation des paramètres de la requête avec Zod. * @param schema Schéma Zod à utiliser pour la validation. * @returns Middleware Express avec req.body.validated contenant les données validées. * @throws AppError avec statut 400 si la validation échoue. */ export const paramsValidator = (schema: ZodType) => (req: Request, _res: Response, next: NextFunction) => { try { req.body = { ...req.body, validatedData: { ...req.body.validatedData, params: schema.parse(req.params) } }; next(); } catch (error) { next(new AppError("Invalid request data", 400)); } };
Query Validator Middleware¶
- Créer le fichier
query_validator.middleware.tsdans le dossiervalidatorpour y placer le middleware de validation des paramètres et des requêtes. -
Copier le contenu suivant dans le fichier
query_validator.middleware.ts:import { Request, Response, NextFunction } from "express"; import { ZodType } from "zod"; import { AppError } from "../../models/AppError.model"; /** * Middleware générique de validation des queries de la requête avec Zod. * @param schema Schéma Zod à utiliser pour la validation. * @returns Middleware Express avec req.body.validated contenant les données validées. * @throws AppError avec statut 400 si la validation échoue. */ export const queryValidator = (schema: ZodType) => (req: Request, _res: Response, next: NextFunction) => { try { req.body = { ...req.body, validatedData: { ...req.body.validatedData, query: schema.parse(req.query) } }; next(); } catch (error) { next(new AppError("Invalid request data", 400)); } };
Errors Middleware¶
- Créer le fichier
error.middleware.tsdans le dossiermiddlewarespour y placer le middleware de gestion des erreurs globales. -
Copier le contenu suivant dans le fichier
error.middleware.ts:import { Request, Response, NextFunction } from 'express'; import { AppError } from '../models/AppError.model'; /** * Middleware to handle errors. * @param err Error object * @param req Request * @param res Response * @param next NextFunction */ export const errorHandler = ( error: AppError, _req: Request, res: Response, _next: NextFunction ) => { res.status(error.httpStatus || 500).json(error.message || "Unknown error occurred"); };
Default Route Middleware¶
- Créer le fichier
default_route.middleware.tsdans le dossiermiddlewarespour y placer le middleware de route par défaut. -
Copier le contenu suivant dans le fichier
default_route.middleware.ts:import { Request, Response, NextFunction } from 'express'; import { AppError } from '../models/AppError.model'; /** * Middleware to handle undefined routes. * @param req Request * @param res Response * @param next NextFunction */ export const defaultRouteHandler = (_req: Request, _res: Response, next: NextFunction) => { next(new AppError("URL not found", 404)); }
Logger Middleware¶
- Créer le fichier
logger.middleware.tsdans le dossiermiddlewarespour y placer le middleware de journalisation (logger). -
Copier le contenu suivant dans le fichier
logger.middleware.ts:import { Request, Response, NextFunction } from 'express'; import { info } from '../utils/logger'; /** * Middleware to log incoming requests. * @param req Request * @param res Response * @param next NextFunction */ export const requestLogger = (req: Request, _res: Response, next: NextFunction) => { info(`Incoming request : ${req.method} ${req.url}`); next(); };
Utils¶
Dans ce dossier utils, il y aura toutes les fonctions utilitaires réutilisables dans toute l'application.
- Créer le dossier
utilsdans le dossiercorepour y placer les fonctions utilitaires réutilisables dans toute l'application. - Les utilitaires recommandés sont
- Logger.ts
Logger.ts¶
- Créer le fichier
logger.tsdans le dossierutilspour y placer une fonction utilitaire de journalisation simple. -
Copier le contenu suivant dans le fichier
logger.ts:import AppConfig from '../../config/AppConfig'; import { sendErrorEmail } from '../email/error.email'; const errorMessage = ` [❌] ${AppConfig.app_name} - ${new Date().toISOString()} |`; const warningMessage = ` [⚠️] ${AppConfig.app_name} - ${new Date().toISOString()} |`; const successMessage = ` [✅] ${AppConfig.app_name} - ${new Date().toISOString()} |`; const infoMessage = ` [❕] ${AppConfig.app_name} - ${new Date().toISOString()} |`; const debugMessage = ` [🐛] ${AppConfig.app_name} - ${new Date().toISOString()} |`; /** * Logger function to log messages based on the environment level. * @description * - If APP_ENV is 0, no logs will be displayed. * - If APP_ENV is 1, only error logs will be displayed. * - If APP_ENV is 2, warning and error logs will be displayed. * - If APP_ENV is 3, success, warning and error logs will be displayed. * - If APP_ENV is 4, info, success, warning and error logs will be displayed. * - If APP_ENV is 5, debug, info, success, warning and error logs will be displayed. * @param args elements to log */ export function error(...args: any[]) { if (!AppConfig.app_env.includes('silent')) { console.error(errorMessage, ...args); } } /** * Logger function to log messages based on the environment level. * @description * - If APP_ENV is 0, no logs will be displayed. * - If APP_ENV is 1, only error logs will be displayed. * - If APP_ENV is 2, warning and error logs will be displayed. * - If APP_ENV is 3, success, warning and error logs will be displayed. * - If APP_ENV is 4, info, success, warning and error logs will be displayed. * - If APP_ENV is 5, debug, info, success, warning and error logs will be displayed. * @param args elements to log */ export function warning(...args: any[]) { if (!AppConfig.app_env.includes('silent')) { console.warn(warningMessage, ...args); } } /** * Logger function to log messages based on the environment level. * @description * - If APP_ENV is 0, no logs will be displayed. * - If APP_ENV is 1, only error logs will be displayed. * - If APP_ENV is 2, warning and error logs will be displayed. * - If APP_ENV is 3, success, warning and error logs will be displayed. * - If APP_ENV is 4, info, success, warning and error logs will be displayed. * - If APP_ENV is 5, debug, info, success, warning and error logs will be displayed. * @param args elements to log */ export function success(...args: any[]) { if (!AppConfig.app_env.includes('silent')) { console.log(successMessage, ...args); } } /** * Logger function to log messages based on the environment level. * @description * - If APP_ENV is 0, no logs will be displayed. * - If APP_ENV is 1, only error logs will be displayed. * - If APP_ENV is 2, warning and error logs will be displayed. * - If APP_ENV is 3, success, warning and error logs will be displayed. * - If APP_ENV is 4, info, success, warning and error logs will be displayed. * - If APP_ENV is 5, debug, info, success, warning and error logs will be displayed. * @param args elements to log */ export function info(...args: any[]) { if (!AppConfig.app_env.includes('silent')) { console.info(infoMessage, ...args); } } /** * Logger function to log messages based on the environment level. * @description * - If APP_ENV is 0, no logs will be displayed. * - If APP_ENV is 1, only error logs will be displayed. * - If APP_ENV is 2, warning and error logs will be displayed. * - If APP_ENV is 3, success, warning and error logs will be displayed. * - If APP_ENV is 4, info, success, warning and error logs will be displayed. * - If APP_ENV is 5, debug, info, success, warning and error logs will be displayed. * @param args elements to log */ export function debug(...args: any[]) { if (!AppConfig.app_env.includes('silent') && AppConfig.app_env.includes('dev')) { console.debug(debugMessage, ...args); } }
Models¶
Dans ce dossier models, il y aura tous les modèles de données globaux de l'application.
- Créer le dossier
modelsdans le dossiercorepour y placer les modèles de données globaux de l'application. - Les modèles recommandés sont
- AppError.model.ts
- Database.model.ts
AppError model¶
- Créer le fichier
AppError.model.tsdans le dossiermodelspour y placer le modèle de gestion des erreurs personnalisées. -
Copier le contenu suivant dans le fichier
AppError.model.ts:/** * Custom Error interface to include HTTP status codes * @param message Error message * @param httpStatus HTTP status code */ export class AppError extends Error { httpStatus: number; constructor(message: string = "Internal Server Error", httpStatus: number = 500) { super(message); this.httpStatus = httpStatus; } }
Database model¶
- Créer le fichier
Database.model.tsdans le dossiermodelspour y placer le modèle de configuration de la base de données. -
Copier le contenu suivant dans le fichier
Database.model.ts:import pg from 'pg'; import * as logger from "../utils/logger"; import { AppError } from './AppError.model'; /*=========================*/ /* Abstract Database Model */ /*=========================*/ /** * Database schema defining the IDatabase interface and Query type. */ export interface IDatabase { connect(): Promise<void>; close(): Promise<void>; } /** * Query type representing a database query with text and optional values. */ export type Query = { text: string; values: (string | number | boolean | null)[] | undefined; }; /** * Abstract Database class implementing the IDatabase interface. * This class provides methods to connect to, close, and execute queries on the database. * @abstract * @implements {IDatabase} * @method connect - Connects to the database. * @method close - Closes the database connection. * @method static execute - Executes a query on the database. */ export abstract class ADatabase implements IDatabase { protected static client: pg.Client | null = null; abstract connect(): Promise<void>; abstract close(): Promise<void>; /** * Executes a query on the database * @async * @param query The query to execute * @returns Array of rows returned by the query or an empty array if no rows are returned or an error occurs * @throws AppError if the database is not connected or if the query fails */ static async execute<T = any>(query: Query): Promise<T[]> { try { if (!this.client) throw new AppError('Database not connected'); const res = await this.client.query(query.text, query.values); if (res.rows === null) { throw new AppError('Database query failed'); } return res.rows || []; } catch (error) { throw (error instanceof AppError) ? error : new AppError("Database unknown error"); } } } /*================*/ /* Database Model */ /*================*/ /** * Database class to manage PostgreSQL connections and queries. * @extends ADatabase * @method connect - Connects to the database. * @method close - Closes the database connection. * @method static execute - Executes a query on the database. * @example * const db = new Database('postgresql://user:password@localhost:5432/mydb'); * const rows = await Database.execute({ text: 'SELECT * FROM mytable', values: [] }); */ export class Database extends ADatabase { constructor(private dburi: string | object) { super(); } /** * Connects the server to the database * @async * @param dburi The database URI * @example dburi = "postgresql://<user>:<password>@<host>:<port>/<db_name>" * @returns true if connected successfully, otherwise false */ async connect(): Promise<void> { try { if (Database.client) return; Database.client = new pg.Client(this.dburi); await Database.client.connect(); Database.client.on('error', (error: Error) => logger.error('DATABASE ERROR :', error)); Database.client.on('end', () => logger.info('DATABASE CONNECTION CLOSED')); } catch (error) { throw new AppError('Database connection failed'); } } /** * Closes the database connection * @async * @returns boolean true if the connection was closed successfully, otherwise false */ async close(): Promise<void> { try { if (!Database.client) return; await Database.client.end(); Database.client = null; } catch (error) { throw new AppError('Database disconnection failed'); } } }
Modules¶
Dans ce dossier modules, il y aura toutes les fonctionnalités de l'application, chaque fonctionnalité étant organisée dans son propre sous-dossier avec ses routes, contrôleurs, services et modèles.
Voici l'architecture recommandée pour chaque module :
example_module
├── example_module.schema.ts # Représente les schémas de validation Zod pour le module
├── example_module.types.ts # Définit les types TypeScript spécifiques au module (à l'aide de z.infer(<ZodSchema>))
├── example_module.swagger.ts # Définit la documentation Swagger pour le module
├── example_module.routes.ts # URI routes pour le module avec les middlewares associés (validators, puis appel du controller)
├── example_module.controller.ts # Récupère les données de la requête, appelle le service approprié, puis renvoie la réponse au client
├── example_module.service.ts # Contient la logique métier principale du module
└── example_module.repository.ts # Gère l'accès aux données et les opérations CRUD pour le module
Vous trouverez ci-dessous une brève description de chaque fichier ainsi que des exemples de contenu pour un module fictif appelé operations et qui gère des opérations financières basiques.
Schema¶
Le fichier operations.schema.ts représente les schémas de validation Zod pour le module operations.
import { z, ZodDate, ZodNumber } from "zod";
/*========*/
/* SELECT */
/*========*/
export const OperationsSchema = z.object({
id: z.int().min(1),
levy_date: z.date().default(new Date()),
label: z.string().trim().min(1).max(255),
amount: z.number(),
category: z.string().trim().min(1).max(255),
source: z.string().trim().max(255).nullable().default(null),
destination: z.string().trim().max(255).nullable().default(null),
costs: z.number().default(0.0),
is_validate: z.boolean().default(false),
user_id: z.int().min(1),
subscription_id: z.int().min(1).nullable().default(null),
created_at: z.date().readonly(),
updated_at: z.date().readonly(),
});
/*========*/
/* INSERT */
/*========*/
export const OperationsInsertSchema = OperationsSchema.extend({
user_id: OperationsSchema.shape.user_id.optional(),
levy_date: z.preprocess<unknown, ZodDate>(
(val) => typeof val === "string" ? new Date(val) : val,
z.date(),
),
}).omit({
id: true,
created_at: true,
updated_at: true,
});
/*========*/
/* UPDATE */
/*========*/
export const OperationsIdUpdateSchema = z.object({
id: z.preprocess<unknown, ZodNumber>(
(val) => typeof val === "string" ? Number(val.trim()) : val,
z.int().min(1),
),
});
export const OperationsUpdateSchema = OperationsInsertSchema.extend({
id: OperationsSchema.shape.id,
});
/*========*/
/* DELETE */
/*========*/
export const OperationsIdDeleteSchema = OperationsIdUpdateSchema;
Types¶
Le fichier operations.types.ts définit les types TypeScript spécifiques au module. Notez l'utilisation de z.infer(<ZodSchema>) pour générer automatiquement les types à partir des schémas Zod définis dans le fichier operations.schema.ts.
import { z } from "zod";
import { OperationsSchema, OperationsInsertSchema, OperationsUpdateSchema, OperationsIdUpdateSchema, OperationsIdDeleteSchema } from "./operations.schema";
/* SELECT */
export type Operation = z.infer<typeof OperationsSchema>;
/* INSERT */
export type OperationInsert = z.infer<typeof OperationsInsertSchema>;
/* UPDATE */
export type OperationsIdUpdate = z.infer<typeof OperationsIdUpdateSchema>;
export type OperationUpdate = z.infer<typeof OperationsUpdateSchema>;
/* DELETE */
export type OperationsIdDelete = z.infer<typeof OperationsIdDeleteSchema>;
Swagger¶
Le fichier operations.swagger.ts définit la documentation Swagger pour le module. Se sont simplement des commentaires qui permettront de générer automatiquement une documentation interactive pour l'API.
/*========*/
/* SELECT */
/*========*/
/**
* @swagger
* components:
* schemas:
* Operations:
* type: object
* required:
* - id
* - levy_date
* - label
* - amount
* - category
* - source
* - destination
* - costs
* - is_validate
* - user_id
* - subscription_id
* - created_at
* - updated_at
* properties:
* id:
* type: number
* exemple: 1
* levy_date:
* type: string
* exemple: "2024-01-01T00:00:00.000Z"
* label:
* type: string
* exemple: "Sample Operation"
* amount:
* type: number
* exemple: 100.00
* category:
* type: string
* exemple: "Income"
* source:
* type: string
* exemple: "Job"
* destination:
* type: string
* exemple: "Bank"
* costs:
* type: number
* exemple: 2.50
* is_validate:
* type: boolean
* exemple: true
* user_id:
* type: number
* exemple: 1
* subscription_id:
* type: number|null
* exemple: 1
* created_at:
* type: string
* exemple: "2024-01-01T00:00:00.000Z"
* updated_at:
* type: string
* exemple: "2024-01-01T00:00:00.000Z"
*/
/*========*/
/* INSERT */
/*========*/
/**
* @swagger
* components:
* schemas:
* OperationsInsert:
* type: object
* required:
* - levy_date
* - label
* - amount
* - category
* properties:
* levy_date:
* type: string
* exemple: "2024-01-01T00:00:00.000Z"
* label:
* type: string
* exemple: "Sample Operation"
* amount:
* type: number
* exemple: 100.00
* category:
* type: string
* exemple: "Income"
* source:
* type: string
* exemple: "Job"
* destination:
* type: string
* exemple: "Bank"
* costs:
* type: number
* exemple: 2.50
* is_validate:
* type: boolean
* exemple: true
*/
/*========*/
/* UPDATE */
/*========*/
/**
* @swagger
* components:
* schemas:
* OperationsUpdate:
* type: object
* required:
* - levy_date
* - label
* - amount
* - category
* - source
* - destination
* - costs
* - is_validate
* properties:
* levy_date:
* type: string
* exemple: "2024-01-01T00:00:00.000Z"
* label:
* type: string
* exemple: "Sample Operation"
* amount:
* type: number
* exemple: 100.00
* category:
* type: string
* exemple: "Income"
* source:
* type: string
* exemple: "Job"
* destination:
* type: string
* exemple: "Bank"
* costs:
* type: number
* exemple: 2.50
* is_validate:
* type: boolean
* exemple: true
*/
Routes¶
Le fichier operations.routes.ts définit les URI routes pour le module avec les middlewares associés (validators, puis appel du controller). Dans se fichier ont aura à la fois les routes ainsi que les commentaires Swagger pour documenter chaque endpoint.
Vous noterez l'utilisation des middlewares de validation du corps, des paramètres et des requêtes avant d'appeler les contrôleurs appropriés.
import { Router } from "express";
import * as OperationsController from "./operations.controller";
import { bodyValidator } from "../../core/middlewares/validators/body_validator.middleware";
import { OperationsIdDeleteSchema, OperationsIdUpdateSchema, OperationsInsertSchema, OperationsUpdateSchema } from "./operations.schema";
import { paramsQueryValidator } from "../../core/middlewares/validators/params_query_validator.middleware";
const router = Router();
/*========*/
/* SELECT */
/*========*/
/**
* @swagger
* /operations:
* get:
* tags:
* - Operations
* summary: Retrieve a list of operations of the authenticated user
* description: Retrieve a list of operations associated with the authenticated user.
* parameters:
* - in: headers
* name: Authorization
* required: true
* schema:
* type: string
* example: "Bearer <token>"
* responses:
* 200:
* description: A list of operations
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Operations'
* 204:
* description: No content. No operations found.
* 401:
* description: Unauthorized access.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error401'
* 500:
* description: Internal server error. Please create an issue on Github
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error500'
*/
router.get('/', OperationsController.selectOperations);
/*========*/
/* INSERT */
/*========*/
/**
* @swagger
* /operations:
* post:
* tags:
* - Operations
* summary: Create a new operation for the authenticated user
* description: Create a new operation associated with the authenticated user.
* parameters:
* - in: headers
* name: Authorization
* required: true
* schema:
* type: string
* example: "Bearer <token>"
* - in: body
* name: operation
* required: true
* description: Operation object that needs to be added
* schema:
* $ref: '#/components/schemas/OperationsInsert'
* responses:
* 201:
* description: Created operation
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Operations'
* 400:
* description: Bad request. Please check the input data.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error400'
* 401:
* description: Unauthorized access.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error401'
* 500:
* description: Internal server error. Please create an issue on Github
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error500'
*/
router.post('/', bodyValidator(OperationsInsertSchema), OperationsController.insertOperations);
/*========*/
/* UPDATE */
/*========*/
/**
* @swagger
* /operations:
* put:
* tags:
* - Operations
* summary: Update an existing operation for the authenticated user
* description: Update an existing operation associated with the authenticated user.
* parameters:
* - in: headers
* name: Authorization
* required: true
* schema:
* type: string
* example: "Bearer <token>"
* - in: path
* name: id
* required: true
* schema:
* type: integer
* example: 1
* - in: body
* name: operation
* required: true
* description: Operation object that needs to be updated
* schema:
* $ref: '#/components/schemas/OperationsUpdate'
* responses:
* 200:
* description: Updated operation
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Operations'
* 400:
* description: Bad request. Please check the input data.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error400'
* 401:
* description: Unauthorized access.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error401'
* 500:
* description: Internal server error. Please create an issue on Github
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error500'
*/
router.put('/:id', paramsQueryValidator(OperationsIdUpdateSchema), bodyValidator(OperationsUpdateSchema), OperationsController.updateOperations);
/*========*/
/* DELETE */
/*========*/
/**
* @swagger
* /operations:
* delete:
* tags:
* - Operations
* summary: Delete an existing operation for the authenticated user
* description: Delete an existing operation associated with the authenticated user.
* parameters:
* - in: headers
* name: Authorization
* required: true
* schema:
* type: string
* example: "Bearer <token>"
* - in: path
* name: id
* required: true
* schema:
* type: integer
* example: 1
* responses:
* 200:
* description: Deleted operation
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Operations'
* 400:
* description: Bad request. Please check the input data.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error400'
* 401:
* description: Unauthorized access.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error401'
* 500:
* description: Internal server error. Please create an issue on Github
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/error500'
*/
router.delete('/:id', paramsQueryValidator(OperationsIdDeleteSchema), OperationsController.deleteOperations);
export default router;
Controller¶
Le fichier Operations.controller.ts récupère les données de la requête, appelle le service approprié, puis renvoie la réponse au client
import { Request, Response, NextFunction } from 'express';
import * as OperationsService from './operations.service';
import { OperationInsert, OperationUpdate } from './operations.types';
/*========*/
/* SELECT */
/*========*/
/**
* Get all operations for a user.
* @param req.body.user The user object containing the user ID.
* @param res The response object.
* @param next The next middleware function.
*/
export const selectOperations = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId: number = req.body.user.id;
const operations = await OperationsService.selectOperations(userId);
res.status(operations.length ? 200 : 204).json(operations);
} catch (error) {
next(error);
}
};
/*========*/
/* INSERT */
/*========*/
/**
* Insert a new operation for a user.
* @param req.body.user The user object containing the user ID.
* @param req.body.validatedData.body The validated operation data to insert.
* @param res The response object.
* @param next The next middleware function.
*/
export const insertOperations = async (req: Request, res: Response, next: NextFunction) => {
try {
const operationData: OperationInsert = { ...req.body.validatedData.body, user_id: req.body.user.id };
const newOperation = await OperationsService.insertOperations(operationData);
res.status(201).json(newOperation);
} catch (error) {
next(error);
}
};
/*========*/
/* UPDATE */
/*========*/
/**
* Update an existing operation for a user.
* @param req.body.user The user object containing the user ID.
* @param req.body.validatedData.body The validated operation data to update.
* @param res The response object.
* @param next The next middleware function.
*/
export const updateOperations = async (req: Request, res: Response, next: NextFunction) => {
try {
const operationData: OperationUpdate = { ...req.body.validatedData.body, id: req.body.validatedData.params.id, user_id: req.body.user.id };
const operations = await OperationsService.updateOperations(operationData);
res.status(200).json(operations);
} catch (error) {
next(error);
}
};
/*========*/
/* DELETE */
/*========*/
/**
* Delete an operation for a user.
* @param req.body.user The user object containing the user ID.
* @param req.body.validatedData.params.id The ID of the operation to delete.
* @param res The response object.
* @param next The next middleware function.
*/
export const deleteOperations = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId: number = req.body.user.id;
const operationId: number = req.body.validatedData.params.id;
const operations = await OperationsService.deleteOperations(userId, operationId);
res.status(200).json(operations);
} catch (error) {
next(error);
}
};
Service¶
Le fichier operations.service.ts contient la logique métier principale du module. Se fichier peut être plus ou moins complexe en fonction des besoins métier.
import { Operation, OperationInsert, OperationUpdate } from './operations.types';
import * as OperationsRepository from './operations.repository';
import { OperationsInsertSchema, OperationsSchema, OperationsUpdateSchema } from './operations.schema';
import { ZodError } from 'zod';
import { AppError } from '../../core/models/AppError.model';
import { addDays, addWeeks, addMonths, isAfter, endOfMonth } from 'date-fns';
import { updateSubscriptionsLastGeneratedAt } from '../subscriptions/subscriptions.service';
import { Subscription } from '../subscriptions/subscriptions.types';
import { SubscriptionsSchema } from '../subscriptions/subscriptions.schema';
/*========*/
/* SELECT */
/*========*/
/**
* Get all operations for a user.
* @param userId The ID of the user.
* @returns An array of Operation objects.
* @throws AppError if there is an issue retrieving the operations.
*/
export async function selectOperations(userId: number): Promise<Operation[]> {
try {
const operations = await OperationsRepository.selectOperations(userId);
return OperationsSchema.array().parse(operations);
} catch (error) {
throw (error instanceof ZodError) ? new AppError("Failed to parse operations", 500) : error;
}
}
/**
* Get all invalid operations.
* @returns An array of Operation objects.
* @throws AppError if there is an issue retrieving the operations.
*/
export async function selectAllOperationsInvalidate(): Promise<Operation[]> {
try {
const operations = await OperationsRepository.selectAllOperationsInvalidate();
return OperationsSchema.array().parse(operations);
} catch (error) {
throw (error instanceof ZodError) ? new AppError("Failed to parse operations", 500) : error;
}
}
/*========*/
/* INSERT */
/*========*/
/**
* Insert a new operation for a user.
* @param operationData The operation data to insert.
* @returns The newly created Operation object.
* @throws AppError if there is an issue inserting the operation.
*/
export async function insertOperations(operationData: OperationInsert): Promise<Operation> {
try {
const operations = await OperationsRepository.insertOperations(operationData);
return OperationsSchema.parse(operations);
} catch (error) {
throw (error instanceof ZodError) ? new AppError("Failed to parse operation (operation inserted successfully)", 500) : error;
}
}
/**
* Insert multiple operations for a user in bulk.
* @param operations The array of operation data to insert.
* @throws AppError if there is an issue inserting the operations.
*/
export async function insertBulkOperations(operationsData: OperationInsert[]): Promise<void> {
if (operationsData.length === 0) return;
try {
const operations: OperationInsert[] = OperationsInsertSchema.array().parse(operationsData);
const chunkSize = 500;
if (operations.length > chunkSize) {
const chunks: OperationInsert[][] = [];
for (let i = 0; i < operations.length; i += chunkSize) {
chunks.push(operations.slice(i, i + chunkSize));
}
await Promise.all(chunks.map((chunk: OperationInsert[]) => OperationsRepository.insertBulkOperations(chunk)));
return;
}
await OperationsRepository.insertBulkOperations(operations);
} catch (error) {
throw (error instanceof ZodError) ? new AppError("Failed to parse operations bulk", 500) : error;
}
}
/**
* Generate missing operations for a subscription up to the current date.
* @param subscription The subscription object.
* @throws AppError if there is an issue generating the operations.
*/
export async function insertMissingOperations(subscription: Subscription): Promise<void> {
let parsedSubscription: Subscription;
try {
parsedSubscription = SubscriptionsSchema.parse(subscription);
} catch (error) {
throw new AppError("Failed to parse subscription", 500);
}
try {
const operationsToInsert: OperationInsert[] = [];
let currentDate: Date = parsedSubscription.last_generated_at ?? parsedSubscription.start_date;
const now: Date = new Date();
const endOfCurrentMonth: Date = endOfMonth(now);
// Ajout de l'opération pour le mois de début si aucune opération n'a encore été générée
if (!parsedSubscription.last_generated_at) {
let is_validate = currentDate <= now;
operationsToInsert.push({
levy_date: currentDate,
label: parsedSubscription.label,
amount: parsedSubscription.amount,
category: parsedSubscription.category,
source: parsedSubscription.source,
destination: parsedSubscription.destination,
costs: parsedSubscription.costs,
is_validate,
subscription_id: parsedSubscription.id,
user_id: parsedSubscription.user_id,
});
}
while (true) {
let nextDate = getNextDate(currentDate, parsedSubscription.interval_unit, parsedSubscription.interval_value);
if (parsedSubscription.interval_unit === 'months' && parsedSubscription.day_of_month) {
nextDate.setDate(parsedSubscription.day_of_month);
}
if (parsedSubscription.end_date && isAfter(nextDate, parsedSubscription.end_date)) break;
if (isAfter(nextDate, endOfCurrentMonth)) break;
let is_validate = nextDate <= now;
operationsToInsert.push({
levy_date: nextDate,
label: parsedSubscription.label,
amount: parsedSubscription.amount,
category: parsedSubscription.category,
source: parsedSubscription.source,
destination: parsedSubscription.destination,
costs: parsedSubscription.costs,
is_validate,
subscription_id: parsedSubscription.id,
user_id: parsedSubscription.user_id,
});
currentDate = nextDate;
}
if (operationsToInsert.length > 0) {
await insertBulkOperations(operationsToInsert);
await updateSubscriptionsLastGeneratedAt(parsedSubscription.id, currentDate);
}
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to generate missing operations", 500);
}
}
/*========*/
/* UPDATE */
/*========*/
/**
* Update an existing operation for a user.
* @param operationData The operation data to update.
* @returns The updated Operation object.
*/
export async function updateOperations(operationData: OperationUpdate): Promise<Operation> {
try {
const operations = await OperationsRepository.updateOperations(operationData);
return OperationsSchema.parse(operations);
} catch (error) {
throw (error instanceof ZodError) ? new AppError("Failed to parse operation (operation updated successfully)", 500) : error;
}
}
/**
* Update multiple operations validate status in bulk.
* @param operations The array of operation data to update.
* @param isValidate The validate status to set.
* @throws AppError if there is an issue updating the operations.
*/
export async function updateBulkOperationsValidate(operations: OperationUpdate[], isValidate: boolean): Promise<void> {
try {
const parsedOperations = OperationsUpdateSchema.array().parse(operations);
const chunkSize = 500;
if (parsedOperations.length > chunkSize) {
const chunks: OperationUpdate[][] = [];
for (let i = 0; i < parsedOperations.length; i += chunkSize) {
chunks.push(parsedOperations.slice(i, i + chunkSize));
}
await Promise.all(chunks.map((chunk: OperationUpdate[]) => OperationsRepository.updateBulkOperationsValidate(chunk, isValidate)));
return;
}
await OperationsRepository.updateBulkOperationsValidate(parsedOperations, isValidate);
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to update operations validate status", 500);
}
}
/*========*/
/* DELETE */
/*========*/
/**
* Delete an operation for a user.
* @param userId The ID of the user.
* @param operationId The ID of the operation to delete.
* @returns The deleted Operation object.
* @throws AppError if there is an issue deleting the operation.
*/
export async function deleteOperations(userId: number, operationId: number): Promise<Operation> {
try {
const operations = await OperationsRepository.deleteOperations(userId, operationId);
return OperationsSchema.parse(operations);
} catch (error) {
throw (error instanceof ZodError) ? new AppError("Failed to parse operation (operation deleted successfully)", 500) : error;
}
}
/*===========*/
/* UTILITIES */
/*===========*/
/**
* Get the next date based on the current date, interval unit, and value.
* @param current Current date
* @param unit Interval unit ('days', 'weeks', 'months')
* @param value Interval value
* @returns The next date
* @throws AppError if the interval unit is unsupported
*/
function getNextDate(current: Date, unit: string, value: number): Date {
switch (unit) {
case 'days':
return addDays(current, value);
case 'weeks':
return addWeeks(current, value);
case 'months':
return addMonths(current, value);
default:
throw new AppError(`Unsupported interval unit : ${unit}`, 400);
}
}
Repository¶
Le fichier operations.repository.ts gère l'accès aux données et les opérations CRUD pour le module. Si les données sont stockées dans une base de données, ce fichier contiendra les requêtes SQL nécessaires pour interagir avec la base de données, sinon, ce fichier contiendra la logique pour interagir avec le système de stockage de données choisi.
import { AppError } from "../../core/models/AppError.model";
import { Database } from "../../core/models/Database.model";
import { Operation, OperationInsert, OperationUpdate } from "./operations.types";
/*========*/
/* SELECT */
/*========*/
/**
* Get all operations for a user.
* @param userId The ID of the user.
* @returns An array of Operation objects.
* @throws AppError if there is an issue retrieving the operations.
*/
export async function selectOperations(userId: number): Promise<Operation[]> {
try {
let query = "SELECT * FROM operations WHERE user_id = $1;";
let values = [userId];
const operations = await Database.execute<Operation>({ text: query, values: values });
/* Automatic conversion of amount and costs fields to Number */
operations.forEach(op => {
if (typeof op.amount === 'string') op.amount = Number(op.amount);
if (typeof op.costs === 'string') op.costs = Number(op.costs);
});
return operations
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to retrieve operations", 500);
}
}
/**
* Get all invalid operations.
* @returns An array of Operation objects.
* @throws AppError if there is an issue retrieving the operations.
*/
export async function selectAllOperationsInvalidate(): Promise<Operation[]> {
try {
const query = "SELECT * FROM operations WHERE is_validate = false AND levy_date <= CURRENT_DATE;";
const operations = await Database.execute<Operation>({ text: query, values: [] });
/* Automatic conversion of amount and costs fields to Number */
operations.forEach(op => {
if (typeof op.amount === 'string') op.amount = Number(op.amount);
if (typeof op.costs === 'string') op.costs = Number(op.costs);
});
return operations;
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to retrieve invalid operations", 500);
}
}
/*========*/
/* INSERT */
/*========*/
/**
* Insert a new operation for a user.
* @param operationData The operation data to insert.
* @returns The newly created Operation object.
* @throws AppError if there is an issue inserting the operation.
*/
export async function insertOperations(operationData: OperationInsert): Promise<Operation> {
try {
const keys = Object.keys(operationData);
const columns = keys.join(", ");
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
const values = keys.map(key => (operationData as any)[key]);
const query = `INSERT INTO operations (${columns}) VALUES (${placeholders}) RETURNING *;`;
const rows = await Database.execute<Operation>({ text: query, values: values });
if (rows.length === 0) { throw new AppError("No operation inserted", 500); }
/* Automatic conversion of amount and costs fields to Number */
const result = { ...rows[0] };
if (typeof result.amount === 'string') result.amount = Number(result.amount);
if (typeof result.costs === 'string') result.costs = Number(result.costs);
return result;
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to insert operations", 500);
}
}
/**
* Insert multiple operations for a user in bulk.
* @param operations The array of operation data to insert.
* @returns void
* @throws AppError if there is an issue inserting the operations.
*/
export async function insertBulkOperations(operations: OperationInsert[]): Promise<void> {
if (operations.length === 0) return;
try {
const keys = Object.keys(operations[0]);
const columns = keys.join(", ");
let query = `INSERT INTO operations (${columns}) VALUES `;
const values: any[] = [];
let valueIndex = 1;
for (const operation of operations) {
const placeholders = keys.map((_, i) => `$${valueIndex++}`).join(", ");
query += `(${placeholders}), `;
values.push(...keys.map(key => (operation as any)[key]));
}
query = query.slice(0, -2) + ";";
await Database.execute({ text: query, values });
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to insert bulk operations", 500);
}
}
/*========*/
/* UPDATE */
/*========*/
/**
* Update an existing operation for a user.
* @param operationData The operation data to update.
* @returns The updated Operation object.
*/
export async function updateOperations(operationData: OperationUpdate): Promise<Operation> {
try {
/* Extract id and user_id, prepare fields to update */
const { id, user_id, ...fieldsToUpdate } = operationData;
const keys = Object.keys(fieldsToUpdate);
if (!id || !user_id || keys.length === 0) {
throw new AppError("Missing data to update", 400);
}
/* Build SET clause and values */
const setClause = keys.map((key, i) => `${key} = $${i + 1}`).join(", ");
const values = keys.map(key => (fieldsToUpdate as any)[key]);
/* Append id and user_id to values for WHERE clause */
const query = `UPDATE operations SET ${setClause} WHERE id = $${values.length + 1} AND user_id = $${values.length + 2} RETURNING *;`;
values.push(id, user_id);
/* Execute query */
const rows = await Database.execute<Operation>({ text: query, values });
if (rows.length === 0) { throw new AppError("No operation updated", 404); }
/* Automatic conversion of amount and costs fields to Number */
const result = { ...rows[0] };
if (typeof result.amount === 'string') result.amount = Number(result.amount);
if (typeof result.costs === 'string') result.costs = Number(result.costs);
return result;
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to update operations", 500);
}
}
/**
* Update multiple operations validate status in bulk.
* @param operations The array of operation data to update.
* @param isValidate The validate status to set.
* @throws AppError if there is an issue updating the operations.
*/
export async function updateBulkOperationsValidate(operations: OperationUpdate[], isValidate: boolean): Promise<void> {
try {
const query = "UPDATE operations SET is_validate = $1 WHERE ";
const values: any[] = [isValidate];
const conditions = operations.map<string>((operation, _index) => {
values.push(operation.id, operation.user_id);
return `(id = $${values.length - 1} AND user_id = $${values.length})`;
}).join(" OR ");
const finalQuery = query + conditions + ";";
await Database.execute({ text: finalQuery, values });
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to update operations validate status", 500);
}
}
/*========*/
/* DELETE */
/*========*/
/**
* Delete an operation for a user.
* @param userId The ID of the user.
* @param operationId The ID of the operation to delete.
* @returns The deleted Operation object.
* @throws AppError if there is an issue deleting the operation.
*/
export async function deleteOperations(userId: number, operationId: number): Promise<Operation> {
try {
const query = `DELETE FROM operations WHERE id = $1 AND user_id = $2 RETURNING *;`;
const values = [operationId, userId];
const rows = await Database.execute<Operation>({ text: query, values });
if (rows.length === 0) { throw new AppError("No operation deleted", 404); }
/* Automatic conversion of amount and costs fields to Number */
const result = { ...rows[0] };
if (typeof result.amount === 'string') result.amount = Number(result.amount);
if (typeof result.costs === 'string') result.costs = Number(result.costs);
return result;
} catch (error) {
throw (error instanceof AppError) ? error : new AppError("Failed to delete operations", 500);
}
}
Licence¶
Copyright (C) 2024 Floris Robart
Authors: Floris Robart
This program is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.