Skip to content

Guide Développeur

Guide pratique pour développer sur la plateforme Roomee.

Installation et Configuration

Prérequis

bash
# Versions requises
node --version  # v20+
pnpm --version  # v8+
docker --version # v24+

Installation Initiale

bash
# Cloner le repo
git clone https://github.com/roomee/services.git
cd services

# Installer les dépendances
pnpm install

# Configurer les variables d'environnement
cp .env.example .env

Configuration Locale

Fichier .env à la racine :

bash
# Base de données
DATABASE_URL="mongodb://localhost:27017/roomee"

# JWT
JWT_ACCESS_SECRET="your-secret-key-change-in-production"
PASSWORD_ENCRYPTION_KEY="32-char-encryption-key-here"

# AMQP
AMQP_GATEWAY_URL="amqp://localhost:5672"

# Services externes (optionnel en local)
SENDBIRD_ORGANIZATION_API_TOKEN="..."
STRIPE_SECRET_KEY="..."
ONESIGNAL_API_KEY="..."

Démarrage des Services

Option 1 : Tous les services

bash
pnpm dev

Option 2 : Service spécifique

bash
cd services/api-authentication
npm run dev

Option 3 : Avec Docker Compose

bash
docker-compose up -d

Commandes Essentielles

Monorepo (racine)

bash
# Développement
pnpm dev              # Tous les services
pnpm dev --filter=api-authentication  # Service spécifique

# Build
pnpm build            # Tous les services
pnpm build --filter=api-news  # Service spécifique

# Tests
pnpm test             # Tous les tests
pnpm test --filter=api-hotel  # Tests d'un service

# Lint
pnpm lint             # Tous les services
pnpm lint:fix         # Auto-fix

# Clean
pnpm clean            # Nettoie node_modules et dist/

Par Service

bash
cd services/api-[service-name]

# Développement avec hot reload
npm run dev

# Build
npm run build

# Tests
npm run test
npm run test:watch
npm run test:coverage

# Prisma
npm run prisma:generate   # Génère le client après changement schéma
npm run prisma:migrate    # Crée une migration
npm run prisma:push       # Push schéma sans migration (dev only)
npm run prisma:studio     # UI Prisma

# Documentation
npm run swagger-autogen   # Génère la doc Swagger

Structure d'un Service

Service Moderne (TypeScript)

services/api-news/
├── src/
│   ├── app.ts                    # Config Express (Singleton)
│   ├── server.ts                 # Point d'entrée
│   ├── config/
│   │   └── config.ts             # ConfigService
│   ├── modules/                  # Organisation modulaire
│   │   └── page/
│   │       ├── controllers/
│   │       ├── services/
│   │       ├── routes/
│   │       ├── validators/
│   │       └── types/
│   ├── middlewares/
│   ├── utils/
│   └── types/
├── prisma/
│   └── schema.prisma
├── dist/                         # Code compilé
├── tsconfig.json
└── package.json

Service Legacy (JavaScript)

services/api-hotel/
├── app.js                        # Config Express
├── loader.js                     # Point d'entrée
├── controllers/
├── routes/
├── repository/
├── middlewares/
├── socket/
├── amqp/
├── config/
├── utils/
├── prisma/
│   └── schema.prisma
└── package.json

Créer un Nouveau Service

1. Initialiser le Service

bash
# Créer le dossier
mkdir -p services/api-my-service
cd services/api-my-service

# Initialiser npm
npm init -y

# Installer les dépendances
npm install express prisma @prisma/client
npm install -D typescript @types/node @types/express ts-node nodemon

# Ajouter @roomee/shared
npm install @roomee/shared@workspace:*

2. Configuration TypeScript

tsconfig.json :

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

3. Initialiser Prisma

bash
npx prisma init --datasource-provider mongodb

prisma/schema.prisma :

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model MyModel {
  id         String   @id @default(auto()) @map("_id") @db.ObjectId
  name       String
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  deleted_at DateTime?

  @@index([created_at])
}

4. Structure de Base

src/server.ts :

typescript
import { App } from './app'
import { configService } from './config/config'

async function startServer() {
  const config = await configService.configureEnvVar()
  const app = App.getInstance()

  await app.init()

  const server = app.getApp().listen(config.port, () => {
    console.log(`Server running on port ${config.port}`)
  })

  // Graceful shutdown
  process.on('SIGTERM', () => {
    server.close(() => process.exit(0))
  })
}

startServer()

src/app.ts :

typescript
import express, { Express } from 'express'
import helmet from 'helmet'
import cors from 'cors'
import { authMiddleware, sentryService, errorHandler } from '@roomee/shared'

export class App {
  private static instance: App
  private readonly app: Express

  private constructor() {
    this.app = express()
  }

  public static getInstance(): App {
    if (!App.instance) {
      App.instance = new App()
    }
    return App.instance
  }

  public async init(): Promise<void> {
    // Sentry
    sentryService.initialize(config.sentry)
    sentryService.setupExpressHandlers(this.app)

    // Middlewares
    this.app.use(helmet())
    this.app.use(cors())
    this.app.use(express.json())

    // Auth
    authMiddleware.initialize({ jwtAccessSecret: config.jwtAccessSecret })

    // Routes
    this.setupRoutes()

    // Error handling
    this.app.use(errorHandler)
  }

  private setupRoutes(): void {
    this.app.get('/health', (req, res) => {
      res.json({ status: 'UP' })
    })

    // Import et monter les routes
  }

  public getApp(): Express {
    return this.app
  }
}

Patterns de Développement

Repository Pattern

typescript
// repository/myModel.repository.ts
import { prisma } from '@/config/prisma'

export class MyModelRepository {
  async findById(id: string) {
    return await prisma.myModel.findUnique({
      where: { id, deleted_at: null }
    })
  }

  async findAll(workspaceId: string, filters: any) {
    return await prisma.myModel.findMany({
      where: {
        workspaceId,
        deleted_at: null,
        ...filters
      }
    })
  }

  async create(data: any) {
    return await prisma.myModel.create({ data })
  }

  async update(id: string, data: any) {
    return await prisma.myModel.update({
      where: { id },
      data: { ...data, updated_at: new Date() }
    })
  }

  async softDelete(id: string, deletedById: string) {
    return await prisma.myModel.update({
      where: { id },
      data: { deleted_at: new Date(), deletedById }
    })
  }
}

Service Pattern

typescript
// services/myModel.service.ts
import { MyModelRepository } from '@/repository'
import { NotFoundError } from '@roomee/shared'

export class MyModelService {
  constructor(private repository: MyModelRepository) {}

  async getById(id: string) {
    const model = await this.repository.findById(id)
    if (!model) {
      throw new NotFoundError('Model not found')
    }
    return model
  }

  async create(data: CreateDto) {
    // Validation métier
    await this.validateData(data)

    // Création
    const model = await this.repository.create(data)

    // Événement AMQP
    await this.emitCreatedEvent(model)

    return model
  }

  private async validateData(data: any) {
    // Logique de validation
  }

  private async emitCreatedEvent(model: any) {
    await amqpServer.emit('model.created', model)
  }
}

Controller Pattern

typescript
// controllers/myModel.controller.ts
import { Request, Response } from 'express'
import { MyModelService } from '@/services'
import { sendSuccess, sendError } from '@roomee/shared'

export class MyModelController {
  constructor(private service: MyModelService) {}

  getById = async (req: Request, res: Response) => {
    try {
      const model = await this.service.getById(req.params.id)
      sendSuccess(res, model)
    } catch (error) {
      sendError(res, error)
    }
  }

  create = async (req: Request, res: Response) => {
    try {
      const model = await this.service.create(req.body)
      sendSuccess(res, model, 201)
    } catch (error) {
      sendError(res, error)
    }
  }
}

Route Pattern

typescript
// routes/myModel.routes.ts
import { Router } from 'express'
import { authMiddleware } from '@roomee/shared'
import { validate } from '@/middlewares'
import { myModelController } from '@/controllers'
import { createMyModelSchema } from '@/validators'

const router = Router()

// Toutes les routes nécessitent auth
router.use(authMiddleware.middleware)

router.get('/:id', myModelController.getById)
router.post('/', validate(createMyModelSchema), myModelController.create)

export default router

Validation avec Zod

Créer un Schema

typescript
// validators/myModel.validator.ts
import { z } from 'zod'

export const createMyModelSchema = z.object({
  body: z.object({
    name: z.string().min(1).max(255),
    workspaceId: z.string().regex(/^[0-9a-fA-F]{24}$/),
    status: z.enum(['active', 'inactive']).optional()
  })
})

export const updateMyModelSchema = z.object({
  params: z.object({
    id: z.string().regex(/^[0-9a-fA-F]{24}$/)
  }),
  body: z.object({
    name: z.string().min(1).max(255).optional(),
    status: z.enum(['active', 'inactive']).optional()
  })
})

export type CreateMyModelDto = z.infer<typeof createMyModelSchema>['body']
export type UpdateMyModelDto = z.infer<typeof updateMyModelSchema>['body']

Middleware de Validation

typescript
// middlewares/validate.middleware.ts
import { Request, Response, NextFunction } from 'express'
import { AnyZodObject } from 'zod'
import { ValidationError } from '@roomee/shared'

export const validate = (schema: AnyZodObject) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params
      })
      next()
    } catch (error) {
      next(new ValidationError('Validation failed', error.errors))
    }
  }
}

Tests

Tests Unitaires (Jest)

typescript
// __tests__/services/myModel.service.test.ts
import { MyModelService } from '@/services'
import { MyModelRepository } from '@/repository'

describe('MyModelService', () => {
  let service: MyModelService
  let repository: MyModelRepository

  beforeEach(() => {
    repository = new MyModelRepository()
    service = new MyModelService(repository)
  })

  describe('getById', () => {
    it('should return model when found', async () => {
      const mockModel = { id: '123', name: 'Test' }
      jest.spyOn(repository, 'findById').mockResolvedValue(mockModel)

      const result = await service.getById('123')

      expect(result).toEqual(mockModel)
      expect(repository.findById).toHaveBeenCalledWith('123')
    })

    it('should throw NotFoundError when not found', async () => {
      jest.spyOn(repository, 'findById').mockResolvedValue(null)

      await expect(service.getById('123')).rejects.toThrow('Model not found')
    })
  })
})

Tests d'Intégration (Supertest)

typescript
// __tests__/routes/myModel.routes.test.ts
import request from 'supertest'
import { App } from '@/app'

describe('MyModel Routes', () => {
  let app: Express
  let authToken: string

  beforeAll(async () => {
    app = App.getInstance().getApp()
    // Login pour obtenir token
    const response = await request(app)
      .post('/auth/login')
      .send({ email: 'test@test.com', password: 'password' })
    authToken = response.body.accessToken
  })

  describe('GET /:id', () => {
    it('should return model by id', async () => {
      const response = await request(app)
        .get('/mymodels/123')
        .set('Authorization', `Bearer ${authToken}`)

      expect(response.status).toBe(200)
      expect(response.body.data).toHaveProperty('id', '123')
    })

    it('should return 401 without auth', async () => {
      const response = await request(app).get('/mymodels/123')

      expect(response.status).toBe(401)
    })
  })
})

Debugging

VS Code Launch Configuration

.vscode/launch.json :

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug api-authentication",
      "runtimeArgs": [
        "-r",
        "ts-node/register",
        "--inspect-brk"
      ],
      "args": ["${workspaceFolder}/services/api-authentication/src/server.ts"],
      "env": {
        "NODE_ENV": "development"
      },
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "sourceMaps": true,
      "restart": true
    }
  ]
}

Logs Winston

typescript
import { logger } from '@roomee/shared'

// Niveaux: error, warn, info, debug
logger.info('User logged in', { userId, email })
logger.error('Database error', { error, query })
logger.debug('Processing request', { body: req.body })

Prisma Studio

bash
cd services/api-[service]
npx prisma studio

Ouvre une UI sur http://localhost:5555 pour explorer la base de données.


Git Workflow

Branches

main          # Production
├── staging   # Pré-production
└── develop   # Développement
    └── feature/[nom-feature]

Commits

Convention : Conventional Commits

bash
feat: add user profile endpoint
fix: correct pagination bug in news service
docs: update API documentation
refactor: extract repository pattern
test: add unit tests for auth service
chore: update dependencies

Pull Request

bash
# Créer une branche
git checkout -b feature/add-notifications

# Développer et committer
git add .
git commit -m "feat: add notification preferences"

# Push
git push origin feature/add-notifications

# Créer la PR sur GitHub

Bonnes Pratiques

1. Toujours Filtrer par Workspace

typescript
// ✅ BON
const data = await prisma.model.findMany({
  where: {
    workspaceId: req.user.workspaceId,
    deleted_at: null
  }
})

2. Toujours Soft Delete

typescript
// ✅ BON
await prisma.model.update({
  where: { id },
  data: { deleted_at: new Date(), deletedById }
})

3. Valider les Entrées

typescript
// ✅ BON
router.post('/', validate(createSchema), controller.create)

4. Logger les Actions Importantes

typescript
logger.info('Member created', { memberId, workspaceId })
logger.error('Failed to send notification', { error, notificationId })

5. Gérer les Erreurs Proprement

typescript
try {
  await service.create(data)
} catch (error) {
  if (error instanceof ValidationError) {
    return res.status(400).json({ error: error.message })
  }
  throw error // Re-throw pour le error handler
}

Ressources Utiles

Documentation

Outils

  • Postman : Tester les API
  • MongoDB Compass : Explorer MongoDB
  • RabbitMQ Management : http://localhost:15672
  • Prisma Studio : UI pour la DB

Support

Documentation technique Roomee Services