πŸ† Projet Final ⭐⭐⭐⭐ Expert ~20h NestJS 10 + TS 5

🌐 Social API β€” Backend Complet

Construire une API sociale production-ready qui combine tous les concepts de la formation : authentification JWT, CRUD avec TypeORM, WebSockets temps rΓ©el, queues BullMQ, tests complets et dΓ©ploiement Docker/CI-CD.

NestJS 10 TypeScript 5 TypeORM 0.3 PostgreSQL Socket.io BullMQ Redis Jest 85%+ Docker GitHub Actions

πŸ—οΈ Architecture de l'Application

social-api/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app.module.ts
β”‚   β”œβ”€β”€ main.ts
β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ auth.module.ts
β”‚   β”‚   β”œβ”€β”€ auth.controller.ts        ← /auth/register, login, refresh, logout, me
β”‚   β”‚   β”œβ”€β”€ auth.service.ts
β”‚   β”‚   β”œβ”€β”€ strategies/
β”‚   β”‚   β”‚   β”œβ”€β”€ local.strategy.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ jwt.strategy.ts
β”‚   β”‚   β”‚   └── jwt-refresh.strategy.ts
β”‚   β”‚   └── guards/
β”‚   β”‚       β”œβ”€β”€ jwt-auth.guard.ts     ← global (+ @Public())
β”‚   β”‚       β”œβ”€β”€ roles.guard.ts
β”‚   β”‚       └── resource-owner.guard.ts
β”‚   β”œβ”€β”€ users/
β”‚   β”‚   β”œβ”€β”€ users.module.ts
β”‚   β”‚   β”œβ”€β”€ users.controller.ts       ← GET /users/:id, PUT /users/:id, follow/unfollow
β”‚   β”‚   β”œβ”€β”€ users.service.ts
β”‚   β”‚   β”œβ”€β”€ entities/user.entity.ts
β”‚   β”‚   └── dto/
β”‚   β”‚       β”œβ”€β”€ create-user.dto.ts
β”‚   β”‚       └── update-user.dto.ts
β”‚   β”œβ”€β”€ posts/
β”‚   β”‚   β”œβ”€β”€ posts.module.ts
β”‚   β”‚   β”œβ”€β”€ posts.controller.ts       ← CRUD + feed + timeline
β”‚   β”‚   β”œβ”€β”€ posts.service.ts
β”‚   β”‚   β”œβ”€β”€ entities/post.entity.ts
β”‚   β”‚   └── dto/
β”‚   β”‚       β”œβ”€β”€ create-post.dto.ts
β”‚   β”‚       └── update-post.dto.ts
β”‚   β”œβ”€β”€ likes/
β”‚   β”‚   β”œβ”€β”€ likes.module.ts
β”‚   β”‚   β”œβ”€β”€ likes.controller.ts       ← POST /posts/:id/like, DELETE /posts/:id/like
β”‚   β”‚   β”œβ”€β”€ likes.service.ts
β”‚   β”‚   └── entities/like.entity.ts
β”‚   β”œβ”€β”€ comments/
β”‚   β”‚   β”œβ”€β”€ comments.module.ts
β”‚   β”‚   β”œβ”€β”€ comments.controller.ts    ← CRUD + nested replies
β”‚   β”‚   β”œβ”€β”€ comments.service.ts
β”‚   β”‚   └── entities/comment.entity.ts
β”‚   β”œβ”€β”€ notifications/
β”‚   β”‚   β”œβ”€β”€ notifications.module.ts
β”‚   β”‚   β”œβ”€β”€ notifications.gateway.ts  ← WebSocket temps rΓ©el
β”‚   β”‚   β”œβ”€β”€ notifications.service.ts
β”‚   β”‚   └── entities/notification.entity.ts
β”‚   β”œβ”€β”€ mail/
β”‚   β”‚   β”œβ”€β”€ mail.module.ts            ← dynamic module
β”‚   β”‚   β”œβ”€β”€ mail.service.ts
β”‚   β”‚   └── processors/mail.processor.ts ← BullMQ
β”‚   β”œβ”€β”€ common/
β”‚   β”‚   β”œβ”€β”€ decorators/
β”‚   β”‚   β”‚   β”œβ”€β”€ current-user.decorator.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ public.decorator.ts
β”‚   β”‚   β”‚   └── roles.decorator.ts
β”‚   β”‚   β”œβ”€β”€ interceptors/
β”‚   β”‚   β”‚   β”œβ”€β”€ transform.interceptor.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ logging.interceptor.ts
β”‚   β”‚   β”‚   └── timeout.interceptor.ts
β”‚   β”‚   β”œβ”€β”€ filters/
β”‚   β”‚   β”‚   └── all-exceptions.filter.ts
β”‚   β”‚   β”œβ”€β”€ middleware/
β”‚   β”‚   β”‚   └── logger.middleware.ts
β”‚   β”‚   └── pipes/
β”‚   β”‚       └── parse-positive-int.pipe.ts
β”‚   └── health/
β”‚       └── health.controller.ts
β”œβ”€β”€ test/
β”‚   β”œβ”€β”€ auth.e2e-spec.ts
β”‚   └── posts.e2e-spec.ts
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ docker-compose.prod.yml
β”œβ”€β”€ .env.example
└── .github/
    └── workflows/
        β”œβ”€β”€ ci.yml
        └── deploy.yml

πŸ—ƒοΈ EntitΓ©s TypeORM

// user.entity.ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid') id: string;

  @Column({ unique: true }) email: string;
  @Column({ unique: true }) username: string;
  @Column({ select: false }) passwordHash: string;
  @Column({ nullable: true }) bio: string;
  @Column({ nullable: true }) avatarUrl: string;
  @Column({ type: 'enum', enum: Role, default: Role.USER }) role: Role;
  @Column({ nullable: true, select: false }) refreshTokenHash: string;

  @OneToMany(() => Post, post => post.author) posts: Post[];
  @OneToMany(() => Like, like => like.user) likes: Like[];
  @OneToMany(() => Notification, n => n.recipient) notifications: Notification[];

  // Self-referential ManyToMany for follows
  @ManyToMany(() => User, user => user.following)
  @JoinTable({ name: 'user_follows', joinColumn: { name: 'follower_id' }, inverseJoinColumn: { name: 'following_id' } })
  followers: User[];

  @ManyToMany(() => User, user => user.followers)
  following: User[];

  @CreateDateColumn() createdAt: Date;
  @UpdateDateColumn() updatedAt: Date;
}

// post.entity.ts
@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn('uuid') id: string;
  @Column({ type: 'text' }) content: string;
  @Column({ nullable: true }) imageUrl: string;
  @Column({ type: 'simple-array', default: '' }) tags: string[];

  @ManyToOne(() => User, user => user.posts, { eager: true, onDelete: 'CASCADE' })
  author: User;

  @OneToMany(() => Like, like => like.post) likes: Like[];
  @OneToMany(() => Comment, comment => comment.post) comments: Comment[];

  @Column({ default: 0 }) likesCount: number;
  @Column({ default: 0 }) commentsCount: number;

  @CreateDateColumn() createdAt: Date;
  @UpdateDateColumn() updatedAt: Date;
  @DeleteDateColumn() deletedAt: Date;  // soft delete
}

// like.entity.ts
@Entity('likes')
@Unique(['user', 'post'])   // empΓͺche les doublons
export class Like {
  @PrimaryGeneratedColumn('uuid') id: string;
  @ManyToOne(() => User, user => user.likes, { onDelete: 'CASCADE' }) user: User;
  @ManyToOne(() => Post, post => post.likes, { onDelete: 'CASCADE' }) post: Post;
  @CreateDateColumn() createdAt: Date;
}

// notification.entity.ts
@Entity('notifications')
export class Notification {
  @PrimaryGeneratedColumn('uuid') id: string;
  @Column({ type: 'enum', enum: NotificationType }) type: NotificationType;
  @Column({ type: 'jsonb' }) payload: Record<string, any>;
  @Column({ default: false }) read: boolean;
  @ManyToOne(() => User, user => user.notifications, { onDelete: 'CASCADE' })
  recipient: User;
  @Column({ nullable: true }) actorId: string;
  @CreateDateColumn() createdAt: Date;
}

πŸ“‘ Endpoints REST

MΓ©thode Route Description Auth
POST /auth/register Inscription + email de bienvenue (BullMQ) Public
POST /auth/login Login β†’ accessToken (15m) + refreshToken (7j) Public
POST /auth/refresh Rotation des tokens (dΓ©tecte rΓ©utilisation) Refresh JWT
POST /auth/logout Invalider refreshToken en DB JWT
GET /auth/me Profil courant (serialisΓ© sans hash) JWT
GET /users/:id Profil public + stats (followers, following, posts) JWT
PUT /users/:id Modifier profil (ResourceOwnerGuard) JWT + Owner
POST /users/:id/follow S'abonner + notif WebSocket JWT
DELETE /users/:id/follow Se dΓ©sabonner JWT
GET /posts Feed global paginΓ© (cursor-based) JWT
GET /posts/timeline Timeline des utilisateurs suivis JWT
POST /posts CrΓ©er post + notifier followers via WebSocket JWT
PATCH /posts/:id Modifier post (auteur seulement) JWT + Owner
DELETE /posts/:id Soft delete (auteur ou admin) JWT + Owner/Admin
POST /posts/:id/like Liker + notif WebSocket Γ  l'auteur JWT
DELETE /posts/:id/like Retirer le like JWT
GET /posts/:id/comments Commentaires paginΓ©s + replies JWT
POST /posts/:id/comments Commenter + notif Γ  l'auteur du post JWT
GET /notifications Notifs de l'utilisateur courant (non lues en premier) JWT
GET /health Status DB + Redis + mΓ©moire + disque Public

⚑ WebSocket β€” Notifications Temps RΓ©el

// notifications.gateway.ts
@WebSocketGateway({ namespace: '/notifications', cors: { origin: '*' } })
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer() server: Server;

  async handleConnection(client: Socket) {
    const user = await this.authService.validateWsToken(client.handshake.auth.token);
    if (!user) return client.disconnect();
    client.join(`user:${user.id}`);   // salle privΓ©e par userId
    await this.notificationsService.markSocketConnected(user.id, client.id);
  }

  // Envoyer une notif en temps rΓ©el
  sendToUser(userId: string, notification: NotificationDto) {
    this.server.to(`user:${userId}`).emit('notification:new', notification);
  }

  // Marquer comme lu cΓ΄tΓ© client
  @SubscribeMessage('notification:read')
  async markRead(@ConnectedSocket() client: Socket, @MessageBody() id: string) {
    const user = this.getUser(client);
    return this.notificationsService.markAsRead(user.id, id);
  }
}

// Types de notifications Γ©mises
// β†’ 'notification:new'  { type, message, actorUsername, link, createdAt }
// β†’ 'post:liked'        quand un post de l'user reΓ§oit un like
// β†’ 'post:commented'    quand un post de l'user reΓ§oit un commentaire
// β†’ 'user:followed'     quand un user suit l'utilisateur courant

πŸ“¬ Queue BullMQ β€” Emails

// Types de jobs dans la queue 'mail'
type MailJob =
  | { type: 'welcome';      to: string; username: string }
  | { type: 'notification'; to: string; subject: string; items: string[] }
  | { type: 'digest';       to: string; username: string; unreadCount: number };

// mail.processor.ts
@Processor('mail')
export class MailProcessor {
  @Process('welcome')
  async sendWelcome(job: Job<WelcomeJobData>) {
    await this.mailService.send({ to: job.data.to, subject: 'Bienvenue sur Social API', ... });
  }

  @Process('notification')
  async sendNotification(job: Job<NotifJobData>) {
    await this.mailService.send({ to: job.data.to, subject: job.data.subject, ... });
  }
}

// Jobs dΓ©clenchΓ©s automatiquement
// register β†’ 'welcome' job (dΓ©lai 0s)
// like/comment/follow β†’ 'notification' job si user offline (dΓ©lai 30s)
// daily digest cron β†’ 'digest' jobs pour users avec notifs non lues

πŸ§ͺ Suite de Tests Attendue

src/
β”œβ”€β”€ auth/
β”‚   └── auth.service.spec.ts        ← register, login, refresh, reuse detection
β”œβ”€β”€ posts/
β”‚   β”œβ”€β”€ posts.service.spec.ts       ← CRUD, timeline, soft delete
β”‚   └── posts.controller.spec.ts    ← routes, guards, transformations
β”œβ”€β”€ likes/
β”‚   └── likes.service.spec.ts       ← toggle like, doublon @Unique
β”œβ”€β”€ notifications/
β”‚   └── notifications.gateway.spec.ts ← WS connect/disconnect, emit
└── common/
    β”œβ”€β”€ interceptors/transform.interceptor.spec.ts
    └── filters/all-exceptions.filter.spec.ts
test/
β”œβ”€β”€ auth.e2e-spec.ts     ← register β†’ login β†’ refresh β†’ logout
└── posts.e2e-spec.ts    ← create β†’ like β†’ comment β†’ delete
85%
Statements
80%
Branches
90%
Functions
85%
Lines

🐳 Déploiement

# docker-compose.yml (dev)
services:
  api:
    build: .
    ports: ["3000:3000"]
    env_file: .env
    depends_on:
      postgres: { condition: service_healthy }
      redis:    { condition: service_started }

  postgres:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
      interval: 5s

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass $$REDIS_PASSWORD

  pgadmin:
    image: dpage/pgadmin4:latest
    ports: ["5050:80"]

# Pipeline CI/CD (GitHub Actions)
# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚  ci.yml  │────▢│  build-push.yml  │────▢│  deploy.yml  β”‚
# β”‚ lint+testβ”‚     β”‚ Docker β†’ GHCR    β”‚     β”‚  SSH + pull  β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

βœ… CritΓ¨res de Validation

Core

  • Register + login + refresh + logout fonctionnels
  • Refresh token rotation (dΓ©tection rΓ©utilisation)
  • CRUD posts avec pagination cursor-based
  • Timeline des utilisateurs suivis
  • Like/unlike avec contrainte d'unicitΓ©
  • Commentaires imbriquΓ©s (post β†’ replies)
  • Follow/unfollow self-referential

Production

  • Notifications WebSocket en temps rΓ©el
  • Emails via BullMQ (welcome + digest)
  • Coverage β‰₯ 85% toutes mΓ©triques
  • Dockerfile multi-stage < 200MB
  • CI lint+test vert sur chaque PR
  • Health check DB + Redis + memory
  • Joi validation des variables d'env
Bonus : Ajoutez une recherche full-text sur les posts avec tsquery PostgreSQL via TypeORM QueryBuilder. Ou implémentez un système de hashtags avec pages dédiées (#tag → liste des posts).
Important : Le projet final est notΓ© sur la qualitΓ© globale β€” code TypeScript strict (pas de any), gestion d'erreurs cohΓ©rente, tests significatifs (pas de tests triviaux), et architecture modulaire propre.
← Module 10 Mini-projet 10 🧠 QCM Final 🏠 Accueil Formation