api-news
Vue d'ensemble
Le service api-news est le système de gestion de contenu (CMS) de la plateforme Roomee. Il gère les pages hiérarchiques, les commentaires, les réactions, les catégories et toutes les fonctionnalités de contenu collaboratif.
| Caractéristique | Détail |
|---|---|
| Port | 3004 |
| Stack | TypeScript, Express.js, Prisma, AMQP, Cron |
| Base de données | MongoDB (roomee_news) + roomee_members |
| Architecture | Moderne (TS + Modules) |
| Complexité | ⭐⭐⭐⭐ |
Responsabilités
graph TB
NEWS[api-news]
subgraph "Domaines"
PAGES[Pages Hiérarchiques]
COMMENTS[Commentaires]
LIKES[Réactions & Likes]
CATEGORIES[Catégories]
TEMPLATES[Templates]
SCHEDULED[Publications Programmées]
VIEWS[Vues & Stats]
PINS[Épinglage]
end
NEWS --> PAGES
NEWS --> COMMENTS
NEWS --> LIKES
NEWS --> CATEGORIES
NEWS --> TEMPLATES
NEWS --> SCHEDULED
NEWS --> VIEWS
NEWS --> PINSStructure du Projet
api-news/
├── src/
│ ├── app.ts # Configuration Express
│ ├── server.ts # Point d'entrée
│ ├── modules/
│ │ ├── page/ # Module pages
│ │ │ ├── controllers/
│ │ │ │ └── page.controller.ts
│ │ │ ├── services/
│ │ │ │ ├── page.service.ts
│ │ │ │ └── pageEvent.service.ts
│ │ │ ├── routes/
│ │ │ │ └── page.routes.ts
│ │ │ └── types/
│ │ │ └── page.types.ts
│ │ ├── comment/ # Module commentaires
│ │ ├── like/ # Module réactions
│ │ ├── category/ # Module catégories
│ │ ├── template/ # Module templates
│ │ ├── pin/ # Module épinglage
│ │ ├── view/ # Module vues
│ │ └── preference/ # Module préférences
│ ├── core/
│ │ ├── cron/ # Tâches planifiées
│ │ │ └── scheduledPages.cron.ts
│ │ └── events/ # Event services
│ │ ├── pageEvent.service.ts
│ │ ├── commentEvent.service.ts
│ │ └── likeEvent.service.ts
│ ├── prisma-client/ # Prisma clients
│ │ ├── newsClient.ts # Client news
│ │ └── memberClient.ts # Client members
│ ├── config/
│ │ └── config.ts
│ ├── middleware/
│ │ ├── auth.middleware.ts
│ │ ├── workspace.middleware.ts
│ │ └── validation.middleware.ts
│ └── utils/
│ ├── ApiError.ts
│ └── catchAsync.ts
├── prisma/
│ └── schema.prisma # Schéma 14 modèles
└── __tests/Modèles de Données
Page
model Page {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
content Json // Editor.js format
excerpt String?
// Hiérarchie
parentId String? @db.ObjectId
parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction)
children Page[] @relation("PageHierarchy")
// Type de page
pageType String // "feed", "article", "event", "poll"
// Catégorie
categoryId String? @db.ObjectId
category Category? @relation(fields: [categoryId], references: [id])
// Workspace et Auteur
workspaceId String
createdById String // memberId
updatedById String? // memberId
// Médias
medias Json[] // [{ fileUrl, thumbnailUrl, type }]
// Statut
status String @default("draft") // draft, published, archived
publishedAt DateTime?
scheduledAt DateTime? // Publication programmée
// Interactions
views View[]
likes Like[]
comments Comment[]
pins Pin[]
// Compteurs (denormalized)
viewCount Int @default(0)
likeCount Int @default(0)
commentCount Int @default(0)
// Paramètres
enableComments Boolean @default(true)
enableLikes Boolean @default(true)
enableNotifications Boolean @default(true)
isPinned Boolean @default(false)
// Metadata
deleted_at DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workspaceId, deleted_at])
@@index([categoryId])
@@index([createdById])
@@index([parentId])
@@index([status])
@@index([publishedAt])
@@index([scheduledAt])
}Comment
model Comment {
id String @id @default(auto()) @map("_id") @db.ObjectId
content String
pageId String @db.ObjectId
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
// Auteur
memberId String // ID du membre
workspaceId String
// Hiérarchie (réponses)
parentId String? @db.ObjectId
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction)
replies Comment[] @relation("CommentReplies")
// Réactions
likes CommentLike[]
likeCount Int @default(0)
// Métadonnées
isEdited Boolean @default(false)
editedAt DateTime?
deleted_at DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([pageId])
@@index([memberId])
@@index([workspaceId])
@@index([parentId])
}Like
model Like {
id String @id @default(auto()) @map("_id") @db.ObjectId
pageId String @db.ObjectId
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
memberId String // ID du membre
workspaceId String
// Type de réaction
reactionType String @default("like") // like, love, haha, wow, sad, angry
createdAt DateTime @default(now())
@@unique([pageId, memberId])
@@index([pageId])
@@index([memberId])
@@index([workspaceId])
}Category
model Category {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
slug String
description String?
icon String? // Emoji ou icon name
color String? // Couleur hex
workspaceId String
// Relations
pages Page[]
// Ordre d'affichage
order Int @default(0)
isActive Boolean @default(true)
deleted_at DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([workspaceId, slug])
@@index([workspaceId])
}Template
model Template {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
description String?
content Json // Editor.js content template
workspaceId String
// Type de template
pageType String // "article", "event", "poll"
// Usage
usageCount Int @default(0)
isActive Boolean @default(true)
deleted_at DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([workspaceId])
@@index([pageType])
}View
model View {
id String @id @default(auto()) @map("_id") @db.ObjectId
pageId String @db.ObjectId
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
memberId String // ID du membre
workspaceId String
// Durée de lecture
duration Int? // Secondes
// Device info
deviceType String? // mobile, desktop, tablet
userAgent String?
createdAt DateTime @default(now())
@@index([pageId])
@@index([memberId])
@@index([workspaceId])
@@index([createdAt])
}Pin
model Pin {
id String @id @default(auto()) @map("_id") @db.ObjectId
pageId String @db.ObjectId
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
workspaceId String
pinnedBy String // memberId
// Position
order Int @default(0)
// Expiration
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([pageId, workspaceId])
@@index([workspaceId])
@@index([order])
}PagePreference
model PagePreference {
id String @id @default(auto()) @map("_id") @db.ObjectId
pageId String @db.ObjectId
memberId String // ID du membre
workspaceId String
// Préférences
isFavorite Boolean @default(false)
isBookmarked Boolean @default(false)
isFollowing Boolean @default(false) // Suivre les commentaires
// Notifications
notifyOnComment Boolean @default(true)
notifyOnLike Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([pageId, memberId])
@@index([memberId])
@@index([workspaceId])
}Routes API (Sélection)
📄 Pages (/api/v1/pages)
POST /api/v1/pages
Créer une nouvelle page.
Request :
{
"title": "Nouvelle politique RH 2025",
"content": {
"blocks": [
{
"type": "paragraph",
"data": {
"text": "Contenu de la page..."
}
}
]
},
"excerpt": "Résumé de la page",
"pageType": "article",
"categoryId": "65f1234567890abcdef12345",
"parentId": "65f9876543210fedcba98765",
"workspaceId": "65faaaaaaaaaaaaaaaaaaaaa",
"medias": [
{
"fileUrl": "https://storage.googleapis.com/.../photo.jpg",
"thumbnailUrl": "https://storage.googleapis.com/.../photo-thumb.jpg",
"type": "image"
}
],
"enableComments": true,
"enableNotifications": true,
"status": "published"
}Response :
{
"id": "65fbbbbbbbbbbbbbbbbbbbbb",
"title": "Nouvelle politique RH 2025",
"content": { ... },
"pageType": "article",
"status": "published",
"publishedAt": "2025-01-21T10:00:00.000Z",
"createdBy": {
"id": "65fccccccccccccccccccccc",
"firstName": "Marie",
"lastName": "Dubois",
"photo_url": "https://..."
},
"category": {
"id": "65f1234567890abcdef12345",
"name": "Ressources Humaines",
"icon": "👥"
},
"viewCount": 0,
"likeCount": 0,
"commentCount": 0
}Logique :
- Validation du contenu (Zod schema)
- Création de la page dans la base
- Si
status === 'published', émettre événement AMQPpage.created - api-notification reçoit l'événement et crée des notifications
GET /api/v1/pages
Récupérer toutes les pages (avec filtres et pagination).
Query Params :
workspaceId: Requis - ID du workspacepage: Numéro de page (défaut: 1)limit: Nombre d'éléments (défaut: 20)pageType: Filtrer par type (article, feed, event)categoryId: Filtrer par catégoriestatus: Filtrer par statut (draft, published, archived)search: Recherche dans titre et excerptsortBy: Tri (createdAt, publishedAt, viewCount, likeCount)sortOrder: asc ou desc
Response :
{
"data": [
{
"id": "65fbbbbbbbbbbbbbbbbbbbbb",
"title": "Nouvelle politique RH 2025",
"excerpt": "Résumé de la page",
"pageType": "article",
"status": "published",
"publishedAt": "2025-01-21T10:00:00.000Z",
"createdBy": {
"firstName": "Marie",
"lastName": "Dubois"
},
"category": {
"name": "Ressources Humaines",
"icon": "👥"
},
"medias": [...],
"viewCount": 125,
"likeCount": 45,
"commentCount": 12,
"isPinned": false
}
],
"meta": {
"total": 150,
"page": 1,
"limit": 20,
"totalPages": 8
}
}GET /api/v1/pages/:id
Récupérer une page par son ID (avec détails complets).
Response :
{
"id": "65fbbbbbbbbbbbbbbbbbbbbb",
"title": "Nouvelle politique RH 2025",
"content": { ... },
"excerpt": "Résumé",
"pageType": "article",
"status": "published",
"publishedAt": "2025-01-21T10:00:00.000Z",
"parent": {
"id": "65f9876543210fedcba98765",
"title": "Actualités RH"
},
"children": [
{
"id": "65fddddddddddddddddddddd",
"title": "FAQ Politique RH"
}
],
"category": {
"id": "65f1234567890abcdef12345",
"name": "Ressources Humaines",
"icon": "👥",
"color": "#3B82F6"
},
"createdBy": {
"id": "65fccccccccccccccccccccc",
"firstName": "Marie",
"lastName": "Dubois",
"photo_url": "https://..."
},
"medias": [...],
"viewCount": 125,
"likeCount": 45,
"commentCount": 12,
"userInteractions": {
"hasLiked": false,
"hasViewed": true,
"isFavorite": false,
"isBookmarked": false
},
"isPinned": false,
"enableComments": true,
"enableLikes": true
}Logique :
- Récupérer la page avec relations
- Incrémenter le compteur de vues (View)
- Récupérer les interactions de l'utilisateur
PATCH /api/v1/pages/:id
Mettre à jour une page.
DELETE /api/v1/pages/:id
Supprimer une page (soft delete).
POST /api/v1/pages/:id/publish
Publier une page en draft.
Response :
{
"id": "65fbbbbbbbbbbbbbbbbbbbbb",
"status": "published",
"publishedAt": "2025-01-21T11:00:00.000Z"
}Logique :
- Mettre à jour
status = 'published'etpublishedAt = NOW() - Émettre événement AMQP
page.created - Notifications envoyées
POST /api/v1/pages/:id/schedule
Programmer une publication.
Request :
{
"scheduledAt": "2025-01-25T09:00:00.000Z"
}Logique :
- Mettre à jour
scheduledAt - Cron job vérifie toutes les minutes les pages à publier
- Quand
scheduledAt <= NOW(), publier automatiquement
GET /api/v1/pages/:id/hierarchy
Récupérer la hiérarchie complète d'une page.
Response :
{
"breadcrumb": [
{
"id": "65f0000000000000000000001",
"title": "Accueil"
},
{
"id": "65f9876543210fedcba98765",
"title": "Actualités RH"
},
{
"id": "65fbbbbbbbbbbbbbbbbbbbbb",
"title": "Nouvelle politique RH 2025"
}
],
"children": [...]
}💬 Commentaires (/api/v1/comments)
POST /api/v1/comments
Ajouter un commentaire à une page.
Request :
{
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"content": "Super article, merci pour le partage !",
"parentId": null
}Response :
{
"id": "65feeeeeeeeeeeeeeeeeeeeeee",
"content": "Super article, merci pour le partage !",
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"member": {
"id": "65fccccccccccccccccccccc",
"firstName": "Paul",
"lastName": "Martin",
"photo_url": "https://..."
},
"likeCount": 0,
"replies": [],
"createdAt": "2025-01-21T11:30:00.000Z"
}Logique :
- Créer le commentaire
- Incrémenter
page.commentCount - Émettre 2 événements AMQP :
comment.created(channels: ['socket']) → Tous les membres du workspacecomment.created(channels: ['push', 'socket']) → Auteur de la page uniquement
GET /api/v1/comments/page/:pageId
Récupérer tous les commentaires d'une page (hiérarchie avec réponses).
Response :
{
"data": [
{
"id": "65feeeeeeeeeeeeeeeeeeeeeee",
"content": "Super article !",
"member": {...},
"likeCount": 5,
"replies": [
{
"id": "65ffffffffffffffffffffffe",
"content": "Merci !",
"member": {...},
"likeCount": 2,
"createdAt": "2025-01-21T12:00:00.000Z"
}
],
"createdAt": "2025-01-21T11:30:00.000Z"
}
],
"total": 12
}PATCH /api/v1/comments/:id
Modifier un commentaire.
DELETE /api/v1/comments/:id
Supprimer un commentaire (soft delete).
POST /api/v1/comments/:id/like
Aimer un commentaire.
❤️ Réactions (/api/v1/likes)
POST /api/v1/likes
Ajouter une réaction à une page.
Request :
{
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"reactionType": "love"
}Types disponibles : like, love, haha, wow, sad, angry
Response :
{
"id": "65fffffffffffffffffffff",
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"reactionType": "love",
"createdAt": "2025-01-21T11:45:00.000Z"
}Logique :
- Créer ou mettre à jour la réaction (upsert)
- Incrémenter
page.likeCount - Émettre événement AMQP
like.created
DELETE /api/v1/likes/:pageId
Retirer sa réaction d'une page.
GET /api/v1/likes/page/:pageId
Récupérer toutes les réactions d'une page.
Response :
{
"total": 45,
"breakdown": {
"like": 30,
"love": 10,
"wow": 5
},
"members": [
{
"member": {
"firstName": "Marie",
"lastName": "Dubois"
},
"reactionType": "love"
}
]
}🏷️ Catégories (/api/v1/categories)
POST /api/v1/categories
Créer une catégorie.
Request :
{
"name": "Ressources Humaines",
"slug": "ressources-humaines",
"description": "Tout sur les RH",
"icon": "👥",
"color": "#3B82F6",
"workspaceId": "65faaaaaaaaaaaaaaaaaaaaa",
"order": 1
}GET /api/v1/categories/workspace/:workspaceId
Récupérer toutes les catégories d'un workspace.
PATCH /api/v1/categories/:id
Mettre à jour une catégorie.
DELETE /api/v1/categories/:id
Supprimer une catégorie.
📋 Templates (/api/v1/templates)
POST /api/v1/templates
Créer un template.
Request :
{
"name": "Template Article",
"description": "Template pour les articles",
"pageType": "article",
"content": {
"blocks": [...]
},
"workspaceId": "65faaaaaaaaaaaaaaaaaaaaa"
}GET /api/v1/templates/workspace/:workspaceId
Récupérer tous les templates.
POST /api/v1/pages/from-template/:templateId
Créer une page depuis un template.
📌 Épinglage (/api/v1/pins)
POST /api/v1/pins
Épingler une page.
Request :
{
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"workspaceId": "65faaaaaaaaaaaaaaaaaaaaa",
"order": 1,
"expiresAt": "2025-02-01T00:00:00.000Z"
}GET /api/v1/pins/workspace/:workspaceId
Récupérer les pages épinglées.
DELETE /api/v1/pins/:pageId
Désépingler une page.
📊 Statistiques (/api/v1/analytics)
GET /api/v1/analytics/page/:id
Statistiques d'une page.
Response :
{
"pageId": "65fbbbbbbbbbbbbbbbbbbbbb",
"views": {
"total": 125,
"unique": 98,
"byDay": [
{ "date": "2025-01-20", "count": 45 },
{ "date": "2025-01-21", "count": 80 }
]
},
"likes": {
"total": 45,
"breakdown": {
"like": 30,
"love": 10,
"wow": 5
}
},
"comments": {
"total": 12
},
"engagement": {
"rate": 36.8,
"averageReadTime": 120
}
}GET /api/v1/analytics/workspace/:workspaceId
Statistiques globales d'un workspace.
Response :
{
"pages": {
"total": 150,
"published": 120,
"drafts": 30
},
"interactions": {
"views": 12500,
"likes": 3200,
"comments": 850
},
"topPages": [
{
"id": "65fbbbbbbbbbbbbbbbbbbbbb",
"title": "Nouvelle politique RH",
"views": 125,
"engagement": 36.8
}
],
"topCategories": [
{
"id": "65f1234567890abcdef12345",
"name": "Ressources Humaines",
"pageCount": 25
}
]
}Services
Page Event Service
// services/pageEvent.service.ts
class PageEventService {
async emitCreatedEvent(
page: Page,
req: Request,
sendPushNotification: boolean = false
) {
const channels = sendPushNotification
? ['push', 'socket']
: ['socket']
await amqpServer.emit('roomee.notification.page.created', {
type: 'page.created',
workspaceId: page.workspaceId,
workspaceName: workspace.name,
payload: {
id: page.id,
title: page.title,
parent: page.parent ? {
id: page.parent.id,
title: page.parent.title,
pageType: page.parent.pageType
} : null,
medias: page.medias
},
channels,
metadata: {
timestamp: new Date().toISOString(),
actor: {
id: req.user.memberId,
name: `${member.firstName} ${member.lastName}`,
type: 'member'
},
receiverIds: workspaceMembers.map(m => m.id)
}
})
}
async emitUpdatedEvent(page: Page, changes: any) {
await amqpServer.emit('roomee.notification.page.updated', {
type: 'page.updated',
workspaceId: page.workspaceId,
payload: {
id: page.id,
title: page.title,
changes
},
channels: ['socket']
})
}
async emitDeletedEvent(page: Page) {
await amqpServer.emit('roomee.notification.page.deleted', {
type: 'page.deleted',
workspaceId: page.workspaceId,
payload: {
id: page.id,
title: page.title
},
channels: ['socket']
})
}
}Tâches Cron
Publication Programmée
// core/cron/scheduledPages.cron.ts
import cron from 'node-cron'
// Toutes les minutes
cron.schedule('* * * * *', async () => {
const now = new Date()
// Récupérer les pages à publier
const pagesToPublish = await prisma.page.findMany({
where: {
status: 'draft',
scheduledAt: {
lte: now
},
deleted_at: null
}
})
for (const page of pagesToPublish) {
try {
// Publier la page
await prisma.page.update({
where: { id: page.id },
data: {
status: 'published',
publishedAt: now,
scheduledAt: null
}
})
// Émettre événement
await pageEventService.emitCreatedEvent(page, null, true)
logger.info('Scheduled page published', { pageId: page.id })
} catch (error) {
logger.error('Failed to publish scheduled page', {
pageId: page.id,
error
})
}
}
})Événements AMQP
Événements Émis
| Routing Key | Channels | Description |
|---|---|---|
roomee.notification.page.created | ['socket'] ou ['push', 'socket'] | Page créée/publiée |
roomee.notification.page.updated | ['socket'] | Page mise à jour |
roomee.notification.page.deleted | ['socket'] | Page supprimée |
roomee.notification.comment.created | ['socket'] → tous, ['push', 'socket'] → auteur | Commentaire ajouté |
roomee.notification.comment.deleted | ['socket'] | Commentaire supprimé |
roomee.notification.like.created | ['socket'] | Like ajouté |
Pattern de notification graduée (commentaires) :
// 1. Socket pour tous (mise à jour UI)
await commentEventService.emitCreatedEvent(comment, req, false)
// 2. Push + Socket pour l'auteur de la page
if (page.enableNotifications) {
await commentEventService.emitCreatedEvent(comment, req, true, [page.createdById])
}Socket.IO Events
// socket/newsSocket.ts
io.on('connection', (socket) => {
// Rejoindre workspace
socket.on('joinWorkspace', (workspaceId) => {
socket.join(`workspace:${workspaceId}`)
})
// S'abonner à des types spécifiques
socket.on('subscribeToTypes', ({ workspaceId, types }) => {
types.forEach(type => {
socket.join(`workspace:${workspaceId}:type:${type}`)
})
})
// Rejoindre une page spécifique
socket.on('joinPage', (pageId) => {
socket.join(`page:${pageId}`)
})
})
// Émissions serveur
io.to(`workspace:${workspaceId}`).emit('page.created', pageData)
io.to(`page:${pageId}`).emit('comment.created', commentData)
io.to(`page:${pageId}`).emit('like.created', likeData)Synchronisation des Membres
// Endpoint appelé périodiquement
POST /sync/members
// Logique
const members = await memberClient.member.findMany({
where: {
workspaces: {
some: {
workspaceId: workspaceId
}
}
}
})
// Mettre à jour le cache local
await updateMemberCache(members)Configuration
# Server
PORT=3004
# Databases
DATABASE_URL=mongodb://localhost:27017/roomee_news
MEMBER_DATABASE_URL=mongodb://localhost:27017/roomee_members
# JWT
JWT_ACCESS_SECRET=your-jwt-secret
# AMQP
AMQP_GATEWAY_URL=amqp://localhost:5672
AMQP_EXCHANGE_NAME=roomee_events
AMQP_ROUTING_KEY_BASE=roomee.notification.page
AMQP_ROUTING_KEYS=["page.*", "comment.*", "like.*"]
# Service URLs
MEMBER_API_URL=http://localhost:3001
# API Keys
MEMBER_SERVICE_API_KEY=your-member-api-keyBonnes Pratiques
1. Toujours filtrer par workspaceId
const pages = await prisma.page.findMany({
where: {
workspaceId: req.user.workspaceId,
deleted_at: null
}
})2. Utiliser les compteurs denormalisés
// Au lieu de compter à chaque fois
const commentCount = await prisma.comment.count({ where: { pageId } })
// Utiliser le compteur denormalisé
const page = await prisma.page.findUnique({
where: { id },
select: { commentCount: true }
})3. Émettre les bons événements
// Socket pour tous (UI update)
await pageEventService.emitCreatedEvent(page, req, false)
// Push + Socket pour notification complète
if (page.enableNotifications) {
await pageEventService.emitCreatedEvent(page, req, true)
}Améliorations Futures
- [ ] Recherche full-text (Elasticsearch)
- [ ] Versionning des pages (historique)
- [ ] Approbation workflow (review before publish)
- [ ] Mentions dans commentaires (@user)
- [ ] Hashtags et recherche par tags
- [ ] Reactions personnalisées par workspace
- [ ] Export PDF des pages
- [ ] Analytics avancés (heatmaps, scroll depth)