mission-tab deletemission
This commit is contained in:
parent
6cf131921b
commit
4141f51550
982
MISSION_CREATION_WORKFLOW_DETAILED.md
Normal file
982
MISSION_CREATION_WORKFLOW_DETAILED.md
Normal file
@ -0,0 +1,982 @@
|
||||
# Workflow Détaillé : Création de Mission - Prisma, Minio et N8N
|
||||
|
||||
## 📋 Vue d'Ensemble
|
||||
|
||||
Ce document trace **chaque étape** du workflow de création de mission, depuis le formulaire frontend jusqu'aux intégrations externes via N8N, en passant par Prisma (base de données) et Minio (stockage de fichiers).
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flux Complet - Vue d'Ensemble
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. FRONTEND - MissionsAdminPanel │
|
||||
│ - Validation des champs │
|
||||
│ - Préparation des données (base64 pour fichiers) │
|
||||
│ - POST /api/missions │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. BACKEND - POST /api/missions │
|
||||
│ ├─ Authentification (NextAuth) │
|
||||
│ ├─ Validation des champs requis │
|
||||
│ ├─ STEP 1: Prisma.mission.create() │
|
||||
│ ├─ STEP 2: Prisma.missionUser.createMany() │
|
||||
│ ├─ STEP 3: Upload Logo → Minio │
|
||||
│ ├─ STEP 4: Upload Attachments → Minio │
|
||||
│ ├─ STEP 5: Vérification fichiers Minio │
|
||||
│ └─ STEP 6: N8N Workflow Trigger │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. N8N WORKFLOW │
|
||||
│ - Création Leantime Project │
|
||||
│ - Création Outline Collection │
|
||||
│ - Création RocketChat Channel │
|
||||
│ - Création Gitea Repository │
|
||||
│ - Création Penpot Project │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 ÉTAPE 1 : Frontend - Préparation des Données
|
||||
|
||||
### Fichier : `components/missions/missions-admin-panel.tsx`
|
||||
|
||||
### 1.1 Validation (`validateMission()`)
|
||||
|
||||
**Lignes 369-397**
|
||||
|
||||
```typescript
|
||||
const validateMission = () => {
|
||||
const requiredFields = {
|
||||
name: !!missionData.name,
|
||||
oddScope: Array.isArray(missionData.oddScope) && missionData.oddScope.length > 0,
|
||||
niveau: !!missionData.niveau,
|
||||
intention: !!missionData.intention,
|
||||
missionType: !!missionData.missionType,
|
||||
donneurDOrdre: !!missionData.donneurDOrdre,
|
||||
projection: !!missionData.projection,
|
||||
participation: !!missionData.participation,
|
||||
gardiens: gardienDuTemps !== null &&
|
||||
gardienDeLaParole !== null &&
|
||||
gardienDeLaMemoire !== null
|
||||
};
|
||||
|
||||
// Vérifie que tous les champs requis sont remplis
|
||||
// Retourne false si un champ manque
|
||||
}
|
||||
```
|
||||
|
||||
**Champs requis** :
|
||||
- ✅ `name` : Nom de la mission
|
||||
- ✅ `oddScope` : Array avec au moins 1 ODD
|
||||
- ✅ `niveau` : A/B/C/S
|
||||
- ✅ `intention` : Description
|
||||
- ✅ `missionType` : remote/onsite/hybrid
|
||||
- ✅ `donneurDOrdre` : individual/group/organization
|
||||
- ✅ `projection` : short/medium/long
|
||||
- ✅ `participation` : volontaire/cooptation
|
||||
- ✅ `gardiens` : Les 3 gardiens doivent être assignés
|
||||
|
||||
### 1.2 Préparation des Données (`handleSubmitMission()`)
|
||||
|
||||
**Lignes 400-460**
|
||||
|
||||
```typescript
|
||||
const handleSubmitMission = async () => {
|
||||
// 1. Validation
|
||||
if (!validateMission()) return;
|
||||
|
||||
// 2. Préparation des gardiens
|
||||
const guardians = {
|
||||
"gardien-temps": gardienDuTemps,
|
||||
"gardien-parole": gardienDeLaParole,
|
||||
"gardien-memoire": gardienDeLaMemoire
|
||||
};
|
||||
|
||||
// 3. Construction de l'objet de soumission
|
||||
const missionSubmitData = {
|
||||
...missionData, // name, oddScope, niveau, intention, etc.
|
||||
services: selectedServices, // ["Gite", "ArtLab", "Calcul"]
|
||||
profils: selectedProfils, // ["DataIntelligence", "Expression", ...]
|
||||
guardians, // { "gardien-temps": userId, ... }
|
||||
volunteers: volontaires, // [userId1, userId2, ...]
|
||||
logo: missionData.logo // { data: "data:image/png;base64,...", name, type }
|
||||
};
|
||||
|
||||
// 4. Envoi à l'API
|
||||
const response = await fetch('/api/missions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(missionSubmitData)
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Format du logo** (si présent) :
|
||||
```typescript
|
||||
logo: {
|
||||
data: "data:image/png;base64,iVBORw0KGgoAAAANS...", // Base64 avec préfixe
|
||||
name: "logo.png",
|
||||
type: "image/png"
|
||||
}
|
||||
```
|
||||
|
||||
**Format des attachments** (si présents) :
|
||||
```typescript
|
||||
attachments: [
|
||||
{
|
||||
data: "data:application/pdf;base64,JVBERi0xLjQKJe...",
|
||||
name: "document.pdf",
|
||||
type: "application/pdf"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ ÉTAPE 2 : Backend - POST /api/missions
|
||||
|
||||
### Fichier : `app/api/missions/route.ts`
|
||||
|
||||
### 2.1 Authentification et Validation
|
||||
|
||||
**Lignes 205-224**
|
||||
|
||||
```typescript
|
||||
export async function POST(request: Request) {
|
||||
// 1. Vérification de l'authentification
|
||||
const { authorized, userId } = await checkAuth(request);
|
||||
if (!authorized || !userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parsing du body
|
||||
const body = await request.json();
|
||||
|
||||
// 3. Validation minimale (name et oddScope requis)
|
||||
if (!body.name || !body.oddScope) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing required fields',
|
||||
missingFields: ['name', 'oddScope'].filter(field => !body[field])
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 STEP 1 : Création de la Mission en Base de Données
|
||||
|
||||
**Lignes 226-248**
|
||||
|
||||
```typescript
|
||||
// Préparation des données pour Prisma
|
||||
const missionData = {
|
||||
name: body.name,
|
||||
oddScope: body.oddScope, // Array de strings
|
||||
niveau: body.niveau,
|
||||
intention: body.intention,
|
||||
missionType: body.missionType,
|
||||
donneurDOrdre: body.donneurDOrdre,
|
||||
projection: body.projection,
|
||||
services: body.services, // Array de strings
|
||||
profils: body.profils, // Array de strings
|
||||
participation: body.participation,
|
||||
creatorId: userId, // ID de l'utilisateur connecté
|
||||
logo: null, // Sera mis à jour après upload
|
||||
};
|
||||
|
||||
// Création en base de données
|
||||
const mission = await prisma.mission.create({
|
||||
data: missionData
|
||||
});
|
||||
|
||||
// Résultat : mission.id est maintenant disponible
|
||||
// Exemple : mission.id = "abc-123-def-456"
|
||||
```
|
||||
|
||||
**Schéma Prisma** :
|
||||
```prisma
|
||||
model Mission {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
logo String? // null pour l'instant
|
||||
oddScope String[]
|
||||
niveau String
|
||||
intention String
|
||||
missionType String
|
||||
donneurDOrdre String
|
||||
projection String
|
||||
services String[]
|
||||
participation String?
|
||||
profils String[]
|
||||
creatorId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// Relations
|
||||
creator User @relation(...)
|
||||
missionUsers MissionUser[]
|
||||
attachments Attachment[]
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ La mission est créée **AVANT** l'upload des fichiers
|
||||
- ✅ Le `mission.id` est généré automatiquement (UUID)
|
||||
- ✅ Le champ `logo` est `null` pour l'instant
|
||||
- ✅ Tous les champs sont sauvegardés sauf les fichiers
|
||||
|
||||
### 2.3 STEP 2 : Création des MissionUsers (Gardiens + Volontaires)
|
||||
|
||||
**Lignes 250-283**
|
||||
|
||||
```typescript
|
||||
// Préparation du tableau de MissionUsers
|
||||
const missionUsers = [];
|
||||
|
||||
// 2.1 Ajout des gardiens
|
||||
if (body.guardians) {
|
||||
for (const [role, guardianId] of Object.entries(body.guardians)) {
|
||||
if (guardianId) {
|
||||
missionUsers.push({
|
||||
missionId: mission.id, // ID de la mission créée
|
||||
userId: guardianId, // ID de l'utilisateur gardien
|
||||
role: role // "gardien-temps", "gardien-parole", "gardien-memoire"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 Ajout des volontaires
|
||||
if (body.volunteers && body.volunteers.length > 0) {
|
||||
for (const volunteerId of body.volunteers) {
|
||||
missionUsers.push({
|
||||
missionId: mission.id,
|
||||
userId: volunteerId,
|
||||
role: 'volontaire'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 Création en batch dans Prisma
|
||||
if (missionUsers.length > 0) {
|
||||
await prisma.missionUser.createMany({
|
||||
data: missionUsers
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Schéma Prisma MissionUser** :
|
||||
```prisma
|
||||
model MissionUser {
|
||||
id String @id @default(uuid())
|
||||
role String // 'gardien-temps', 'gardien-parole', 'gardien-memoire', 'volontaire'
|
||||
missionId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mission Mission @relation(...)
|
||||
user User @relation(...)
|
||||
|
||||
@@unique([missionId, userId, role]) // Un utilisateur ne peut avoir qu'un rôle par mission
|
||||
}
|
||||
```
|
||||
|
||||
**Exemple de données créées** :
|
||||
```typescript
|
||||
missionUsers = [
|
||||
{ missionId: "abc-123", userId: "user-1", role: "gardien-temps" },
|
||||
{ missionId: "abc-123", userId: "user-2", role: "gardien-parole" },
|
||||
{ missionId: "abc-123", userId: "user-3", role: "gardien-memoire" },
|
||||
{ missionId: "abc-123", userId: "user-4", role: "volontaire" },
|
||||
{ missionId: "abc-123", userId: "user-5", role: "volontaire" }
|
||||
]
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Utilisation de `createMany()` pour performance (1 requête au lieu de N)
|
||||
- ✅ Contrainte unique : un utilisateur ne peut avoir qu'un rôle par mission
|
||||
- ✅ Les gardiens sont obligatoires, les volontaires optionnels
|
||||
|
||||
### 2.4 STEP 3 : Upload du Logo vers Minio
|
||||
|
||||
**Lignes 285-310**
|
||||
|
||||
```typescript
|
||||
let logoPath = null;
|
||||
if (body.logo?.data) {
|
||||
try {
|
||||
// 3.1 Conversion base64 → Buffer → File
|
||||
const base64Data = body.logo.data.split(',')[1]; // Retire "data:image/png;base64,"
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const file = new File(
|
||||
[buffer],
|
||||
body.logo.name || 'logo.png',
|
||||
{ type: body.logo.type || 'image/png' }
|
||||
);
|
||||
|
||||
// 3.2 Upload vers Minio
|
||||
const { filePath } = await uploadMissionLogo(userId, mission.id, file);
|
||||
logoPath = filePath; // Ex: "missions/abc-123/logo.png"
|
||||
uploadedFiles.push({ type: 'logo', path: filePath });
|
||||
|
||||
// 3.3 Mise à jour de la mission avec le chemin du logo
|
||||
await prisma.mission.update({
|
||||
where: { id: mission.id },
|
||||
data: { logo: filePath }
|
||||
});
|
||||
} catch (uploadError) {
|
||||
throw new Error('Failed to upload logo');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fonction `uploadMissionLogo()` - `lib/mission-uploads.ts`** :
|
||||
|
||||
**Lignes 96-145**
|
||||
|
||||
```typescript
|
||||
export async function uploadMissionLogo(
|
||||
userId: string,
|
||||
missionId: string,
|
||||
file: File
|
||||
): Promise<{ filePath: string }> {
|
||||
|
||||
// 1. Génération du chemin
|
||||
const fileExtension = file.name.substring(file.name.lastIndexOf('.'));
|
||||
const filePath = getMissionLogoPath(userId, missionId, fileExtension);
|
||||
// Résultat : "missions/{missionId}/logo.png"
|
||||
|
||||
// 2. Conversion pour Minio (retire le préfixe "missions/")
|
||||
const minioPath = filePath.replace(/^missions\//, '');
|
||||
// Résultat : "{missionId}/logo.png"
|
||||
|
||||
// 3. Conversion File → Buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// 4. Upload vers Minio via S3 SDK
|
||||
await s3Client.send(new PutObjectCommand({
|
||||
Bucket: 'missions', // Bucket Minio
|
||||
Key: minioPath, // "{missionId}/logo.png"
|
||||
Body: buffer, // Contenu du fichier
|
||||
ContentType: file.type, // "image/png"
|
||||
ACL: 'public-read' // Accès public en lecture
|
||||
}));
|
||||
|
||||
return { filePath }; // Retourne le chemin complet avec préfixe
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Minio** :
|
||||
```typescript
|
||||
// lib/mission-uploads.ts
|
||||
const s3Client = new S3Client({
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://dome-api.slm-lab.net', // Endpoint Minio
|
||||
credentials: {
|
||||
accessKeyId: '4aBT4CMb7JIMMyUtp4Pl',
|
||||
secretAccessKey: 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg'
|
||||
},
|
||||
forcePathStyle: true // Requis pour MinIO
|
||||
});
|
||||
```
|
||||
|
||||
**Structure dans Minio** :
|
||||
```
|
||||
Bucket: missions
|
||||
└── {missionId}/
|
||||
└── logo.png
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Le logo est uploadé **APRÈS** la création de la mission (pour avoir le missionId)
|
||||
- ✅ Le chemin est mis à jour dans Prisma après l'upload
|
||||
- ✅ Le fichier est stocké avec ACL `public-read` pour accès public
|
||||
- ✅ Le chemin stocké inclut le préfixe `missions/` pour cohérence
|
||||
|
||||
### 2.5 STEP 4 : Upload des Attachments vers Minio
|
||||
|
||||
**Lignes 312-343**
|
||||
|
||||
```typescript
|
||||
if (body.attachments && body.attachments.length > 0) {
|
||||
try {
|
||||
// 4.1 Traitement parallèle de tous les attachments
|
||||
const attachmentPromises = body.attachments.map(async (attachment: any) => {
|
||||
// Conversion base64 → Buffer → File
|
||||
const base64Data = attachment.data.split(',')[1];
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const file = new File(
|
||||
[buffer],
|
||||
attachment.name || 'attachment',
|
||||
{ type: attachment.type || 'application/octet-stream' }
|
||||
);
|
||||
|
||||
// 4.2 Upload vers Minio
|
||||
const { filePath, filename, fileType, fileSize } =
|
||||
await uploadMissionAttachment(userId, mission.id, file);
|
||||
uploadedFiles.push({ type: 'attachment', path: filePath });
|
||||
|
||||
// 4.3 Création de l'enregistrement Attachment en base
|
||||
return prisma.attachment.create({
|
||||
data: {
|
||||
missionId: mission.id,
|
||||
filename, // "document.pdf"
|
||||
filePath, // "missions/abc-123/attachments/document.pdf"
|
||||
fileType, // "application/pdf"
|
||||
fileSize, // 123456 (bytes)
|
||||
uploaderId: userId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 4.4 Attente de tous les uploads (parallèle)
|
||||
await Promise.all(attachmentPromises);
|
||||
} catch (attachmentError) {
|
||||
throw new Error('Failed to upload attachments');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fonction `uploadMissionAttachment()` - `lib/mission-uploads.ts`** :
|
||||
|
||||
**Lignes 148-210**
|
||||
|
||||
```typescript
|
||||
export async function uploadMissionAttachment(
|
||||
userId: string,
|
||||
missionId: string,
|
||||
file: File
|
||||
): Promise<{
|
||||
filename: string;
|
||||
filePath: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
}> {
|
||||
|
||||
// 1. Génération du chemin
|
||||
const filePath = getMissionAttachmentPath(userId, missionId, file.name);
|
||||
// Résultat : "missions/{missionId}/attachments/{filename}"
|
||||
|
||||
// 2. Conversion pour Minio
|
||||
const minioPath = filePath.replace(/^missions\//, '');
|
||||
// Résultat : "{missionId}/attachments/{filename}"
|
||||
|
||||
// 3. Conversion File → Buffer
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// 4. Upload vers Minio
|
||||
await s3Client.send(new PutObjectCommand({
|
||||
Bucket: 'missions',
|
||||
Key: minioPath,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read'
|
||||
}));
|
||||
|
||||
return {
|
||||
filename: file.name,
|
||||
filePath, // Chemin complet avec préfixe
|
||||
fileType: file.type,
|
||||
fileSize: file.size
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Schéma Prisma Attachment** :
|
||||
```prisma
|
||||
model Attachment {
|
||||
id String @id @default(uuid())
|
||||
filename String // "document.pdf"
|
||||
filePath String // "missions/{missionId}/attachments/{filename}"
|
||||
fileType String // "application/pdf"
|
||||
fileSize Int // 123456
|
||||
missionId String
|
||||
uploaderId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mission Mission @relation(...)
|
||||
uploader User @relation(...)
|
||||
}
|
||||
```
|
||||
|
||||
**Structure dans Minio** :
|
||||
```
|
||||
Bucket: missions
|
||||
└── {missionId}/
|
||||
├── logo.png
|
||||
└── attachments/
|
||||
├── document1.pdf
|
||||
├── document2.docx
|
||||
└── image.jpg
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Uploads en **parallèle** avec `Promise.all()` pour performance
|
||||
- ✅ Chaque attachment crée un enregistrement Prisma séparé
|
||||
- ✅ Le `uploaderId` est l'utilisateur qui a créé la mission
|
||||
- ✅ Les fichiers sont stockés avec ACL `public-read`
|
||||
|
||||
### 2.6 STEP 5 : Vérification des Fichiers dans Minio
|
||||
|
||||
**Lignes 345-365**
|
||||
|
||||
```typescript
|
||||
// 5.1 Vérification du logo
|
||||
if (logoPath) {
|
||||
const logoExists = await verifyFileExists(logoPath);
|
||||
if (!logoExists) {
|
||||
throw new Error('Logo file not found in Minio');
|
||||
}
|
||||
}
|
||||
|
||||
// 5.2 Vérification des attachments
|
||||
if (body.attachments?.length > 0) {
|
||||
const attachmentVerifications = uploadedFiles
|
||||
.filter(f => f.type === 'attachment')
|
||||
.map(f => verifyFileExists(f.path));
|
||||
|
||||
const attachmentResults = await Promise.all(attachmentVerifications);
|
||||
if (attachmentResults.some(exists => !exists)) {
|
||||
throw new Error('One or more attachment files not found in Minio');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fonction `verifyFileExists()` - Lignes 191-202**
|
||||
|
||||
```typescript
|
||||
async function verifyFileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await s3Client.send(new HeadObjectCommand({
|
||||
Bucket: 'missions',
|
||||
Key: filePath.replace('missions/', '') // Retire le préfixe
|
||||
}));
|
||||
return true; // Fichier existe
|
||||
} catch (error) {
|
||||
return false; // Fichier n'existe pas
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Vérification **AVANT** de déclencher N8N
|
||||
- ✅ Utilise `HeadObjectCommand` (légère, ne télécharge pas le fichier)
|
||||
- ✅ Si un fichier manque, le workflow s'arrête avec erreur
|
||||
|
||||
### 2.7 STEP 6 : Déclenchement du Workflow N8N
|
||||
|
||||
**Lignes 367-393**
|
||||
|
||||
```typescript
|
||||
// 6.1 Préparation des données pour N8N
|
||||
const n8nData = {
|
||||
...body, // Toutes les données de la mission
|
||||
creatorId: userId, // ID du créateur
|
||||
logoPath: logoPath, // Chemin du logo (ou null)
|
||||
config: {
|
||||
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL
|
||||
}
|
||||
};
|
||||
|
||||
// 6.2 Déclenchement du workflow
|
||||
const n8nService = new N8nService();
|
||||
const workflowResult = await n8nService.triggerMissionCreation(n8nData);
|
||||
|
||||
// 6.3 Vérification du résultat
|
||||
if (!workflowResult.success) {
|
||||
throw new Error(workflowResult.error || 'N8N workflow failed');
|
||||
}
|
||||
|
||||
// 6.4 Retour succès
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
mission,
|
||||
message: 'Mission created successfully with all integrations'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 ÉTAPE 3 : Service N8N
|
||||
|
||||
### Fichier : `lib/services/n8n-service.ts`
|
||||
|
||||
### 3.1 Configuration
|
||||
|
||||
**Lignes 3-17**
|
||||
|
||||
```typescript
|
||||
export class N8nService {
|
||||
private webhookUrl: string;
|
||||
private rollbackWebhookUrl: string;
|
||||
private apiKey: string;
|
||||
|
||||
constructor() {
|
||||
this.webhookUrl = process.env.N8N_WEBHOOK_URL ||
|
||||
'https://brain.slm-lab.net/webhook/mission-created';
|
||||
this.rollbackWebhookUrl = process.env.N8N_ROLLBACK_WEBHOOK_URL ||
|
||||
'https://brain.slm-lab.net/webhook/mission-rollback';
|
||||
this.apiKey = process.env.N8N_API_KEY || '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Nettoyage et Validation des Données
|
||||
|
||||
**Lignes 19-49**
|
||||
|
||||
```typescript
|
||||
async triggerMissionCreation(data: any): Promise<any> {
|
||||
// Nettoyage des données
|
||||
const cleanData = {
|
||||
name: data.name,
|
||||
oddScope: Array.isArray(data.oddScope) ? data.oddScope : [data.oddScope],
|
||||
niveau: data.niveau || 'default',
|
||||
intention: data.intention?.trim() || '',
|
||||
missionType: data.missionType || 'default',
|
||||
donneurDOrdre: data.donneurDOrdre || 'default',
|
||||
projection: data.projection || 'default',
|
||||
services: Array.isArray(data.services) ? data.services : [],
|
||||
participation: data.participation || 'default',
|
||||
profils: Array.isArray(data.profils) ? data.profils : [],
|
||||
guardians: data.guardians || {},
|
||||
volunteers: Array.isArray(data.volunteers) ? data.volunteers : [],
|
||||
creatorId: data.creatorId,
|
||||
config: {
|
||||
...data.config,
|
||||
N8N_API_KEY: this.apiKey,
|
||||
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://api.slm-lab.net/api'
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Normalisation des arrays (assure qu'ils sont bien des arrays)
|
||||
- ✅ Valeurs par défaut pour éviter les undefined
|
||||
- ✅ Trim de l'intention pour retirer les espaces
|
||||
- ✅ Conservation de la config avec les clés API
|
||||
|
||||
### 3.3 Envoi au Webhook N8N
|
||||
|
||||
**Lignes 73-96**
|
||||
|
||||
```typescript
|
||||
const response = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey // Authentification
|
||||
},
|
||||
body: JSON.stringify(cleanData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Traitement de la Réponse
|
||||
|
||||
**Lignes 98-143**
|
||||
|
||||
```typescript
|
||||
const responseText = await response.text();
|
||||
|
||||
try {
|
||||
const result = JSON.parse(responseText);
|
||||
|
||||
// Détection d'erreurs partielles
|
||||
if (result.error || result.message?.includes('failed')) {
|
||||
const errorMessage = result.message || result.error;
|
||||
const failedServices = {
|
||||
gitRepo: errorMessage.includes('Git repository creation failed'),
|
||||
leantimeProject: errorMessage.includes('Leantime project creation failed'),
|
||||
docCollection: errorMessage.includes('Documentation collection creation failed'),
|
||||
rocketChatChannel: errorMessage.includes('RocketChat channel creation failed')
|
||||
};
|
||||
|
||||
// Retourne succès avec erreurs partielles
|
||||
return {
|
||||
success: true,
|
||||
results: { ...result, failedServices }
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, results: result };
|
||||
|
||||
} catch (parseError) {
|
||||
// Si la réponse n'est pas JSON, considère comme succès
|
||||
return {
|
||||
success: true,
|
||||
results: {
|
||||
logoUrl: null,
|
||||
leantimeProjectId: null,
|
||||
outlineCollectionId: null,
|
||||
rocketChatChannelId: null,
|
||||
giteaRepositoryUrl: null
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Format de réponse attendu de N8N** :
|
||||
```json
|
||||
{
|
||||
"leantimeProjectId": "project-123",
|
||||
"outlineCollectionId": "collection-456",
|
||||
"rocketChatChannelId": "channel-789",
|
||||
"giteaRepositoryUrl": "https://git.slm-lab.net/mission-abc",
|
||||
"penpotProjectId": "penpot-xyz"
|
||||
}
|
||||
```
|
||||
|
||||
**Intégrations créées par N8N** :
|
||||
1. **Leantime** : Projet de gestion de projet
|
||||
2. **Outline** : Collection de documentation
|
||||
3. **RocketChat** : Canal de communication
|
||||
4. **Gitea** : Repository Git
|
||||
5. **Penpot** : Projet de design
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Gestion des erreurs partielles (certains services peuvent échouer)
|
||||
- ✅ Si la réponse n'est pas JSON, considère comme succès (workflow déclenché)
|
||||
- ✅ Les IDs retournés ne sont **PAS** sauvegardés en base (TODO)
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Gestion des Erreurs et Cleanup
|
||||
|
||||
### Fichier : `app/api/missions/route.ts` - Lignes 398-418
|
||||
|
||||
```typescript
|
||||
catch (error) {
|
||||
console.error('Error in mission creation:', error);
|
||||
|
||||
// Cleanup: Suppression de tous les fichiers uploadés
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
await s3Client.send(new DeleteObjectCommand({
|
||||
Bucket: 'missions',
|
||||
Key: file.path.replace('missions/', '')
|
||||
}));
|
||||
console.log('Cleaned up file:', file.path);
|
||||
} catch (cleanupError) {
|
||||
console.error('Error cleaning up file:', file.path, cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Failed to create mission',
|
||||
details: error instanceof Error ? error.message : String(error)
|
||||
}, { status: 500 });
|
||||
}
|
||||
```
|
||||
|
||||
**Scénarios de cleanup** :
|
||||
1. ✅ Erreur lors de l'upload du logo → Suppression du logo
|
||||
2. ✅ Erreur lors de l'upload d'un attachment → Suppression de tous les fichiers
|
||||
3. ✅ Erreur lors de la vérification Minio → Suppression de tous les fichiers
|
||||
4. ✅ Erreur N8N → Suppression de tous les fichiers
|
||||
|
||||
**Points importants** :
|
||||
- ✅ La mission reste en base même en cas d'erreur (orphan)
|
||||
- ✅ Les MissionUsers restent en base même en cas d'erreur
|
||||
- ✅ Seuls les fichiers Minio sont nettoyés
|
||||
- ⚠️ **TODO** : Rollback complet (suppression mission + users si erreur)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Résumé des Opérations Prisma
|
||||
|
||||
### Requêtes Prisma dans l'ordre d'exécution :
|
||||
|
||||
1. **`prisma.mission.create()`**
|
||||
- Crée la mission avec tous les champs
|
||||
- Génère un UUID pour `id`
|
||||
- `logo` = `null` initialement
|
||||
|
||||
2. **`prisma.missionUser.createMany()`**
|
||||
- Crée tous les gardiens et volontaires en une requête
|
||||
- Utilise `createMany()` pour performance
|
||||
|
||||
3. **`prisma.mission.update()`** (si logo)
|
||||
- Met à jour le champ `logo` avec le chemin Minio
|
||||
|
||||
4. **`prisma.attachment.create()`** (pour chaque attachment)
|
||||
- Créé en parallèle avec `Promise.all()`
|
||||
- Un enregistrement par fichier
|
||||
|
||||
**Total** : 1 + 1 + (0 ou 1) + N requêtes Prisma
|
||||
- Minimum : 2 requêtes (mission + users)
|
||||
- Avec logo : 3 requêtes
|
||||
- Avec N attachments : 3 + N requêtes
|
||||
|
||||
---
|
||||
|
||||
## 📦 Résumé des Opérations Minio
|
||||
|
||||
### Uploads Minio dans l'ordre d'exécution :
|
||||
|
||||
1. **Logo** (si présent)
|
||||
- Bucket : `missions`
|
||||
- Key : `{missionId}/logo.png`
|
||||
- Path stocké : `missions/{missionId}/logo.png`
|
||||
|
||||
2. **Attachments** (si présents, en parallèle)
|
||||
- Bucket : `missions`
|
||||
- Key : `{missionId}/attachments/{filename}`
|
||||
- Path stocké : `missions/{missionId}/attachments/{filename}`
|
||||
|
||||
3. **Vérifications** (après uploads)
|
||||
- `HeadObjectCommand` pour chaque fichier
|
||||
- Vérifie l'existence avant N8N
|
||||
|
||||
**Total** : 1 + N uploads + (1 + N) vérifications
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Résumé du Workflow N8N
|
||||
|
||||
### Données envoyées à N8N :
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: "Mission Example",
|
||||
oddScope: ["odd-3"],
|
||||
niveau: "a",
|
||||
intention: "Description...",
|
||||
missionType: "remote",
|
||||
donneurDOrdre: "individual",
|
||||
projection: "short",
|
||||
services: ["Gite", "ArtLab"],
|
||||
participation: "volontaire",
|
||||
profils: ["DataIntelligence", "Expression"],
|
||||
guardians: {
|
||||
"gardien-temps": "user-1",
|
||||
"gardien-parole": "user-2",
|
||||
"gardien-memoire": "user-3"
|
||||
},
|
||||
volunteers: ["user-4", "user-5"],
|
||||
creatorId: "user-creator",
|
||||
logoPath: "missions/abc-123/logo.png",
|
||||
config: {
|
||||
N8N_API_KEY: "...",
|
||||
MISSION_API_URL: "https://api.slm-lab.net/api"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Actions N8N (workflow interne) :
|
||||
|
||||
1. Création projet Leantime
|
||||
2. Création collection Outline
|
||||
3. Création canal RocketChat
|
||||
4. Création repository Gitea
|
||||
5. Création projet Penpot
|
||||
|
||||
**Note** : Les IDs retournés ne sont **PAS** sauvegardés en base actuellement.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Points d'Attention et TODOs
|
||||
|
||||
### 1. Sauvegarde des IDs N8N
|
||||
**Problème** : Les IDs retournés par N8N (leantimeProjectId, etc.) ne sont pas sauvegardés en base.
|
||||
|
||||
**Solution proposée** :
|
||||
```typescript
|
||||
// Après le workflow N8N
|
||||
if (workflowResult.success && workflowResult.results) {
|
||||
await prisma.mission.update({
|
||||
where: { id: mission.id },
|
||||
data: {
|
||||
leantimeProjectId: workflowResult.results.leantimeProjectId,
|
||||
outlineCollectionId: workflowResult.results.outlineCollectionId,
|
||||
rocketChatChannelId: workflowResult.results.rocketChatChannelId,
|
||||
giteaRepositoryUrl: workflowResult.results.giteaRepositoryUrl,
|
||||
penpotProjectId: workflowResult.results.penpotProjectId
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Rollback Complet
|
||||
**Problème** : En cas d'erreur, la mission reste en base (orphan).
|
||||
|
||||
**Solution proposée** :
|
||||
```typescript
|
||||
catch (error) {
|
||||
// Suppression de la mission et des relations
|
||||
await prisma.mission.delete({ where: { id: mission.id } });
|
||||
// Les MissionUsers et Attachments seront supprimés en cascade
|
||||
// Nettoyage Minio...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Credentials Minio Hardcodés
|
||||
**Problème** : Les credentials Minio sont hardcodés dans `lib/mission-uploads.ts`.
|
||||
|
||||
**Solution** : Déplacer vers variables d'environnement.
|
||||
|
||||
### 4. Gestion des Erreurs N8N Partielles
|
||||
**Problème** : Si certains services N8N échouent, on continue quand même.
|
||||
|
||||
**Solution** : Décider si on continue ou on rollback selon la criticité.
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance et Optimisations
|
||||
|
||||
### Optimisations actuelles :
|
||||
- ✅ `createMany()` pour MissionUsers (1 requête au lieu de N)
|
||||
- ✅ `Promise.all()` pour les attachments (parallèle)
|
||||
- ✅ `HeadObjectCommand` pour vérification (léger)
|
||||
|
||||
### Optimisations possibles :
|
||||
- 🔄 Transaction Prisma pour atomicité
|
||||
- 🔄 Batch upload Minio (si supporté)
|
||||
- 🔄 Retry logic pour N8N
|
||||
- 🔄 Cache des vérifications Minio
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging et Logs
|
||||
|
||||
### Points de logging importants :
|
||||
|
||||
1. **Début du workflow** : `=== Mission Creation Started ===`
|
||||
2. **Création Prisma** : `Mission created successfully`
|
||||
3. **Upload Minio** : `Logo upload successful` / `Attachment upload successful`
|
||||
4. **Vérification** : `verifyFileExists()` logs
|
||||
5. **N8N** : `=== Starting N8N Workflow ===` / `N8N Workflow Result`
|
||||
6. **Erreurs** : Tous les catch blocks loggent les erreurs
|
||||
|
||||
### Commandes de debug :
|
||||
|
||||
```bash
|
||||
# Voir les logs du serveur
|
||||
npm run dev # Logs dans la console
|
||||
|
||||
# Vérifier Minio
|
||||
# Accéder à https://dome-api.slm-lab.net
|
||||
# Bucket: missions
|
||||
|
||||
# Vérifier Prisma
|
||||
npx prisma studio # Interface graphique
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Document généré le** : $(date)
|
||||
**Version** : 1.0
|
||||
**Auteur** : Analyse complète du codebase
|
||||
|
||||
625
MISSION_DELETION_WORKFLOW.md
Normal file
625
MISSION_DELETION_WORKFLOW.md
Normal file
@ -0,0 +1,625 @@
|
||||
# Workflow Détaillé : Suppression de Mission - Centrale (/missions)
|
||||
|
||||
## 📋 Vue d'Ensemble
|
||||
|
||||
Ce document trace **chaque étape** du workflow de suppression d'une mission depuis la page Centrale (`/missions/[missionId]`), incluant les vérifications de permissions, la suppression des fichiers Minio, et la suppression en base de données.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flux Complet - Vue d'Ensemble
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 1. FRONTEND - MissionDetailPage │
|
||||
│ - Clic sur bouton "Supprimer" │
|
||||
│ - Confirmation utilisateur (confirm dialog) │
|
||||
│ - DELETE /api/missions/[missionId] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2. BACKEND - DELETE /api/missions/[missionId] │
|
||||
│ ├─ Authentification (NextAuth) │
|
||||
│ ├─ Vérification mission existe │
|
||||
│ ├─ Vérification permissions (créateur ou admin) │
|
||||
│ ├─ STEP 1: Suppression logo Minio (si existe) │
|
||||
│ ├─ STEP 2: Rollback N8N (TODO - non implémenté) │
|
||||
│ └─ STEP 3: Suppression mission Prisma (CASCADE) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3. PRISMA CASCADE │
|
||||
│ - Suppression automatique MissionUsers │
|
||||
│ - Suppression automatique Attachments │
|
||||
│ ⚠️ Fichiers Minio des attachments NON supprimés │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 ÉTAPE 1 : Frontend - Clic sur Supprimer
|
||||
|
||||
### Fichier : `app/missions/[missionId]/page.tsx`
|
||||
|
||||
### 1.1 Bouton Supprimer
|
||||
|
||||
**Lignes 397-411**
|
||||
|
||||
```typescript
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 border-red-600 text-red-600 hover:bg-red-50 bg-white"
|
||||
onClick={handleDeleteMission}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-red-600"></div>
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
Supprimer
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Caractéristiques** :
|
||||
- ✅ Bouton rouge avec icône Trash2
|
||||
- ✅ Désactivé pendant la suppression (`deleting` state)
|
||||
- ✅ Affiche un spinner pendant le traitement
|
||||
|
||||
### 1.2 Handler de Suppression
|
||||
|
||||
**Lignes 143-176**
|
||||
|
||||
```typescript
|
||||
const handleDeleteMission = async () => {
|
||||
// 1. Confirmation utilisateur
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer cette mission ? Cette action est irréversible.")) {
|
||||
return; // Annulation si l'utilisateur refuse
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true); // Active le spinner
|
||||
|
||||
// 2. Appel API DELETE
|
||||
const response = await fetch(`/api/missions/${missionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// 3. Vérification de la réponse
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete mission');
|
||||
}
|
||||
|
||||
// 4. Notification de succès
|
||||
toast({
|
||||
title: "Mission supprimée",
|
||||
description: "La mission a été supprimée avec succès",
|
||||
});
|
||||
|
||||
// 5. Redirection vers la liste des missions
|
||||
router.push('/missions');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission:', error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la mission",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false); // Désactive le spinner
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ **Double confirmation** : Dialog natif du navigateur
|
||||
- ✅ **Gestion d'état** : `deleting` pour le spinner
|
||||
- ✅ **Redirection automatique** vers `/missions` en cas de succès
|
||||
- ✅ **Gestion d'erreurs** avec toast notification
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ ÉTAPE 2 : Backend - DELETE /api/missions/[missionId]
|
||||
|
||||
### Fichier : `app/api/missions/[missionId]/route.ts`
|
||||
|
||||
### 2.1 Authentification et Récupération de la Mission
|
||||
|
||||
**Lignes 292-316**
|
||||
|
||||
```typescript
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
props: { params: Promise<{ missionId: string }> }
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// 1. Vérification de l'authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Récupération de la mission avec relations
|
||||
const mission = await prisma.mission.findUnique({
|
||||
where: { id: params.missionId },
|
||||
include: {
|
||||
missionUsers: {
|
||||
include: {
|
||||
user: true // Inclut les infos des utilisateurs
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Vérification que la mission existe
|
||||
if (!mission) {
|
||||
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Vérification de session NextAuth
|
||||
- ✅ Récupération de la mission avec `missionUsers` (pour logging/info)
|
||||
- ✅ Retour 404 si mission n'existe pas
|
||||
|
||||
### 2.2 Vérification des Permissions
|
||||
|
||||
**Lignes 318-324**
|
||||
|
||||
```typescript
|
||||
// Vérification : utilisateur doit être créateur OU admin
|
||||
const isCreator = mission.creatorId === session.user.id;
|
||||
const userRoles = Array.isArray(session.user.role) ? session.user.role : [];
|
||||
const isAdmin = userRoles.includes('admin') || userRoles.includes('ADMIN');
|
||||
|
||||
if (!isCreator && !isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
```
|
||||
|
||||
**Règles de permissions** :
|
||||
- ✅ **Créateur** : Peut supprimer sa propre mission
|
||||
- ✅ **Admin** : Peut supprimer n'importe quelle mission
|
||||
- ❌ **Autres utilisateurs** : Même gardiens/volontaires ne peuvent pas supprimer
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Vérification stricte : seul le créateur ou un admin peut supprimer
|
||||
- ✅ Les gardiens (même gardien-memoire) ne peuvent pas supprimer
|
||||
- ✅ Retour 403 Forbidden si pas autorisé
|
||||
|
||||
### 2.3 STEP 1 : Suppression du Logo dans Minio
|
||||
|
||||
**Lignes 326-334**
|
||||
|
||||
```typescript
|
||||
// Suppression du logo si présent
|
||||
if (mission.logo) {
|
||||
try {
|
||||
await deleteMissionLogo(params.missionId, mission.logo);
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission logo:', error);
|
||||
// Continue deletion even if logo deletion fails
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fonction `deleteMissionLogo()` - `lib/mission-uploads.ts`** :
|
||||
|
||||
**Lignes 42-61**
|
||||
|
||||
```typescript
|
||||
export async function deleteMissionLogo(
|
||||
missionId: string,
|
||||
logoPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const normalizedPath = ensureMissionsPrefix(logoPath);
|
||||
// ⚠️ TODO: La fonction ne fait que logger, ne supprime pas vraiment !
|
||||
console.log('Deleting mission logo:', {
|
||||
missionId,
|
||||
originalPath: logoPath,
|
||||
normalizedPath
|
||||
});
|
||||
// TODO: Implémenter la suppression réelle avec DeleteObjectCommand
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission logo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ PROBLÈME IDENTIFIÉ** :
|
||||
- ❌ La fonction `deleteMissionLogo()` ne supprime **PAS** réellement le fichier
|
||||
- ❌ Elle ne fait que logger les informations
|
||||
- ⚠️ Le logo reste dans Minio après suppression de la mission
|
||||
|
||||
**Solution proposée** :
|
||||
```typescript
|
||||
export async function deleteMissionLogo(
|
||||
missionId: string,
|
||||
logoPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
const normalizedPath = ensureMissionsPrefix(logoPath);
|
||||
const minioPath = normalizedPath.replace(/^missions\//, '');
|
||||
|
||||
await s3Client.send(new DeleteObjectCommand({
|
||||
Bucket: 'missions',
|
||||
Key: minioPath
|
||||
}));
|
||||
|
||||
console.log('Mission logo deleted successfully:', minioPath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission logo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Continue la suppression même si le logo échoue (try/catch)
|
||||
- ⚠️ **BUG** : Le logo n'est pas réellement supprimé actuellement
|
||||
|
||||
### 2.4 STEP 2 : Rollback N8N (TODO - Non Implémenté)
|
||||
|
||||
**Lignes 336-344**
|
||||
|
||||
```typescript
|
||||
// Trigger n8n workflow for rollback
|
||||
// TODO: Implement rollbackMission method in N8nService
|
||||
// const n8nService = new N8nService();
|
||||
// try {
|
||||
// await n8nService.rollbackMission(mission);
|
||||
// } catch (error) {
|
||||
// console.error('Error during mission rollback:', error);
|
||||
// // Continue with mission deletion even if rollback fails
|
||||
// }
|
||||
```
|
||||
|
||||
**⚠️ NON IMPLÉMENTÉ** :
|
||||
- ❌ Le rollback N8N n'est pas appelé
|
||||
- ❌ Les intégrations externes (Leantime, Outline, RocketChat, etc.) ne sont **PAS** supprimées
|
||||
- ⚠️ Les ressources externes restent orphelines
|
||||
|
||||
**Méthode disponible mais non utilisée** :
|
||||
- ✅ `N8nService.triggerMissionRollback()` existe dans `lib/services/n8n-service.ts`
|
||||
- ✅ Webhook URL : `https://brain.slm-lab.net/webhook/mission-rollback`
|
||||
- ❌ Mais n'est pas appelée dans le DELETE
|
||||
|
||||
**Solution proposée** :
|
||||
```typescript
|
||||
// Rollback N8N
|
||||
const n8nService = new N8nService();
|
||||
try {
|
||||
await n8nService.triggerMissionRollback({
|
||||
missionId: mission.id,
|
||||
leantimeProjectId: mission.leantimeProjectId,
|
||||
outlineCollectionId: mission.outlineCollectionId,
|
||||
rocketChatChannelId: mission.rocketChatChannelId,
|
||||
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||
penpotProjectId: mission.penpotProjectId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during mission rollback:', error);
|
||||
// Continue with mission deletion even if rollback fails
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 STEP 3 : Suppression de la Mission en Base de Données
|
||||
|
||||
**Lignes 346-349**
|
||||
|
||||
```typescript
|
||||
// Suppression de la mission (CASCADE automatique)
|
||||
await prisma.mission.delete({
|
||||
where: { id: params.missionId }
|
||||
});
|
||||
```
|
||||
|
||||
**Schéma Prisma - Relations avec CASCADE** :
|
||||
|
||||
```prisma
|
||||
model Mission {
|
||||
// ...
|
||||
attachments Attachment[]
|
||||
missionUsers MissionUser[]
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
mission Mission @relation(fields: [missionId], references: [id], onDelete: Cascade)
|
||||
// ...
|
||||
}
|
||||
|
||||
model MissionUser {
|
||||
mission Mission @relation(fields: [missionId], references: [id], onDelete: Cascade)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**CASCADE automatique** :
|
||||
- ✅ **MissionUsers** : Supprimés automatiquement (`onDelete: Cascade`)
|
||||
- ✅ **Attachments** : Supprimés automatiquement (`onDelete: Cascade`)
|
||||
- ❌ **Fichiers Minio** : **NON supprimés automatiquement** (pas de trigger)
|
||||
|
||||
**Points importants** :
|
||||
- ✅ Une seule requête Prisma supprime la mission et toutes ses relations
|
||||
- ✅ Atomicité : Si la suppression échoue, rien n'est supprimé
|
||||
- ⚠️ **PROBLÈME** : Les fichiers Minio des attachments ne sont pas supprimés
|
||||
|
||||
### 2.6 Retour de Succès
|
||||
|
||||
**Lignes 351-358**
|
||||
|
||||
```typescript
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete mission' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 ÉTAPE 3 : Cascade Prisma
|
||||
|
||||
### Suppression Automatique des Relations
|
||||
|
||||
Quand `prisma.mission.delete()` est exécuté, Prisma supprime automatiquement :
|
||||
|
||||
1. **Tous les MissionUsers** associés
|
||||
```sql
|
||||
DELETE FROM "MissionUser" WHERE "missionId" = 'abc-123';
|
||||
```
|
||||
|
||||
2. **Tous les Attachments** associés
|
||||
```sql
|
||||
DELETE FROM "Attachment" WHERE "missionId" = 'abc-123';
|
||||
```
|
||||
|
||||
**⚠️ PROBLÈME MAJEUR** :
|
||||
- ❌ Les fichiers Minio des attachments ne sont **PAS** supprimés
|
||||
- ❌ Les fichiers restent dans Minio : `missions/{missionId}/attachments/*`
|
||||
- ⚠️ **Orphelins** : Fichiers sans enregistrement en base
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Problèmes Identifiés et Solutions
|
||||
|
||||
### Problème 1 : Logo non supprimé dans Minio
|
||||
|
||||
**Symptôme** : Le logo reste dans Minio après suppression
|
||||
|
||||
**Cause** : `deleteMissionLogo()` ne fait que logger, ne supprime pas
|
||||
|
||||
**Solution** :
|
||||
```typescript
|
||||
export async function deleteMissionLogo(
|
||||
missionId: string,
|
||||
logoPath: string
|
||||
): Promise<void> {
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
const normalizedPath = ensureMissionsPrefix(logoPath);
|
||||
const minioPath = normalizedPath.replace(/^missions\//, '');
|
||||
|
||||
await s3Client.send(new DeleteObjectCommand({
|
||||
Bucket: 'missions',
|
||||
Key: minioPath
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
### Problème 2 : Attachments non supprimés dans Minio
|
||||
|
||||
**Symptôme** : Les fichiers attachments restent dans Minio
|
||||
|
||||
**Cause** : Pas de suppression des fichiers avant la suppression Prisma
|
||||
|
||||
**Solution** :
|
||||
```typescript
|
||||
// Avant la suppression Prisma
|
||||
if (mission.attachments && mission.attachments.length > 0) {
|
||||
// Récupérer les attachments
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { missionId: params.missionId }
|
||||
});
|
||||
|
||||
// Supprimer chaque fichier Minio
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
await deleteMissionAttachment(attachment.filePath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment file:', error);
|
||||
// Continue même si un fichier échoue
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Problème 3 : Rollback N8N non implémenté
|
||||
|
||||
**Symptôme** : Les intégrations externes restent orphelines
|
||||
|
||||
**Cause** : Code commenté, non implémenté
|
||||
|
||||
**Solution** :
|
||||
```typescript
|
||||
// Décommenter et implémenter
|
||||
const n8nService = new N8nService();
|
||||
try {
|
||||
await n8nService.triggerMissionRollback({
|
||||
missionId: mission.id,
|
||||
leantimeProjectId: mission.leantimeProjectId,
|
||||
outlineCollectionId: mission.outlineCollectionId,
|
||||
rocketChatChannelId: mission.rocketChatChannelId,
|
||||
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||
penpotProjectId: mission.penpotProjectId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during mission rollback:', error);
|
||||
// Continue avec la suppression même si rollback échoue
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Résumé des Opérations
|
||||
|
||||
### Opérations Effectuées ✅
|
||||
|
||||
1. ✅ **Vérification authentification** : Session NextAuth
|
||||
2. ✅ **Vérification permissions** : Créateur ou Admin
|
||||
3. ✅ **Suppression Prisma Mission** : Avec cascade automatique
|
||||
4. ✅ **Suppression Prisma MissionUsers** : Cascade automatique
|
||||
5. ✅ **Suppression Prisma Attachments** : Cascade automatique
|
||||
|
||||
### Opérations NON Effectuées ❌
|
||||
|
||||
1. ❌ **Suppression logo Minio** : Fonction ne fait que logger
|
||||
2. ❌ **Suppression attachments Minio** : Pas de code pour supprimer
|
||||
3. ❌ **Rollback N8N** : Code commenté, non implémenté
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Workflow Complet Corrigé (Proposé)
|
||||
|
||||
```typescript
|
||||
export async function DELETE(...) {
|
||||
// 1. Authentification
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) return 401;
|
||||
|
||||
// 2. Récupération mission avec attachments
|
||||
const mission = await prisma.mission.findUnique({
|
||||
where: { id: params.missionId },
|
||||
include: {
|
||||
attachments: true, // Inclure les attachments
|
||||
missionUsers: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!mission) return 404;
|
||||
|
||||
// 3. Vérification permissions
|
||||
const isCreator = mission.creatorId === session.user.id;
|
||||
const isAdmin = session.user.role?.includes('admin');
|
||||
if (!isCreator && !isAdmin) return 403;
|
||||
|
||||
// 4. Suppression logo Minio
|
||||
if (mission.logo) {
|
||||
try {
|
||||
await deleteMissionLogo(mission.id, mission.logo); // À implémenter
|
||||
} catch (error) {
|
||||
console.error('Error deleting logo:', error);
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Suppression attachments Minio
|
||||
if (mission.attachments && mission.attachments.length > 0) {
|
||||
for (const attachment of mission.attachments) {
|
||||
try {
|
||||
await deleteMissionAttachment(attachment.filePath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment:', error);
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Rollback N8N
|
||||
const n8nService = new N8nService();
|
||||
try {
|
||||
await n8nService.triggerMissionRollback({
|
||||
missionId: mission.id,
|
||||
leantimeProjectId: mission.leantimeProjectId,
|
||||
outlineCollectionId: mission.outlineCollectionId,
|
||||
rocketChatChannelId: mission.rocketChatChannelId,
|
||||
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||
penpotProjectId: mission.penpotProjectId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during N8N rollback:', error);
|
||||
// Continue même si rollback échoue
|
||||
}
|
||||
|
||||
// 7. Suppression Prisma (CASCADE)
|
||||
await prisma.mission.delete({
|
||||
where: { id: params.missionId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Ordre d'Exécution Recommandé
|
||||
|
||||
1. **Vérifications** (authentification, permissions, existence)
|
||||
2. **Suppression fichiers Minio** (logo + attachments)
|
||||
3. **Rollback N8N** (intégrations externes)
|
||||
4. **Suppression Prisma** (mission + cascade automatique)
|
||||
|
||||
**Pourquoi cet ordre ?**
|
||||
- ✅ Supprimer les fichiers avant la base pour éviter les orphelins
|
||||
- ✅ Rollback N8N avant suppression Prisma pour avoir les IDs
|
||||
- ✅ Suppression Prisma en dernier (point de non-retour)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Points d'Attention
|
||||
|
||||
### 1. Atomicité
|
||||
**Problème** : Si une étape échoue, les précédentes sont déjà faites
|
||||
|
||||
**Solution** : Transaction Prisma + Rollback manuel si erreur
|
||||
|
||||
### 2. Performance
|
||||
**Problème** : Suppression séquentielle des fichiers Minio
|
||||
|
||||
**Solution** : `Promise.all()` pour suppressions parallèles
|
||||
|
||||
### 3. Gestion d'Erreurs
|
||||
**Problème** : Continue même si certaines suppressions échouent
|
||||
|
||||
**Solution** : Décider si on continue ou on rollback selon criticité
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Logs à surveiller :
|
||||
|
||||
1. **Début suppression** : `DELETE /api/missions/[id]`
|
||||
2. **Permissions** : `isCreator` / `isAdmin`
|
||||
3. **Suppression logo** : `Deleting mission logo`
|
||||
4. **Suppression attachments** : `Deleting mission attachment`
|
||||
5. **Rollback N8N** : `Triggering n8n rollback workflow`
|
||||
6. **Suppression Prisma** : `prisma.mission.delete()`
|
||||
|
||||
### Vérifications manuelles :
|
||||
|
||||
```bash
|
||||
# Vérifier Minio
|
||||
# Accéder à https://dome-api.slm-lab.net
|
||||
# Bucket: missions
|
||||
# Vérifier que les dossiers {missionId} sont supprimés
|
||||
|
||||
# Vérifier Prisma
|
||||
npx prisma studio
|
||||
# Vérifier que la mission et ses relations sont supprimées
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Document généré le** : $(date)
|
||||
**Version** : 1.0
|
||||
**Auteur** : Analyse complète du codebase
|
||||
|
||||
@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/app/api/auth/options";
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { deleteMissionLogo, getMissionFileUrl } from '@/lib/mission-uploads';
|
||||
import { deleteMissionLogo, deleteMissionAttachment, getMissionFileUrl } from '@/lib/mission-uploads';
|
||||
import { getPublicUrl, S3_CONFIG } from '@/lib/s3';
|
||||
import { N8nService } from '@/lib/services/n8n-service';
|
||||
|
||||
@ -323,31 +323,72 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get attachments before deletion (needed for Minio cleanup)
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { missionId: params.missionId }
|
||||
});
|
||||
|
||||
// Step 1: Trigger N8N workflow for deletion (rollback external integrations)
|
||||
console.log('=== Starting N8N Deletion Workflow ===');
|
||||
const n8nService = new N8nService();
|
||||
|
||||
const n8nDeletionData = {
|
||||
missionId: mission.id,
|
||||
name: mission.name,
|
||||
leantimeProjectId: mission.leantimeProjectId,
|
||||
outlineCollectionId: mission.outlineCollectionId,
|
||||
rocketChatChannelId: mission.rocketChatChannelId,
|
||||
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||
penpotProjectId: mission.penpotProjectId,
|
||||
config: {
|
||||
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Sending deletion data to N8N:', JSON.stringify(n8nDeletionData, null, 2));
|
||||
|
||||
const n8nResult = await n8nService.triggerMissionDeletion(n8nDeletionData);
|
||||
console.log('N8N Deletion Workflow Result:', JSON.stringify(n8nResult, null, 2));
|
||||
|
||||
if (!n8nResult.success) {
|
||||
console.error('N8N deletion workflow failed, but continuing with mission deletion:', n8nResult.error);
|
||||
// Continue with deletion even if N8N fails (non-blocking)
|
||||
}
|
||||
|
||||
// Step 2: Delete files from Minio AFTER N8N confirmation
|
||||
// Delete logo if exists
|
||||
if (mission.logo) {
|
||||
try {
|
||||
await deleteMissionLogo(params.missionId, mission.logo);
|
||||
console.log('Logo deleted successfully from Minio');
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission logo:', error);
|
||||
console.error('Error deleting mission logo from Minio:', error);
|
||||
// Continue deletion even if logo deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger n8n workflow for rollback
|
||||
// TODO: Implement rollbackMission method in N8nService
|
||||
// const n8nService = new N8nService();
|
||||
// try {
|
||||
// await n8nService.rollbackMission(mission);
|
||||
// } catch (error) {
|
||||
// console.error('Error during mission rollback:', error);
|
||||
// // Continue with mission deletion even if rollback fails
|
||||
// }
|
||||
// Delete attachments from Minio
|
||||
if (attachments.length > 0) {
|
||||
console.log(`Deleting ${attachments.length} attachment(s) from Minio...`);
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
await deleteMissionAttachment(attachment.filePath);
|
||||
console.log(`Attachment deleted successfully: ${attachment.filename}`);
|
||||
} catch (error) {
|
||||
console.error(`Error deleting attachment ${attachment.filename} from Minio:`, error);
|
||||
// Continue deletion even if one attachment fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the mission
|
||||
// Step 3: Delete the mission from database (CASCADE will delete MissionUsers and Attachments)
|
||||
await prisma.mission.delete({
|
||||
where: { id: params.missionId }
|
||||
});
|
||||
|
||||
console.log('Mission deleted successfully from database');
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission:', error);
|
||||
|
||||
@ -41,13 +41,25 @@ export function getMissionFileUrl(path: string): string {
|
||||
// Helper function to delete a mission logo
|
||||
export async function deleteMissionLogo(missionId: string, logoPath: string): Promise<void> {
|
||||
try {
|
||||
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
const normalizedPath = ensureMissionsPrefix(logoPath);
|
||||
// Add your S3/MinIO deletion logic here
|
||||
const minioPath = normalizedPath.replace(/^missions\//, ''); // Remove prefix for Minio
|
||||
|
||||
console.log('Deleting mission logo:', {
|
||||
missionId,
|
||||
originalPath: logoPath,
|
||||
normalizedPath
|
||||
normalizedPath,
|
||||
minioPath
|
||||
});
|
||||
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: 'missions',
|
||||
Key: minioPath,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
|
||||
console.log('Mission logo deleted successfully:', minioPath);
|
||||
} catch (error) {
|
||||
console.error('Error deleting mission logo:', {
|
||||
error,
|
||||
|
||||
@ -16,6 +16,75 @@ export class N8nService {
|
||||
}
|
||||
}
|
||||
|
||||
async triggerMissionDeletion(data: any): Promise<any> {
|
||||
try {
|
||||
const deleteWebhookUrl = process.env.N8N_DELETE_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook-test/mission-delete';
|
||||
|
||||
console.log('Triggering n8n mission deletion workflow with data:', JSON.stringify(data, null, 2));
|
||||
console.log('Using deletion webhook URL:', deleteWebhookUrl);
|
||||
console.log('API key present:', !!this.apiKey);
|
||||
|
||||
const response = await fetch(deleteWebhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
console.log('Deletion webhook response status:', response.status);
|
||||
console.log('Deletion webhook response headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Deletion webhook error response:', errorText);
|
||||
// Try to parse the error response as JSON for more details
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
console.error('Parsed error response:', errorJson);
|
||||
} catch (e) {
|
||||
console.error('Error response is not JSON');
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}, body: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log('N8nService - Deletion raw response:', responseText);
|
||||
|
||||
// Try to parse the response as JSON
|
||||
try {
|
||||
const result = JSON.parse(responseText);
|
||||
console.log('Parsed deletion workflow result:', JSON.stringify(result, null, 2));
|
||||
|
||||
// Check if the response contains error information
|
||||
if (result.error || result.message?.includes('failed')) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || result.error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
results: result
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.log('Response is not JSON, treating as workflow trigger confirmation');
|
||||
return {
|
||||
success: true,
|
||||
results: { confirmed: true }
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error triggering n8n deletion workflow:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async triggerMissionCreation(data: any): Promise<any> {
|
||||
try {
|
||||
console.log('N8nService - Input data:', {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user