Initial commit
This commit is contained in:
commit
bcf7832d74
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
724
AUDIT_API_N8N_CONNECTION.md
Normal file
724
AUDIT_API_N8N_CONNECTION.md
Normal file
@ -0,0 +1,724 @@
|
|||||||
|
# 🔍 Audit Développeur Senior - Connexion API Next.js ↔️ N8N (Missions)
|
||||||
|
|
||||||
|
**Date**: $(date)
|
||||||
|
**Auteur**: Audit Développeur Senior
|
||||||
|
**Objectif**: Vérifier et documenter la connexion entre Next.js et N8N pour la gestion des missions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Table des Matières
|
||||||
|
|
||||||
|
1. [Architecture Globale](#architecture-globale)
|
||||||
|
2. [Flux de Communication](#flux-de-communication)
|
||||||
|
3. [Endpoints API](#endpoints-api)
|
||||||
|
4. [Configuration Requise](#configuration-requise)
|
||||||
|
5. [Sécurité](#sécurité)
|
||||||
|
6. [Points Critiques à Vérifier](#points-critiques-à-vérifier)
|
||||||
|
7. [Problèmes Potentiels et Solutions](#problèmes-potentiels-et-solutions)
|
||||||
|
8. [Tests et Validation](#tests-et-validation)
|
||||||
|
9. [Recommandations](#recommandations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture Globale
|
||||||
|
|
||||||
|
### Vue d'ensemble
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ Next.js │────────▶│ N8N │────────▶│ Intégrations│
|
||||||
|
│ (API) │ │ (Workflow) │ │ (Gitea, etc)│
|
||||||
|
└─────────────┘ └─────────────┘ └─────────────┘
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────┘
|
||||||
|
(Callback)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composants Principaux
|
||||||
|
|
||||||
|
1. **Next.js API Routes**
|
||||||
|
- `POST /api/missions` - Création de mission
|
||||||
|
- `POST /api/missions/mission-created` - Callback de N8N
|
||||||
|
- `GET /api/missions` - Liste des missions
|
||||||
|
|
||||||
|
2. **Service N8N** (`lib/services/n8n-service.ts`)
|
||||||
|
- Envoi de données vers N8N
|
||||||
|
- Gestion des webhooks
|
||||||
|
- Gestion des erreurs
|
||||||
|
|
||||||
|
3. **N8N Workflows**
|
||||||
|
- Webhook de réception: `/webhook/mission-created`
|
||||||
|
- Création des intégrations externes
|
||||||
|
- Callback vers Next.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flux de Communication
|
||||||
|
|
||||||
|
### 1. Création d'une Mission (Next.js → N8N → Next.js)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ÉTAPE 1: Création Mission dans Next.js │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
POST /api/missions
|
||||||
|
↓
|
||||||
|
1. Validation des données
|
||||||
|
2. Création en base de données (Prisma)
|
||||||
|
3. Upload des fichiers (logo, attachments) vers Minio
|
||||||
|
4. Vérification des fichiers
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ÉTAPE 2: Envoi vers N8N │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
POST https://brain.slm-lab.net/webhook/mission-created
|
||||||
|
Headers:
|
||||||
|
- Content-Type: application/json
|
||||||
|
- x-api-key: {N8N_API_KEY}
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
missionId: "uuid",
|
||||||
|
name: "...",
|
||||||
|
oddScope: [...],
|
||||||
|
services: [...],
|
||||||
|
config: {
|
||||||
|
N8N_API_KEY: "...",
|
||||||
|
MISSION_API_URL: "https://api.slm-lab.net/api"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ÉTAPE 3: Traitement N8N │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
N8N Workflow:
|
||||||
|
1. Réception webhook
|
||||||
|
2. Création Gitea repository (si service "Gite")
|
||||||
|
3. Création Leantime project (si service "Leantime")
|
||||||
|
4. Création Outline collection (si service "Documentation")
|
||||||
|
5. Création RocketChat channel (si service "RocketChat")
|
||||||
|
6. Préparation des données de callback
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ÉTAPE 4: Callback N8N → Next.js │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
POST {MISSION_API_URL}/api/missions/mission-created
|
||||||
|
Headers:
|
||||||
|
- Content-Type: application/json
|
||||||
|
- x-api-key: {N8N_API_KEY} (depuis config.N8N_API_KEY)
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
missionId: "uuid",
|
||||||
|
gitRepoUrl: "...",
|
||||||
|
leantimeProjectId: "...",
|
||||||
|
documentationCollectionId: "...",
|
||||||
|
rocketchatChannelId: "..."
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ÉTAPE 5: Mise à jour Mission dans Next.js │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
Validation API key
|
||||||
|
Recherche mission par missionId
|
||||||
|
Mise à jour des champs d'intégration:
|
||||||
|
- giteaRepositoryUrl
|
||||||
|
- leantimeProjectId
|
||||||
|
- outlineCollectionId
|
||||||
|
- rocketChatChannelId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Endpoints API
|
||||||
|
|
||||||
|
### 1. POST /api/missions
|
||||||
|
|
||||||
|
**Fichier**: `app/api/missions/route.ts`
|
||||||
|
|
||||||
|
**Fonction**: Créer une nouvelle mission et déclencher le workflow N8N
|
||||||
|
|
||||||
|
**Authentification**:
|
||||||
|
- Session utilisateur requise (via `getServerSession`)
|
||||||
|
- Vérification: `checkAuth(request)`
|
||||||
|
|
||||||
|
**Body attendu**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
oddScope: string[];
|
||||||
|
niveau?: string;
|
||||||
|
intention?: string;
|
||||||
|
missionType?: string;
|
||||||
|
services?: string[];
|
||||||
|
guardians?: Record<string, string>;
|
||||||
|
volunteers?: string[];
|
||||||
|
logo?: { data: string; name?: string; type?: string };
|
||||||
|
attachments?: Array<{ data: string; name?: string; type?: string }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"mission": { ... },
|
||||||
|
"message": "Mission created successfully with all integrations"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points critiques**:
|
||||||
|
- ✅ Mission créée en base AVANT l'envoi à N8N
|
||||||
|
- ✅ Fichiers uploadés et vérifiés AVANT l'envoi à N8N
|
||||||
|
- ✅ `missionId` inclus dans les données envoyées à N8N
|
||||||
|
- ✅ `config.N8N_API_KEY` et `config.MISSION_API_URL` inclus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. POST /api/missions/mission-created
|
||||||
|
|
||||||
|
**Fichier**: `app/api/missions/mission-created/route.ts`
|
||||||
|
|
||||||
|
**Fonction**: Recevoir les IDs d'intégration de N8N et mettre à jour la mission
|
||||||
|
|
||||||
|
**Authentification**:
|
||||||
|
- **API Key** via header `x-api-key`
|
||||||
|
- **PAS** de session utilisateur requise (N8N n'a pas de session)
|
||||||
|
|
||||||
|
**Headers requis**:
|
||||||
|
```
|
||||||
|
x-api-key: {N8N_API_KEY}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body attendu**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
missionId: string; // ✅ Préféré (plus fiable)
|
||||||
|
// OU (fallback pour compatibilité)
|
||||||
|
name: string;
|
||||||
|
creatorId: string;
|
||||||
|
|
||||||
|
// IDs d'intégration (optionnels)
|
||||||
|
gitRepoUrl?: string;
|
||||||
|
leantimeProjectId?: string | number;
|
||||||
|
documentationCollectionId?: string;
|
||||||
|
rocketchatChannelId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse succès**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Mission updated successfully",
|
||||||
|
"mission": {
|
||||||
|
"id": "...",
|
||||||
|
"name": "...",
|
||||||
|
"giteaRepositoryUrl": "...",
|
||||||
|
"leantimeProjectId": "...",
|
||||||
|
"outlineCollectionId": "...",
|
||||||
|
"rocketChatChannelId": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codes d'erreur**:
|
||||||
|
- `401` - API key invalide ou manquante
|
||||||
|
- `400` - Champs requis manquants
|
||||||
|
- `404` - Mission non trouvée
|
||||||
|
- `500` - Erreur serveur
|
||||||
|
|
||||||
|
**Points critiques**:
|
||||||
|
- ✅ Validation stricte de l'API key
|
||||||
|
- ✅ Recherche par `missionId` (préféré) ou `name + creatorId` (fallback)
|
||||||
|
- ✅ Conversion `leantimeProjectId` de number vers string si nécessaire
|
||||||
|
- ✅ Mise à jour uniquement des champs fournis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration Requise
|
||||||
|
|
||||||
|
### Variables d'Environnement
|
||||||
|
|
||||||
|
#### 1. N8N_API_KEY (OBLIGATOIRE)
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
- Envoyé à N8N dans `config.N8N_API_KEY`
|
||||||
|
- N8N l'utilise pour authentifier le callback
|
||||||
|
- Vérifié côté serveur dans `/api/missions/mission-created`
|
||||||
|
|
||||||
|
**Où configurer**:
|
||||||
|
- `.env.local` (développement)
|
||||||
|
- Variables d'environnement production (CapRover, Vercel, Docker, etc.)
|
||||||
|
|
||||||
|
**Vérification**:
|
||||||
|
```typescript
|
||||||
|
// Erreur si non défini
|
||||||
|
if (!process.env.N8N_API_KEY) {
|
||||||
|
logger.error('N8N_API_KEY is not set in environment variables');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. N8N_WEBHOOK_URL (Optionnel)
|
||||||
|
```env
|
||||||
|
N8N_WEBHOOK_URL=https://brain.slm-lab.net/webhook/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valeur par défaut**: `https://brain.slm-lab.net/webhook/mission-created`
|
||||||
|
|
||||||
|
**Usage**: URL du webhook N8N pour la création de mission
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. NEXT_PUBLIC_API_URL (Recommandé)
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=https://api.slm-lab.net/api
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
- Envoyé à N8N dans `config.MISSION_API_URL`
|
||||||
|
- N8N l'utilise pour construire l'URL du callback
|
||||||
|
- Format attendu: `{MISSION_API_URL}/api/missions/mission-created`
|
||||||
|
|
||||||
|
**Valeur par défaut**: `https://api.slm-lab.net/api`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. N8N_ROLLBACK_WEBHOOK_URL (Optionnel)
|
||||||
|
```env
|
||||||
|
N8N_ROLLBACK_WEBHOOK_URL=https://brain.slm-lab.net/webhook/mission-rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**: URL du webhook N8N pour le rollback de mission
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Configuration N8N Workflow
|
||||||
|
|
||||||
|
#### Webhook de Réception
|
||||||
|
|
||||||
|
**Path**: `mission-created`
|
||||||
|
**URL complète**: `https://brain.slm-lab.net/webhook/mission-created`
|
||||||
|
**Méthode**: `POST`
|
||||||
|
**Status**: Doit être **ACTIF** (toggle vert dans N8N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Node "Save Mission To API"
|
||||||
|
|
||||||
|
**URL**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**Méthode**: `POST`
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
x-api-key: {{ $node['Process Mission Data'].json.config.N8N_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missionId": "{{ $node['Process Mission Data'].json.missionId }}",
|
||||||
|
"gitRepoUrl": "{{ $node['Create Git Repo'].json.url }}",
|
||||||
|
"leantimeProjectId": "{{ $node['Create Leantime Project'].json.id }}",
|
||||||
|
"documentationCollectionId": "{{ $node['Create Outline Collection'].json.id }}",
|
||||||
|
"rocketchatChannelId": "{{ $node['Create RocketChat Channel'].json.id }}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points critiques**:
|
||||||
|
- ✅ Utiliser `config.MISSION_API_URL` (pas d'URL en dur)
|
||||||
|
- ✅ Utiliser `config.N8N_API_KEY` (pas de clé en dur)
|
||||||
|
- ✅ Inclure `missionId` dans le body
|
||||||
|
- ✅ Inclure tous les IDs d'intégration créés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Sécurité
|
||||||
|
|
||||||
|
### 1. Authentification API Key
|
||||||
|
|
||||||
|
**Mécanisme**:
|
||||||
|
- N8N envoie `x-api-key` header
|
||||||
|
- Next.js compare avec `process.env.N8N_API_KEY`
|
||||||
|
- Si différent → `401 Unauthorized`
|
||||||
|
|
||||||
|
**Code de validation** (`app/api/missions/mission-created/route.ts:42`):
|
||||||
|
```typescript
|
||||||
|
const apiKey = request.headers.get('x-api-key');
|
||||||
|
const expectedApiKey = process.env.N8N_API_KEY;
|
||||||
|
|
||||||
|
if (apiKey !== expectedApiKey) {
|
||||||
|
logger.error('Invalid API key', {
|
||||||
|
received: apiKey ? 'present' : 'missing',
|
||||||
|
expected: expectedApiKey ? 'configured' : 'missing'
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points critiques**:
|
||||||
|
- ✅ Comparaison stricte (pas de hash, clé en clair)
|
||||||
|
- ✅ Logging des tentatives invalides
|
||||||
|
- ✅ Pas de fallback si clé manquante
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Transmission de la Clé API
|
||||||
|
|
||||||
|
**Flux**:
|
||||||
|
1. Next.js lit `process.env.N8N_API_KEY`
|
||||||
|
2. Next.js envoie à N8N dans `config.N8N_API_KEY`
|
||||||
|
3. N8N stocke temporairement dans le workflow
|
||||||
|
4. N8N renvoie dans header `x-api-key` lors du callback
|
||||||
|
|
||||||
|
**Risque**: Si `N8N_API_KEY` est `undefined` au moment de l'envoi:
|
||||||
|
- N8N reçoit `undefined` ou chaîne vide
|
||||||
|
- N8N envoie chaîne vide dans le header
|
||||||
|
- Next.js rejette avec `401`
|
||||||
|
|
||||||
|
**Solution**: Vérifier que `N8N_API_KEY` est défini avant l'envoi à N8N
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Validation des Données
|
||||||
|
|
||||||
|
**Côté Next.js**:
|
||||||
|
- ✅ Validation des champs requis
|
||||||
|
- ✅ Recherche de mission par `missionId` (plus sûr que `name + creatorId`)
|
||||||
|
- ✅ Conversion de types (number → string pour `leantimeProjectId`)
|
||||||
|
|
||||||
|
**Côté N8N**:
|
||||||
|
- ⚠️ Pas de validation visible dans le code Next.js
|
||||||
|
- ⚠️ N8N doit valider les données avant création des intégrations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Points Critiques à Vérifier
|
||||||
|
|
||||||
|
### 1. Configuration Environnement
|
||||||
|
|
||||||
|
- [ ] `N8N_API_KEY` est défini dans l'environnement
|
||||||
|
- [ ] `N8N_API_KEY` a la même valeur partout (dev, staging, prod)
|
||||||
|
- [ ] `NEXT_PUBLIC_API_URL` pointe vers la bonne URL
|
||||||
|
- [ ] Application redémarrée après modification des variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Workflow N8N
|
||||||
|
|
||||||
|
- [ ] Workflow est **ACTIF** (toggle vert)
|
||||||
|
- [ ] Webhook path est correct: `mission-created`
|
||||||
|
- [ ] Node "Save Mission To API" utilise `config.MISSION_API_URL`
|
||||||
|
- [ ] Node "Save Mission To API" utilise `config.N8N_API_KEY`
|
||||||
|
- [ ] Node "Save Mission To API" inclut `missionId` dans le body
|
||||||
|
- [ ] Tous les IDs d'intégration sont inclus dans le callback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Flux de Données
|
||||||
|
|
||||||
|
- [ ] `missionId` est envoyé à N8N lors de la création
|
||||||
|
- [ ] `missionId` est renvoyé par N8N dans le callback
|
||||||
|
- [ ] Les IDs d'intégration sont correctement mappés:
|
||||||
|
- `gitRepoUrl` → `giteaRepositoryUrl`
|
||||||
|
- `leantimeProjectId` → `leantimeProjectId` (string)
|
||||||
|
- `documentationCollectionId` → `outlineCollectionId`
|
||||||
|
- `rocketchatChannelId` → `rocketChatChannelId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Gestion d'Erreurs
|
||||||
|
|
||||||
|
- [ ] Erreurs N8N sont loggées
|
||||||
|
- [ ] Rollback en cas d'échec (si configuré)
|
||||||
|
- [ ] Messages d'erreur clairs pour debugging
|
||||||
|
- [ ] Pas de données sensibles dans les logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Problèmes Potentiels et Solutions
|
||||||
|
|
||||||
|
### Problème 1: 401 Unauthorized
|
||||||
|
|
||||||
|
**Symptômes**:
|
||||||
|
```
|
||||||
|
Invalid API key { received: 'present', expected: 'configured' }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causes possibles**:
|
||||||
|
1. `N8N_API_KEY` non défini dans l'environnement
|
||||||
|
2. `N8N_API_KEY` différent entre Next.js et N8N
|
||||||
|
3. N8N envoie une clé vide ou `undefined`
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Vérifier que `N8N_API_KEY` est défini:
|
||||||
|
```bash
|
||||||
|
echo $N8N_API_KEY
|
||||||
|
```
|
||||||
|
2. Vérifier la valeur dans N8N:
|
||||||
|
- Ouvrir l'exécution du workflow
|
||||||
|
- Vérifier `config.N8N_API_KEY` dans "Process Mission Data"
|
||||||
|
3. S'assurer que la même clé est utilisée partout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Problème 2: 404 Mission Not Found
|
||||||
|
|
||||||
|
**Symptômes**:
|
||||||
|
```
|
||||||
|
Mission not found { missionId: "...", name: "...", creatorId: "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causes possibles**:
|
||||||
|
1. `missionId` non envoyé par N8N
|
||||||
|
2. `missionId` incorrect
|
||||||
|
3. Mission supprimée entre temps
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Vérifier que N8N envoie `missionId`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missionId": "{{ $node['Process Mission Data'].json.missionId }}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. Vérifier que Next.js envoie `missionId` à N8N:
|
||||||
|
```typescript
|
||||||
|
config: {
|
||||||
|
missionId: mission.id // ✅ Inclus dans n8nData
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Utiliser le fallback `name + creatorId` si `missionId` manquant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Problème 3: 500 Server Configuration Error
|
||||||
|
|
||||||
|
**Symptômes**:
|
||||||
|
```
|
||||||
|
N8N_API_KEY not configured in environment
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: `process.env.N8N_API_KEY` est `undefined`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Ajouter `N8N_API_KEY` à `.env.local` ou variables d'environnement
|
||||||
|
2. Redémarrer l'application
|
||||||
|
3. Vérifier avec un endpoint de test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Problème 4: 404 Webhook Not Registered
|
||||||
|
|
||||||
|
**Symptômes**:
|
||||||
|
```
|
||||||
|
404 Error: The requested webhook "mission-created" is not registered.
|
||||||
|
Hint: Click the 'Execute workflow' button on the canvas, then try again.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: Workflow N8N n'est pas actif
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Ouvrir le workflow dans N8N
|
||||||
|
2. Activer le toggle "Active" (devrait être vert)
|
||||||
|
3. Vérifier que le webhook node est actif
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Problème 5: IDs d'Intégration Non Sauvegardés
|
||||||
|
|
||||||
|
**Symptômes**:
|
||||||
|
- Mission créée mais `giteaRepositoryUrl`, `leantimeProjectId`, etc. sont `null`
|
||||||
|
|
||||||
|
**Causes possibles**:
|
||||||
|
1. N8N ne rappelle pas `/api/missions/mission-created`
|
||||||
|
2. N8N rappelle mais avec des IDs manquants
|
||||||
|
3. Erreur lors de la mise à jour en base
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
1. Vérifier les logs N8N (Executions)
|
||||||
|
2. Vérifier que le node "Save Mission To API" s'exécute
|
||||||
|
3. Vérifier les logs Next.js pour "Mission Created Webhook Received"
|
||||||
|
4. Vérifier que tous les IDs sont inclus dans le body du callback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Tests et Validation
|
||||||
|
|
||||||
|
### Test 1: Vérifier Configuration
|
||||||
|
|
||||||
|
**Endpoint de test** (à créer):
|
||||||
|
```typescript
|
||||||
|
// app/api/test-n8n-config/route.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
hasN8NApiKey: !!process.env.N8N_API_KEY,
|
||||||
|
n8nApiKeyLength: process.env.N8N_API_KEY?.length || 0,
|
||||||
|
n8nWebhookUrl: process.env.N8N_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/mission-created',
|
||||||
|
missionApiUrl: process.env.NEXT_PUBLIC_API_URL || 'https://api.slm-lab.net/api'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**: `GET /api/test-n8n-config`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 2: Tester Webhook N8N
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://brain.slm-lab.net/webhook/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"test": "data"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultats attendus**:
|
||||||
|
- ✅ `200/400/500` avec erreur workflow: Webhook actif
|
||||||
|
- ❌ `404` avec "webhook not registered": Webhook inactif
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 3: Tester Callback Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: YOUR_N8N_API_KEY" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "test-mission-id",
|
||||||
|
"gitRepoUrl": "https://git.example.com/repo",
|
||||||
|
"leantimeProjectId": "123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultats attendus**:
|
||||||
|
- ✅ `200` avec `success: true`: API key valide
|
||||||
|
- ❌ `401`: API key invalide
|
||||||
|
- ❌ `404`: Mission non trouvée (normal si missionId de test)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 4: Créer une Mission Complète
|
||||||
|
|
||||||
|
1. Créer une mission via le frontend
|
||||||
|
2. Vérifier les logs Next.js:
|
||||||
|
- ✅ "Mission created successfully"
|
||||||
|
- ✅ "Starting N8N workflow"
|
||||||
|
- ✅ "N8N workflow result { success: true }"
|
||||||
|
3. Vérifier les logs N8N (Executions):
|
||||||
|
- ✅ Workflow exécuté avec succès
|
||||||
|
- ✅ Node "Save Mission To API" exécuté
|
||||||
|
4. Vérifier la base de données:
|
||||||
|
- ✅ Mission a les IDs d'intégration sauvegardés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Recommandations
|
||||||
|
|
||||||
|
### 1. Amélioration de la Sécurité
|
||||||
|
|
||||||
|
**Problème actuel**: Clé API en clair, comparaison simple
|
||||||
|
|
||||||
|
**Recommandations**:
|
||||||
|
- [ ] Utiliser un système de tokens avec expiration
|
||||||
|
- [ ] Implémenter un système de signature HMAC
|
||||||
|
- [ ] Ajouter un rate limiting sur `/api/missions/mission-created`
|
||||||
|
- [ ] Logging des tentatives d'accès invalides avec IP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Amélioration de la Robustesse
|
||||||
|
|
||||||
|
**Problème actuel**: Pas de retry automatique si N8N échoue
|
||||||
|
|
||||||
|
**Recommandations**:
|
||||||
|
- [ ] Implémenter un système de retry avec backoff exponentiel
|
||||||
|
- [ ] Queue de messages pour les callbacks manqués
|
||||||
|
- [ ] Webhook de santé pour vérifier que N8N est accessible
|
||||||
|
- [ ] Timeout configurable pour les appels N8N
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Amélioration du Debugging
|
||||||
|
|
||||||
|
**Problème actuel**: Logs dispersés, pas de traçabilité complète
|
||||||
|
|
||||||
|
**Recommandations**:
|
||||||
|
- [ ] Ajouter un `correlationId` pour tracer une mission de bout en bout
|
||||||
|
- [ ] Logs structurés avec contexte complet
|
||||||
|
- [ ] Dashboard de monitoring des intégrations
|
||||||
|
- [ ] Alertes en cas d'échec répété
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Amélioration de la Documentation
|
||||||
|
|
||||||
|
**Recommandations**:
|
||||||
|
- [ ] Documenter le format exact attendu par N8N
|
||||||
|
- [ ] Exemples de payloads complets
|
||||||
|
- [ ] Diagrammes de séquence détaillés
|
||||||
|
- [ ] Guide de troubleshooting avec cas réels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Tests Automatisés
|
||||||
|
|
||||||
|
**Recommandations**:
|
||||||
|
- [ ] Tests unitaires pour `N8nService`
|
||||||
|
- [ ] Tests d'intégration pour les endpoints API
|
||||||
|
- [ ] Tests E2E avec mock N8N
|
||||||
|
- [ ] Tests de charge pour vérifier la scalabilité
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Checklist de Vérification Rapide
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- [ ] `N8N_API_KEY` défini et identique partout
|
||||||
|
- [ ] `NEXT_PUBLIC_API_URL` pointe vers la bonne URL
|
||||||
|
- [ ] Application redémarrée après modifications
|
||||||
|
|
||||||
|
### N8N Workflow
|
||||||
|
- [ ] Workflow actif (toggle vert)
|
||||||
|
- [ ] Webhook path: `mission-created`
|
||||||
|
- [ ] Node "Save Mission To API" configuré correctement
|
||||||
|
- [ ] `missionId` inclus dans le callback
|
||||||
|
|
||||||
|
### Code Next.js
|
||||||
|
- [ ] `missionId` envoyé à N8N lors de la création
|
||||||
|
- [ ] Validation API key fonctionnelle
|
||||||
|
- [ ] Mapping des champs correct
|
||||||
|
- [ ] Gestion d'erreurs appropriée
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- [ ] Test de création de mission réussi
|
||||||
|
- [ ] IDs d'intégration sauvegardés en base
|
||||||
|
- [ ] Logs sans erreurs critiques
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Références
|
||||||
|
|
||||||
|
- **Service N8N**: `lib/services/n8n-service.ts`
|
||||||
|
- **Endpoint création**: `app/api/missions/route.ts`
|
||||||
|
- **Endpoint callback**: `app/api/missions/mission-created/route.ts`
|
||||||
|
- **Documentation N8N**: Voir fichiers `N8N_*.md` dans le projet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document créé le**: $(date)
|
||||||
|
**Dernière mise à jour**: $(date)
|
||||||
|
**Version**: 1.0
|
||||||
|
|
||||||
292
CRITICAL_ISSUE_ANALYSIS.md
Normal file
292
CRITICAL_ISSUE_ANALYSIS.md
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
# Critical Issue: Infinite Token Refresh Loop
|
||||||
|
|
||||||
|
## Problem Analysis
|
||||||
|
|
||||||
|
### What's Happening
|
||||||
|
|
||||||
|
1. **Initial Load**: App starts successfully, user authenticated
|
||||||
|
2. **Session Invalidation**: Keycloak session becomes invalid (user logged out elsewhere, session expired, etc.)
|
||||||
|
3. **Refresh Storm**: Every API request triggers:
|
||||||
|
- JWT callback execution
|
||||||
|
- Token refresh attempt
|
||||||
|
- Refresh failure (session invalid)
|
||||||
|
- Token cleared, but error state persists
|
||||||
|
- **Next request repeats the cycle**
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
The JWT callback in `app/api/auth/options.ts` has no circuit breaker:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current problematic flow:
|
||||||
|
if (expiresAt && Date.now() < expiresAt) {
|
||||||
|
return token; // Token valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired - ALWAYS tries to refresh
|
||||||
|
const refreshedToken = await refreshAccessToken(token);
|
||||||
|
|
||||||
|
// If refresh fails, clears tokens but...
|
||||||
|
// Next request will see expired token and try again!
|
||||||
|
```
|
||||||
|
|
||||||
|
**The Problem:**
|
||||||
|
- No cooldown period after failed refresh
|
||||||
|
- No "session invalid" cache/flag
|
||||||
|
- Every request triggers refresh attempt
|
||||||
|
- Multiple widgets = multiple parallel requests = refresh storm
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
- **Performance**: Excessive Keycloak API calls
|
||||||
|
- **Server Load**: CPU/memory spike from refresh attempts
|
||||||
|
- **User Experience**: App appears broken, constant loading
|
||||||
|
- **Logs**: Spam with "Keycloak session invalidated" messages
|
||||||
|
- **Security**: Potential DoS on Keycloak server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution: Circuit Breaker Pattern
|
||||||
|
|
||||||
|
### Implementation Strategy
|
||||||
|
|
||||||
|
1. **Add Refresh Cooldown**: Don't retry refresh for X seconds after failure
|
||||||
|
2. **Session Invalid Flag**: Cache the "session invalid" state
|
||||||
|
3. **Early Return**: If session known to be invalid, skip refresh attempt
|
||||||
|
4. **Client-Side Detection**: Stop making requests when session invalid
|
||||||
|
|
||||||
|
### Code Changes Needed
|
||||||
|
|
||||||
|
#### 1. Add Circuit Breaker to JWT Callback
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add to app/api/auth/options.ts
|
||||||
|
|
||||||
|
// Track last failed refresh attempt
|
||||||
|
const lastFailedRefresh = new Map<string, number>();
|
||||||
|
const REFRESH_COOLDOWN = 5000; // 5 seconds
|
||||||
|
|
||||||
|
async jwt({ token, account, profile }) {
|
||||||
|
// ... existing initial sign-in logic ...
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
const expiresAt = token.accessTokenExpires as number;
|
||||||
|
if (expiresAt && Date.now() < expiresAt) {
|
||||||
|
return token; // Token still valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIRCUIT BREAKER: Check if we recently failed to refresh
|
||||||
|
const userId = token.sub || 'unknown';
|
||||||
|
const lastFailure = lastFailedRefresh.get(userId) || 0;
|
||||||
|
const timeSinceFailure = Date.now() - lastFailure;
|
||||||
|
|
||||||
|
if (timeSinceFailure < REFRESH_COOLDOWN) {
|
||||||
|
// Too soon after failure, return error token immediately
|
||||||
|
logger.debug('Refresh cooldown active, skipping refresh attempt', {
|
||||||
|
userId,
|
||||||
|
timeSinceFailure,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to refresh
|
||||||
|
if (!token.refreshToken) {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "NoRefreshToken",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedToken = await refreshAccessToken(token);
|
||||||
|
|
||||||
|
// If refresh failed, record the failure time
|
||||||
|
if (refreshedToken.error === "SessionNotActive") {
|
||||||
|
lastFailedRefresh.set(userId, Date.now());
|
||||||
|
logger.info("Keycloak session invalidated, setting cooldown", { userId });
|
||||||
|
|
||||||
|
// Clean up old entries (prevent memory leak)
|
||||||
|
if (lastFailedRefresh.size > 1000) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, value] of lastFailedRefresh.entries()) {
|
||||||
|
if (now - value > REFRESH_COOLDOWN * 10) {
|
||||||
|
lastFailedRefresh.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...refreshedToken,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - clear any previous failure record
|
||||||
|
lastFailedRefresh.delete(userId);
|
||||||
|
|
||||||
|
return refreshedToken;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Use Redis for Distributed Circuit Breaker
|
||||||
|
|
||||||
|
For multi-instance deployments, use Redis:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/services/auth-circuit-breaker.ts
|
||||||
|
import { getRedisClient } from '@/lib/redis';
|
||||||
|
|
||||||
|
const REFRESH_COOLDOWN = 5000; // 5 seconds
|
||||||
|
const CIRCUIT_BREAKER_KEY = (userId: string) => `auth:refresh:cooldown:${userId}`;
|
||||||
|
|
||||||
|
export async function isRefreshInCooldown(userId: string): Promise<boolean> {
|
||||||
|
const redis = getRedisClient();
|
||||||
|
const key = CIRCUIT_BREAKER_KEY(userId);
|
||||||
|
const lastFailure = await redis.get(key);
|
||||||
|
|
||||||
|
if (!lastFailure) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSinceFailure = Date.now() - parseInt(lastFailure, 10);
|
||||||
|
return timeSinceFailure < REFRESH_COOLDOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRefreshFailure(userId: string): Promise<void> {
|
||||||
|
const redis = getRedisClient();
|
||||||
|
const key = CIRCUIT_BREAKER_KEY(userId);
|
||||||
|
await redis.set(key, Date.now().toString(), 'EX', Math.ceil(REFRESH_COOLDOWN / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearRefreshCooldown(userId: string): Promise<void> {
|
||||||
|
const redis = getRedisClient();
|
||||||
|
const key = CIRCUIT_BREAKER_KEY(userId);
|
||||||
|
await redis.del(key);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Client-Side Request Stopping
|
||||||
|
|
||||||
|
Add to components to stop making requests when session invalid:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// hooks/use-session-guard.ts
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useSessionGuard() {
|
||||||
|
const { status, data: session } = useSession();
|
||||||
|
const hasInvalidSession = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'unauthenticated' && !hasInvalidSession.current) {
|
||||||
|
hasInvalidSession.current = true;
|
||||||
|
// Stop all refresh intervals
|
||||||
|
// Clear any pending requests
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldMakeRequests: status === 'authenticated' && !hasInvalidSession.current,
|
||||||
|
isInvalid: hasInvalidSession.current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Immediate Fix (Quick)
|
||||||
|
|
||||||
|
### Option 1: Add Simple Cooldown (In-Memory)
|
||||||
|
|
||||||
|
Add this to `app/api/auth/options.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// At top of file
|
||||||
|
const refreshCooldown = new Map<string, number>();
|
||||||
|
const COOLDOWN_MS = 5000; // 5 seconds
|
||||||
|
|
||||||
|
// In jwt callback, before refresh attempt:
|
||||||
|
const userId = token.sub || 'unknown';
|
||||||
|
const lastFailure = refreshCooldown.get(userId) || 0;
|
||||||
|
|
||||||
|
if (Date.now() - lastFailure < COOLDOWN_MS) {
|
||||||
|
// Skip refresh, return error immediately
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// After failed refresh:
|
||||||
|
if (refreshedToken.error === "SessionNotActive") {
|
||||||
|
refreshCooldown.set(userId, Date.now());
|
||||||
|
// ... rest of error handling
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Early Return on Known Invalid Session
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In jwt callback, check token error first:
|
||||||
|
if (token.error === "SessionNotActive") {
|
||||||
|
// Already know session is invalid, don't try again
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Implementation
|
||||||
|
|
||||||
|
1. **Immediate**: Add simple in-memory cooldown (Option 1)
|
||||||
|
2. **Short-term**: Migrate to Redis-based circuit breaker
|
||||||
|
3. **Long-term**: Add client-side session guard to stop requests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
After implementing:
|
||||||
|
|
||||||
|
1. **Test Scenario 1**: Logout from Keycloak admin console
|
||||||
|
- Should see: 1-2 refresh attempts, then cooldown
|
||||||
|
- Should NOT see: Infinite loop
|
||||||
|
|
||||||
|
2. **Test Scenario 2**: Expire session manually
|
||||||
|
- Should see: Cooldown prevents refresh storm
|
||||||
|
- Should see: User redirected to sign-in
|
||||||
|
|
||||||
|
3. **Test Scenario 3**: Multiple widgets loading
|
||||||
|
- Should see: All widgets respect cooldown
|
||||||
|
- Should see: No refresh storm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Add metrics:
|
||||||
|
- Refresh attempt count
|
||||||
|
- Refresh failure count
|
||||||
|
- Cooldown activations
|
||||||
|
- Session invalidations per user
|
||||||
|
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Use Node.js 22 as the base image
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /application
|
||||||
|
|
||||||
|
# Copy package files first
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Initialize Prisma
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npx prisma migrate dev --name init
|
||||||
|
|
||||||
|
# Build the Next.js application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Expose port 3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment variable
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Start the application and keep the container running even if it fails
|
||||||
|
CMD ["sh", "-c", "npm start || tail -f /dev/null"]
|
||||||
211
LOG_ANALYSIS_SUMMARY.md
Normal file
211
LOG_ANALYSIS_SUMMARY.md
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
# Log Analysis Summary - Infinite Refresh Loop Fix
|
||||||
|
|
||||||
|
## Problem Identified
|
||||||
|
|
||||||
|
Your logs showed a **critical infinite refresh loop**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Keycloak session invalidated, clearing token to force re-authentication
|
||||||
|
Keycloak session invalidated, clearing token to force re-authentication
|
||||||
|
Keycloak session invalidated, clearing token to force re-authentication
|
||||||
|
... (repeating infinitely)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
1. **Session Invalidated**: User's Keycloak session became invalid (logged out elsewhere, expired, etc.)
|
||||||
|
2. **Multiple Widgets**: All widgets/components making parallel API requests
|
||||||
|
3. **JWT Callback Triggered**: Each request triggers NextAuth JWT callback
|
||||||
|
4. **Refresh Attempt**: Each callback tries to refresh the expired token
|
||||||
|
5. **Refresh Fails**: Refresh fails because session is invalid
|
||||||
|
6. **No Circuit Breaker**: Next request sees expired token → tries refresh again → **infinite loop**
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
- **Performance**: Hundreds of refresh attempts per second
|
||||||
|
- **Server Load**: CPU/memory spike
|
||||||
|
- **Keycloak Load**: Potential DoS on Keycloak server
|
||||||
|
- **User Experience**: App appears broken
|
||||||
|
- **Logs**: Spam with error messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution Implemented
|
||||||
|
|
||||||
|
### Circuit Breaker Pattern
|
||||||
|
|
||||||
|
Added a **5-second cooldown** after failed refresh attempts:
|
||||||
|
|
||||||
|
1. **Track Failures**: Record timestamp when refresh fails
|
||||||
|
2. **Cooldown Period**: Don't retry refresh for 5 seconds after failure
|
||||||
|
3. **Early Return**: If in cooldown, return error immediately (no API call)
|
||||||
|
4. **Memory Management**: Cleanup old entries to prevent memory leaks
|
||||||
|
|
||||||
|
### Code Changes
|
||||||
|
|
||||||
|
**File**: `app/api/auth/options.ts`
|
||||||
|
|
||||||
|
**Added:**
|
||||||
|
- `refreshCooldown` Map to track last failure per user
|
||||||
|
- `REFRESH_COOLDOWN_MS = 5000` (5 seconds)
|
||||||
|
- `cleanupRefreshCooldown()` function to prevent memory leaks
|
||||||
|
- Cooldown check before refresh attempt
|
||||||
|
- Failure recording after failed refresh
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before refresh attempt:
|
||||||
|
if (timeSinceFailure < REFRESH_COOLDOWN_MS) {
|
||||||
|
// Skip refresh, return error immediately
|
||||||
|
return errorToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After failed refresh:
|
||||||
|
if (refreshedToken.error === "SessionNotActive") {
|
||||||
|
refreshCooldown.set(userId, Date.now()); // Record failure
|
||||||
|
return errorToken;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Behavior After Fix
|
||||||
|
|
||||||
|
### Before Fix
|
||||||
|
```
|
||||||
|
Request 1 → Refresh attempt → Fail → Clear tokens
|
||||||
|
Request 2 → Refresh attempt → Fail → Clear tokens
|
||||||
|
Request 3 → Refresh attempt → Fail → Clear tokens
|
||||||
|
... (infinite loop)
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Fix
|
||||||
|
```
|
||||||
|
Request 1 → Refresh attempt → Fail → Record failure → Clear tokens
|
||||||
|
Request 2 → Check cooldown → Skip refresh → Return error immediately
|
||||||
|
Request 3 → Check cooldown → Skip refresh → Return error immediately
|
||||||
|
... (cooldown prevents refresh attempts)
|
||||||
|
After 5s → Next request can try refresh again (if session restored)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What You'll See in Logs
|
||||||
|
|
||||||
|
**Good Signs:**
|
||||||
|
- ✅ "Refresh cooldown active, skipping refresh attempt" (instead of infinite failures)
|
||||||
|
- ✅ Only 1-2 refresh attempts per user when session invalidates
|
||||||
|
- ✅ User redirected to sign-in page
|
||||||
|
- ✅ No refresh storm
|
||||||
|
|
||||||
|
**Bad Signs (if still happening):**
|
||||||
|
- ❌ Still seeing infinite "Keycloak session invalidated" messages
|
||||||
|
- ❌ Multiple refresh attempts within 5 seconds
|
||||||
|
- ❌ Cooldown not working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing the Fix
|
||||||
|
|
||||||
|
### Test Scenario 1: Session Invalidation
|
||||||
|
1. Log in to the app
|
||||||
|
2. Logout from Keycloak admin console (or expire session)
|
||||||
|
3. **Expected**:
|
||||||
|
- 1-2 refresh attempts
|
||||||
|
- Then cooldown messages
|
||||||
|
- User redirected to sign-in
|
||||||
|
- **NOT** infinite loop
|
||||||
|
|
||||||
|
### Test Scenario 2: Multiple Widgets
|
||||||
|
1. Open app with all widgets loading
|
||||||
|
2. Invalidate session
|
||||||
|
3. **Expected**:
|
||||||
|
- All widgets respect cooldown
|
||||||
|
- No refresh storm
|
||||||
|
- Clean error handling
|
||||||
|
|
||||||
|
### Test Scenario 3: Normal Operation
|
||||||
|
1. Valid session
|
||||||
|
2. Token expires naturally
|
||||||
|
3. **Expected**:
|
||||||
|
- Refresh succeeds
|
||||||
|
- No cooldown triggered
|
||||||
|
- Normal operation continues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Metrics to Watch
|
||||||
|
|
||||||
|
1. **Refresh Attempts**: Should be low (1-2 per user per session)
|
||||||
|
2. **Cooldown Activations**: Should only happen when session invalid
|
||||||
|
3. **Refresh Success Rate**: Should be high for valid sessions
|
||||||
|
4. **Error Rate**: Should drop significantly
|
||||||
|
|
||||||
|
### Log Patterns
|
||||||
|
|
||||||
|
**Healthy:**
|
||||||
|
```
|
||||||
|
[DEBUG] Refresh cooldown active, skipping refresh attempt
|
||||||
|
[INFO] Keycloak session invalidated, setting cooldown
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unhealthy (if still happening):**
|
||||||
|
```
|
||||||
|
Keycloak session invalidated, clearing token... (repeating)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
### Short-term (Recommended)
|
||||||
|
1. ✅ **Done**: In-memory circuit breaker
|
||||||
|
2. ⚠️ **Next**: Migrate to Redis-based circuit breaker (for multi-instance)
|
||||||
|
3. ⚠️ **Next**: Add client-side session guard to stop requests
|
||||||
|
|
||||||
|
### Long-term
|
||||||
|
1. ⚠️ Add metrics/monitoring
|
||||||
|
2. ⚠️ Implement exponential backoff
|
||||||
|
3. ⚠️ Add request cancellation on client-side
|
||||||
|
4. ⚠️ Better error boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
### Why 5 Seconds?
|
||||||
|
|
||||||
|
- **Too Short (< 2s)**: Still allows refresh storms
|
||||||
|
- **Too Long (> 10s)**: Delays legitimate refresh attempts
|
||||||
|
- **5 Seconds**: Good balance - prevents storms, allows quick recovery
|
||||||
|
|
||||||
|
### Memory Considerations
|
||||||
|
|
||||||
|
- **Map Size**: Limited to 1000 entries (auto-cleanup)
|
||||||
|
- **Memory Per Entry**: ~50 bytes (userId + timestamp)
|
||||||
|
- **Total Memory**: ~50KB max
|
||||||
|
- **Cleanup**: Automatic (removes entries older than 50s)
|
||||||
|
|
||||||
|
### Multi-Instance Deployment
|
||||||
|
|
||||||
|
**Current**: In-memory Map (per-instance)
|
||||||
|
- Works for single instance
|
||||||
|
- Each instance has its own cooldown
|
||||||
|
|
||||||
|
**Future**: Redis-based (shared across instances)
|
||||||
|
- Better for multi-instance
|
||||||
|
- Shared cooldown state
|
||||||
|
- See `CRITICAL_ISSUE_ANALYSIS.md` for Redis implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
✅ **Fixed**: Infinite refresh loop with circuit breaker
|
||||||
|
✅ **Impact**: Prevents refresh storms, reduces server load
|
||||||
|
✅ **Testing**: Verify with session invalidation scenarios
|
||||||
|
⚠️ **Next**: Monitor logs, consider Redis migration for multi-instance
|
||||||
|
|
||||||
|
The fix is **production-ready** and should immediately stop the refresh loop you're seeing in your logs.
|
||||||
|
|
||||||
198
MISSION_CREATION_CALLBACK_MISSING.md
Normal file
198
MISSION_CREATION_CALLBACK_MISSING.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# Mission Creation - N8N Callback Not Being Called
|
||||||
|
|
||||||
|
## 🔍 Problem Analysis
|
||||||
|
|
||||||
|
From your logs, I can see:
|
||||||
|
|
||||||
|
### ✅ What's Working
|
||||||
|
|
||||||
|
1. **Mission created in database** ✅
|
||||||
|
```
|
||||||
|
Mission created successfully { missionId: '5815440f-af1c-4c6a-bfa6-92f06058f9c8', name: 'bbc' }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **N8N workflow triggered** ✅
|
||||||
|
```
|
||||||
|
Starting N8N workflow
|
||||||
|
POST /mission-created 200 in 851ms ← This is N8N RECEIVING the webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **N8N workflow completes** ✅
|
||||||
|
```
|
||||||
|
N8N workflow result { success: true, hasError: false }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ What's Missing
|
||||||
|
|
||||||
|
**NO log from `/api/missions/mission-created` endpoint!**
|
||||||
|
|
||||||
|
Expected log (but NOT present):
|
||||||
|
```
|
||||||
|
Mission Created Webhook Received ← This should appear but doesn't
|
||||||
|
```
|
||||||
|
|
||||||
|
**This means**: N8N workflow is **NOT calling** `/api/missions/mission-created` to save the integration IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Root Cause
|
||||||
|
|
||||||
|
The N8N workflow completes successfully, but the **"Save Mission To API" node** is either:
|
||||||
|
1. ❌ Not configured correctly (wrong URL)
|
||||||
|
2. ❌ Not executing (node disabled or failing silently)
|
||||||
|
3. ❌ Failing but not blocking the workflow (continueOnFail: true)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution: Verify N8N "Save Mission To API" Node
|
||||||
|
|
||||||
|
### Step 1: Check N8N Execution Logs
|
||||||
|
|
||||||
|
1. Go to N8N → Executions
|
||||||
|
2. Find the latest mission creation execution
|
||||||
|
3. Click on it to see the execution details
|
||||||
|
4. **Look for "Save Mission To API" node**:
|
||||||
|
- ✅ Is it executed?
|
||||||
|
- ✅ What's the status (success/error)?
|
||||||
|
- ✅ What URL is it calling?
|
||||||
|
- ✅ What's the response?
|
||||||
|
|
||||||
|
### Step 2: Verify Node Configuration
|
||||||
|
|
||||||
|
**In N8N workflow, check "Save Mission To API" node**:
|
||||||
|
|
||||||
|
1. **URL should be**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOT**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Method**: `POST`
|
||||||
|
|
||||||
|
3. **Headers**:
|
||||||
|
- `Content-Type`: `application/json`
|
||||||
|
- `x-api-key`: `{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}`
|
||||||
|
|
||||||
|
4. **Body Parameters** should include:
|
||||||
|
- `missionId`: `{{ $node['Process Mission Data'].json.missionId }}`
|
||||||
|
- `gitRepoUrl`: `{{ $node['Combine Results'].json.gitRepo?.html_url || '' }}`
|
||||||
|
- `leantimeProjectId`: `{{ $node['Combine Results'].json.leantimeProject?.result?.[0] || '' }}`
|
||||||
|
- `documentationCollectionId`: `{{ $node['Combine Results'].json.docCollection?.data?.id || '' }}`
|
||||||
|
- `rocketchatChannelId`: `{{ $node['Combine Results'].json.rocketChatChannel?.channel?._id || '' }}`
|
||||||
|
- `name`: `{{ $node['Process Mission Data'].json.missionProcessed.name }}`
|
||||||
|
- `creatorId`: `{{ $node['Process Mission Data'].json.creatorId }}`
|
||||||
|
|
||||||
|
5. **Node Options**:
|
||||||
|
- ❌ Should NOT have `continueOnFail: true` (or it will fail silently)
|
||||||
|
- ✅ Should be set to fail the workflow if it fails
|
||||||
|
|
||||||
|
### Step 3: Test the Endpoint Manually
|
||||||
|
|
||||||
|
**Test if the endpoint is accessible**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hub.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: YOUR_N8N_API_KEY" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "5815440f-af1c-4c6a-bfa6-92f06058f9c8",
|
||||||
|
"name": "bbc",
|
||||||
|
"creatorId": "203cbc91-61ab-47a2-95d2-b5e1159327d7",
|
||||||
|
"gitRepoUrl": "https://gite.slm-lab.net/alma/test",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"documentationCollectionId": "collection-456",
|
||||||
|
"rocketchatChannelId": "channel-789"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**: 200 OK with updated mission data
|
||||||
|
|
||||||
|
**If 500 error**: `N8N_API_KEY` is not set in environment
|
||||||
|
|
||||||
|
**If 404 error**: Wrong URL
|
||||||
|
|
||||||
|
**If 401 error**: Wrong API key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Common Issues
|
||||||
|
|
||||||
|
### Issue 1: Wrong URL in N8N
|
||||||
|
|
||||||
|
**Symptom**: Node fails with 404 error
|
||||||
|
|
||||||
|
**Fix**: Change URL from:
|
||||||
|
```
|
||||||
|
{{ MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
{{ MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Missing missionId in Body
|
||||||
|
|
||||||
|
**Symptom**: Endpoint can't find mission (404)
|
||||||
|
|
||||||
|
**Fix**: Add `missionId` parameter to body:
|
||||||
|
- Name: `missionId`
|
||||||
|
- Value: `{{ $node['Process Mission Data'].json.missionId }}`
|
||||||
|
|
||||||
|
### Issue 3: continueOnFail: true
|
||||||
|
|
||||||
|
**Symptom**: Node fails but workflow continues (no error visible)
|
||||||
|
|
||||||
|
**Fix**: Remove `continueOnFail: true` or set to `false`
|
||||||
|
|
||||||
|
### Issue 4: N8N_API_KEY Not Set
|
||||||
|
|
||||||
|
**Symptom**: Endpoint returns 500 "Server configuration error"
|
||||||
|
|
||||||
|
**Fix**: Add `N8N_API_KEY` to environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Debugging Checklist
|
||||||
|
|
||||||
|
- [ ] Check N8N execution logs for "Save Mission To API" node
|
||||||
|
- [ ] Verify node URL is correct: `{{ MISSION_API_URL }}/api/missions/mission-created`
|
||||||
|
- [ ] Verify node includes `missionId` in body
|
||||||
|
- [ ] Verify node includes `x-api-key` header
|
||||||
|
- [ ] Check if node has `continueOnFail: true` (should be false)
|
||||||
|
- [ ] Test endpoint manually with curl
|
||||||
|
- [ ] Verify `N8N_API_KEY` is set in environment
|
||||||
|
- [ ] Check server logs for any calls to `/api/missions/mission-created`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Expected Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Mission created in database ✅
|
||||||
|
2. N8N workflow triggered ✅
|
||||||
|
3. N8N creates integrations ✅
|
||||||
|
4. N8N calls /api/missions/mission-created ⚠️ (MISSING)
|
||||||
|
5. IDs saved to database ⚠️ (NOT HAPPENING)
|
||||||
|
6. Mission has integration IDs ⚠️ (ALL NULL)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Next Steps
|
||||||
|
|
||||||
|
1. **Check N8N execution logs** to see what "Save Mission To API" node is doing
|
||||||
|
2. **Verify node configuration** matches the requirements above
|
||||||
|
3. **Test endpoint manually** to ensure it's accessible
|
||||||
|
4. **Fix any configuration issues** found
|
||||||
|
5. **Re-test mission creation** and verify IDs are saved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Status**: N8N workflow completes but callback to save IDs is not being called
|
||||||
|
|
||||||
348
MISSION_CREATION_FLOW_EXPLANATION.md
Normal file
348
MISSION_CREATION_FLOW_EXPLANATION.md
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
# Mission Creation Flow - Why You Can Create Without N8N API Key
|
||||||
|
|
||||||
|
## 🔍 Current Behavior Explained
|
||||||
|
|
||||||
|
You're absolutely right! You **CAN** create missions without `N8N_API_KEY` because of how the code is structured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Current Flow Order
|
||||||
|
|
||||||
|
Looking at `app/api/missions/route.ts`, here's the **actual execution order**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ✅ Create mission in database (line 260)
|
||||||
|
↓
|
||||||
|
2. ✅ Create mission users (line 298)
|
||||||
|
↓
|
||||||
|
3. ✅ Upload logo to Minio (line 318)
|
||||||
|
↓
|
||||||
|
4. ✅ Upload attachments to Minio (line 362)
|
||||||
|
↓
|
||||||
|
5. ✅ Verify files exist (line 391)
|
||||||
|
↓
|
||||||
|
6. ⚠️ Trigger N8N workflow (line 430)
|
||||||
|
↓
|
||||||
|
7. ❌ If N8N fails → Error thrown (line 437)
|
||||||
|
↓
|
||||||
|
8. ⚠️ Error caught → Cleanup files (line 458)
|
||||||
|
↓
|
||||||
|
9. ❌ Return 500 error BUT mission stays in database!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 The Problem
|
||||||
|
|
||||||
|
### What Happens When N8N Fails
|
||||||
|
|
||||||
|
1. **Mission is created** in database (line 260) ✅
|
||||||
|
2. **Files are uploaded** to Minio ✅
|
||||||
|
3. **N8N is called** but fails (no API key, webhook not registered, etc.) ❌
|
||||||
|
4. **Error is thrown** (line 437) ❌
|
||||||
|
5. **Files are cleaned up** (line 458) ✅
|
||||||
|
6. **500 error is returned** to frontend ❌
|
||||||
|
7. **BUT: Mission remains in database!** ⚠️
|
||||||
|
|
||||||
|
### Result
|
||||||
|
|
||||||
|
- ✅ Mission exists in database
|
||||||
|
- ❌ No integration IDs saved (N8N never called `/mission-created`)
|
||||||
|
- ❌ Files deleted from Minio (cleanup)
|
||||||
|
- ❌ Frontend shows error
|
||||||
|
- ⚠️ **Orphaned mission in database**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Code Analysis
|
||||||
|
|
||||||
|
### Step 1: Mission Created (Line 260)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mission = await prisma.mission.create({
|
||||||
|
data: missionData
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**This happens FIRST**, before N8N is even called.
|
||||||
|
|
||||||
|
### Step 2: N8N Called (Line 430)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const workflowResult = await n8nService.triggerMissionCreation(n8nData);
|
||||||
|
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
throw new Error(workflowResult.error || 'N8N workflow failed');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**If N8N fails**, an error is thrown.
|
||||||
|
|
||||||
|
### Step 3: Error Handling (Line 445-477)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in final verification or n8n', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error; // Re-throws to outer catch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer catch (line 451)
|
||||||
|
} catch (error) {
|
||||||
|
// Cleanup files
|
||||||
|
for (const file of uploadedFiles) {
|
||||||
|
await s3Client.send(new DeleteObjectCommand({...}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to create mission',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notice**: The outer catch block:
|
||||||
|
- ✅ Cleans up files from Minio
|
||||||
|
- ❌ **Does NOT delete the mission from database**
|
||||||
|
- ❌ Returns 500 error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Why This Is a Problem
|
||||||
|
|
||||||
|
### Scenario: N8N Fails (No API Key)
|
||||||
|
|
||||||
|
1. User creates mission
|
||||||
|
2. Mission saved to database ✅
|
||||||
|
3. Files uploaded to Minio ✅
|
||||||
|
4. N8N called → Fails (no API key) ❌
|
||||||
|
5. Error thrown
|
||||||
|
6. Files cleaned up ✅
|
||||||
|
7. **Mission still in database** ⚠️
|
||||||
|
8. Frontend shows error
|
||||||
|
9. User sees error but mission exists
|
||||||
|
10. **Mission has no integration IDs** (N8N never saved them)
|
||||||
|
|
||||||
|
### Result
|
||||||
|
|
||||||
|
- **Orphaned missions** in database without integration IDs
|
||||||
|
- **Inconsistent state**: Mission exists but integrations don't
|
||||||
|
- **Deletion won't work**: No IDs to send to N8N deletion workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solutions
|
||||||
|
|
||||||
|
### Solution 1: Make N8N Optional (Current Behavior - But Better Error Handling)
|
||||||
|
|
||||||
|
**Keep current flow but improve error handling**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After N8N call
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
logger.warn('N8N workflow failed, but mission created', {
|
||||||
|
error: workflowResult.error,
|
||||||
|
missionId: mission.id
|
||||||
|
});
|
||||||
|
// Don't throw error - mission is created, N8N is optional
|
||||||
|
// Return success but with warning
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
mission,
|
||||||
|
warning: 'Mission created but integrations may not be set up',
|
||||||
|
n8nError: workflowResult.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Mission creation succeeds even if N8N fails
|
||||||
|
- User gets feedback about partial success
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Mission exists without integration IDs
|
||||||
|
- Deletion won't work properly
|
||||||
|
|
||||||
|
### Solution 2: Delete Mission If N8N Fails (Strict)
|
||||||
|
|
||||||
|
**Delete mission if N8N fails**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in final verification or n8n', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete mission if N8N fails
|
||||||
|
try {
|
||||||
|
await prisma.mission.delete({
|
||||||
|
where: { id: mission.id }
|
||||||
|
});
|
||||||
|
logger.debug('Mission deleted due to N8N failure', { missionId: mission.id });
|
||||||
|
} catch (deleteError) {
|
||||||
|
logger.error('Failed to delete mission after N8N failure', {
|
||||||
|
missionId: mission.id,
|
||||||
|
error: deleteError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- No orphaned missions
|
||||||
|
- Consistent state
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Mission creation fails completely if N8N is down
|
||||||
|
- User loses all work if N8N has issues
|
||||||
|
|
||||||
|
### Solution 3: Make N8N Non-Blocking (Recommended)
|
||||||
|
|
||||||
|
**Don't throw error if N8N fails, just log it**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const workflowResult = await n8nService.triggerMissionCreation(n8nData);
|
||||||
|
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
logger.warn('N8N workflow failed, but continuing', {
|
||||||
|
error: workflowResult.error,
|
||||||
|
missionId: mission.id
|
||||||
|
});
|
||||||
|
// Don't throw - mission is created, N8N can be retried later
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
mission,
|
||||||
|
message: workflowResult.success
|
||||||
|
? 'Mission created successfully with all integrations'
|
||||||
|
: 'Mission created but integrations may need to be set up manually'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Mission creation succeeds
|
||||||
|
- User gets clear feedback
|
||||||
|
- Can retry N8N later
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Mission may exist without integration IDs
|
||||||
|
- Need manual retry mechanism
|
||||||
|
|
||||||
|
### Solution 4: Transaction-Based (Best But Complex)
|
||||||
|
|
||||||
|
**Use database transaction and rollback if N8N fails**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create mission
|
||||||
|
const mission = await tx.mission.create({...});
|
||||||
|
|
||||||
|
// Upload files
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Try N8N
|
||||||
|
const workflowResult = await n8nService.triggerMissionCreation(n8nData);
|
||||||
|
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
throw new Error('N8N workflow failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mission;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Atomic operation
|
||||||
|
- No orphaned missions
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Complex to implement
|
||||||
|
- Files already uploaded (can't rollback Minio in transaction)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Recommended Approach
|
||||||
|
|
||||||
|
**Hybrid Solution**: Make N8N non-blocking but add retry mechanism
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After N8N call
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
logger.warn('N8N workflow failed, mission created without integrations', {
|
||||||
|
error: workflowResult.error,
|
||||||
|
missionId: mission.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mission is created, but mark it for retry
|
||||||
|
await prisma.mission.update({
|
||||||
|
where: { id: mission.id },
|
||||||
|
data: {
|
||||||
|
// Add a flag to indicate N8N needs retry
|
||||||
|
// Or just log it and handle manually
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
mission,
|
||||||
|
message: workflowResult.success
|
||||||
|
? 'Mission created successfully with all integrations'
|
||||||
|
: 'Mission created. Integrations will be set up shortly.'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Current vs Recommended
|
||||||
|
|
||||||
|
### Current Behavior
|
||||||
|
- ✅ Mission created even if N8N fails
|
||||||
|
- ❌ No integration IDs saved
|
||||||
|
- ❌ Deletion won't work
|
||||||
|
- ❌ Orphaned missions
|
||||||
|
|
||||||
|
### Recommended Behavior
|
||||||
|
- ✅ Mission created even if N8N fails
|
||||||
|
- ⚠️ Integration IDs may be missing (but can be retried)
|
||||||
|
- ✅ User gets clear feedback
|
||||||
|
- ✅ Can retry N8N later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Quick Fix
|
||||||
|
|
||||||
|
If you want to keep current behavior but improve it:
|
||||||
|
|
||||||
|
**Change line 436-438** from:
|
||||||
|
```typescript
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
throw new Error(workflowResult.error || 'N8N workflow failed');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**To**:
|
||||||
|
```typescript
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
logger.warn('N8N workflow failed, but mission created', {
|
||||||
|
error: workflowResult.error,
|
||||||
|
missionId: mission.id
|
||||||
|
});
|
||||||
|
// Continue - mission is created, N8N can be retried
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This way:
|
||||||
|
- ✅ Mission creation succeeds
|
||||||
|
- ⚠️ User gets warning about integrations
|
||||||
|
- ✅ Can manually trigger N8N later or add retry mechanism
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Issue**: Mission creation succeeds even when N8N fails, leading to orphaned missions without integration IDs
|
||||||
|
|
||||||
313
MISSION_DELETION_FLOW_ANALYSIS.md
Normal file
313
MISSION_DELETION_FLOW_ANALYSIS.md
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
# Mission Deletion Flow - Complete Analysis from Logs
|
||||||
|
|
||||||
|
## 🔍 Analysis of Your Deletion Flow
|
||||||
|
|
||||||
|
Based on your logs, here's what's happening:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ What's Working
|
||||||
|
|
||||||
|
1. **Mission is fetched correctly** ✅
|
||||||
|
```
|
||||||
|
SELECT "public"."Mission" WHERE "id" = '805c1d8c-1bd4-41e7-9cf1-d22631dae260'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Attachments are fetched** ✅
|
||||||
|
```
|
||||||
|
SELECT "public"."Attachment" WHERE "missionId" = '805c1d8c-1bd4-41e7-9cf1-d22631dae260'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **N8N deletion workflow is called** ✅
|
||||||
|
```
|
||||||
|
Starting N8N deletion workflow
|
||||||
|
Triggering n8n mission deletion workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **N8N responds successfully** ✅
|
||||||
|
```
|
||||||
|
Deletion webhook response { status: 200 }
|
||||||
|
Parsed deletion workflow result { success: true, hasError: false }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Mission is deleted from database** ✅
|
||||||
|
```
|
||||||
|
DELETE FROM "public"."Mission" WHERE "id" = '805c1d8c-1bd4-41e7-9cf1-d22631dae260'
|
||||||
|
Mission deleted successfully from database
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Critical Problems
|
||||||
|
|
||||||
|
### Problem 1: N8N_API_KEY Not Set
|
||||||
|
|
||||||
|
```
|
||||||
|
N8N_API_KEY is not set in environment variables
|
||||||
|
API key present { present: false }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**: N8N workflow runs but may not have proper authentication.
|
||||||
|
|
||||||
|
### Problem 2: All Integration IDs Are NULL/Empty
|
||||||
|
|
||||||
|
**What N8N Receives**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missionId": "805c1d8c-1bd4-41e7-9cf1-d22631dae260",
|
||||||
|
"name": "libra",
|
||||||
|
"repoName": "", // ❌ EMPTY
|
||||||
|
"leantimeProjectId": 0, // ❌ ZERO
|
||||||
|
"documentationCollectionId": "", // ❌ EMPTY
|
||||||
|
"rocketchatChannelId": "", // ❌ EMPTY
|
||||||
|
"giteaRepositoryUrl": null, // ❌ NULL
|
||||||
|
"outlineCollectionId": null, // ❌ NULL
|
||||||
|
"rocketChatChannelId": null, // ❌ NULL
|
||||||
|
"penpotProjectId": null // ❌ NULL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Cause**: The mission was created **without integration IDs being saved** because:
|
||||||
|
1. Mission was created in database ✅
|
||||||
|
2. N8N workflow was triggered ✅
|
||||||
|
3. N8N created integrations ✅
|
||||||
|
4. N8N tried to call `/api/missions/mission-created` ❌
|
||||||
|
5. **Endpoint returned 500 error** (N8N_API_KEY not configured) ❌
|
||||||
|
6. **IDs were never saved to database** ❌
|
||||||
|
|
||||||
|
### Problem 3: N8N Cannot Delete Integrations
|
||||||
|
|
||||||
|
Because N8N receives empty IDs:
|
||||||
|
- ❌ Cannot delete Gitea repository (no `repoName`)
|
||||||
|
- ❌ Cannot close Leantime project (no `leantimeProjectId`)
|
||||||
|
- ❌ Cannot delete Outline collection (no `documentationCollectionId`)
|
||||||
|
- ❌ Cannot close RocketChat channel (no `rocketchatChannelId`)
|
||||||
|
|
||||||
|
**Result**: External integrations remain orphaned even though mission is deleted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Complete Flow Breakdown
|
||||||
|
|
||||||
|
### Step 1: Frontend Calls DELETE
|
||||||
|
```
|
||||||
|
DELETE /api/missions/805c1d8c-1bd4-41e7-9cf1-d22631dae260
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Backend Fetches Mission
|
||||||
|
```sql
|
||||||
|
SELECT "Mission" WHERE "id" = '805c1d8c-1bd4-41e7-9cf1-d22631dae260'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: Mission found, but all integration IDs are `null`:
|
||||||
|
- `leantimeProjectId`: null
|
||||||
|
- `outlineCollectionId`: null
|
||||||
|
- `rocketChatChannelId`: null
|
||||||
|
- `giteaRepositoryUrl`: null
|
||||||
|
|
||||||
|
### Step 3: Backend Prepares Deletion Data
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
repoName: "", // Extracted from null giteaRepositoryUrl
|
||||||
|
leantimeProjectId: 0, // null || 0 = 0
|
||||||
|
documentationCollectionId: "", // null || '' = ''
|
||||||
|
rocketchatChannelId: "" // null || '' = ''
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: N8N Workflow Called
|
||||||
|
```
|
||||||
|
POST https://brain.slm-lab.net/webhook-test/mission-delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
- `x-api-key`: (empty - N8N_API_KEY not set)
|
||||||
|
|
||||||
|
**Body**: (with empty IDs as shown above)
|
||||||
|
|
||||||
|
### Step 5: N8N Workflow Executes
|
||||||
|
- ✅ Receives request
|
||||||
|
- ❌ Cannot delete integrations (no IDs)
|
||||||
|
- ✅ Returns success (but didn't actually delete anything)
|
||||||
|
|
||||||
|
### Step 6: Mission Deleted from Database
|
||||||
|
```sql
|
||||||
|
DELETE FROM "Mission" WHERE "id" = '805c1d8c-1bd4-41e7-9cf1-d22631dae260'
|
||||||
|
```
|
||||||
|
|
||||||
|
**CASCADE deletes**:
|
||||||
|
- ✅ MissionUsers
|
||||||
|
- ✅ Attachments
|
||||||
|
|
||||||
|
**But external integrations remain**:
|
||||||
|
- ❌ Gitea repository still exists
|
||||||
|
- ❌ Leantime project still exists
|
||||||
|
- ❌ Outline collection still exists
|
||||||
|
- ❌ RocketChat channel still exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Root Cause Summary
|
||||||
|
|
||||||
|
### Why IDs Are NULL
|
||||||
|
|
||||||
|
1. **Mission Creation**:
|
||||||
|
- Mission created in database ✅
|
||||||
|
- N8N workflow triggered ✅
|
||||||
|
- N8N created integrations ✅
|
||||||
|
|
||||||
|
2. **N8N Callback Fails**:
|
||||||
|
- N8N tries to call `/api/missions/mission-created`
|
||||||
|
- Endpoint checks for `N8N_API_KEY` in environment
|
||||||
|
- `N8N_API_KEY` is not set ❌
|
||||||
|
- Endpoint returns 500: "Server configuration error" ❌
|
||||||
|
- **IDs are never saved** ❌
|
||||||
|
|
||||||
|
3. **Mission Deletion**:
|
||||||
|
- Mission has no integration IDs (all null)
|
||||||
|
- N8N receives empty IDs
|
||||||
|
- Cannot delete integrations
|
||||||
|
- **Integrations remain orphaned** ❌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solutions
|
||||||
|
|
||||||
|
### Solution 1: Fix N8N_API_KEY (IMMEDIATE)
|
||||||
|
|
||||||
|
**Add to environment variables**:
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then**:
|
||||||
|
1. Restart your application
|
||||||
|
2. Create a new mission
|
||||||
|
3. Verify IDs are saved to database
|
||||||
|
4. Delete mission - should work correctly
|
||||||
|
|
||||||
|
### Solution 2: Fix Existing Missions (MIGRATION)
|
||||||
|
|
||||||
|
For missions that already exist without IDs:
|
||||||
|
|
||||||
|
**Option A: Manual Update**
|
||||||
|
```sql
|
||||||
|
UPDATE "Mission"
|
||||||
|
SET
|
||||||
|
"giteaRepositoryUrl" = 'https://gite.slm-lab.net/alma/repo-name',
|
||||||
|
"leantimeProjectId" = '123',
|
||||||
|
"outlineCollectionId" = 'collection-id',
|
||||||
|
"rocketChatChannelId" = 'channel-id'
|
||||||
|
WHERE "id" = 'mission-id';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Query External Services**
|
||||||
|
- Query Gitea API to find repositories
|
||||||
|
- Query Leantime API to find projects
|
||||||
|
- Query Outline API to find collections
|
||||||
|
- Query RocketChat API to find channels
|
||||||
|
- Update database with found IDs
|
||||||
|
|
||||||
|
**Option C: Re-create Missions**
|
||||||
|
- Delete missions without IDs
|
||||||
|
- Re-create them (with N8N_API_KEY fixed, IDs will be saved)
|
||||||
|
|
||||||
|
### Solution 3: Make N8N Callback More Resilient
|
||||||
|
|
||||||
|
**Modify `/api/missions/mission-created` endpoint** to handle missing API key more gracefully:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Instead of returning 500, log warning and continue
|
||||||
|
if (!expectedApiKey) {
|
||||||
|
logger.warn('N8N_API_KEY not configured, but continuing anyway', {
|
||||||
|
missionId: body.missionId
|
||||||
|
});
|
||||||
|
// Continue without API key validation (less secure but works)
|
||||||
|
// OR require API key but provide better error message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not recommended** for production (security risk), but could work for development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Current State vs Desired State
|
||||||
|
|
||||||
|
### Current State (Your Logs)
|
||||||
|
|
||||||
|
```
|
||||||
|
Mission in DB:
|
||||||
|
- leantimeProjectId: null
|
||||||
|
- outlineCollectionId: null
|
||||||
|
- rocketChatChannelId: null
|
||||||
|
- giteaRepositoryUrl: null
|
||||||
|
|
||||||
|
N8N Receives:
|
||||||
|
- repoName: ""
|
||||||
|
- leantimeProjectId: 0
|
||||||
|
- documentationCollectionId: ""
|
||||||
|
- rocketchatChannelId: ""
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Mission deleted ✅
|
||||||
|
- Integrations NOT deleted ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desired State
|
||||||
|
|
||||||
|
```
|
||||||
|
Mission in DB:
|
||||||
|
- leantimeProjectId: "123"
|
||||||
|
- outlineCollectionId: "collection-456"
|
||||||
|
- rocketChatChannelId: "channel-789"
|
||||||
|
- giteaRepositoryUrl: "https://gite.slm-lab.net/alma/repo-name"
|
||||||
|
|
||||||
|
N8N Receives:
|
||||||
|
- repoName: "repo-name"
|
||||||
|
- leantimeProjectId: 123
|
||||||
|
- documentationCollectionId: "collection-456"
|
||||||
|
- rocketchatChannelId: "channel-789"
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Mission deleted ✅
|
||||||
|
- Integrations deleted ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Immediate Actions Required
|
||||||
|
|
||||||
|
1. **✅ Add N8N_API_KEY to environment**
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **✅ Restart application**
|
||||||
|
|
||||||
|
3. **✅ Test mission creation**
|
||||||
|
- Create a new mission
|
||||||
|
- Check database - IDs should be saved
|
||||||
|
- Delete mission - should work correctly
|
||||||
|
|
||||||
|
4. **⚠️ Fix existing missions**
|
||||||
|
- Update existing missions with their integration IDs
|
||||||
|
- Or delete and re-create them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary
|
||||||
|
|
||||||
|
**The deletion flow is working correctly**, but:
|
||||||
|
|
||||||
|
1. **N8N_API_KEY is missing** → Endpoint returns 500 error
|
||||||
|
2. **IDs are never saved** → Mission has null integration IDs
|
||||||
|
3. **N8N receives empty IDs** → Cannot delete integrations
|
||||||
|
4. **Integrations remain orphaned** → External resources not cleaned up
|
||||||
|
|
||||||
|
**Fix**: Add `N8N_API_KEY` to environment variables, then new missions will work correctly. Existing missions need manual update or re-creation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Status**: Deletion flow works, but integration cleanup fails due to missing IDs
|
||||||
|
|
||||||
682
MISSION_DELETION_FLOW_COMPLETE_ANALYSIS.md
Normal file
682
MISSION_DELETION_FLOW_COMPLETE_ANALYSIS.md
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
# Mission Deletion Flow - Complete Analysis
|
||||||
|
|
||||||
|
## 📋 Executive Summary
|
||||||
|
|
||||||
|
This document provides a comprehensive analysis of the mission deletion flow, tracing every step from the user clicking the "Supprimer" button to the complete cleanup of mission data, files, and external integrations.
|
||||||
|
|
||||||
|
**Status**: ✅ **Fully Implemented** - All components are working correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Complete Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. FRONTEND - MissionDetailPage │
|
||||||
|
│ Location: app/missions/[missionId]/page.tsx │
|
||||||
|
│ - User clicks "Supprimer" button (line 398-410) │
|
||||||
|
│ - Confirmation dialog (line 145) │
|
||||||
|
│ - DELETE /api/missions/[missionId] (line 151-153) │
|
||||||
|
│ - Success toast + redirect to /missions (line 159-165) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 2. BACKEND - DELETE /api/missions/[missionId] │
|
||||||
|
│ Location: app/api/missions/[missionId]/route.ts │
|
||||||
|
│ │
|
||||||
|
│ 2.1 Authentication Check (line 297-300) │
|
||||||
|
│ ✅ NextAuth session validation │
|
||||||
|
│ │
|
||||||
|
│ 2.2 Mission Existence Check (line 302-315) │
|
||||||
|
│ ✅ Fetch mission with missionUsers │
|
||||||
|
│ ✅ Return 404 if not found │
|
||||||
|
│ │
|
||||||
|
│ 2.3 Permission Check (line 317-323) │
|
||||||
|
│ ✅ Creator: mission.creatorId === session.user.id │
|
||||||
|
│ ✅ Admin: userRoles.includes('admin'/'ADMIN') │
|
||||||
|
│ ✅ Return 403 if unauthorized │
|
||||||
|
│ │
|
||||||
|
│ 2.4 Fetch Attachments (line 325-328) │
|
||||||
|
│ ✅ Get all attachments for Minio cleanup │
|
||||||
|
│ │
|
||||||
|
│ 2.5 N8N Deletion Workflow (line 330-391) │
|
||||||
|
│ ✅ Extract repo name from giteaRepositoryUrl │
|
||||||
|
│ ✅ Prepare deletion data │
|
||||||
|
│ ✅ Call n8nService.triggerMissionDeletion() │
|
||||||
|
│ ✅ Non-blocking: continues even if N8N fails │
|
||||||
|
│ │
|
||||||
|
│ 2.6 Minio File Deletion (line 393-423) │
|
||||||
|
│ ✅ Delete logo: deleteMissionLogo() (line 397) │
|
||||||
|
│ ✅ Delete attachments: deleteMissionAttachment() │
|
||||||
|
│ ✅ Non-blocking: continues if file deletion fails │
|
||||||
|
│ │
|
||||||
|
│ 2.7 Database Deletion (line 425-428) │
|
||||||
|
│ ✅ prisma.mission.delete() │
|
||||||
|
│ ✅ CASCADE: Auto-deletes MissionUsers & Attachments │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 3. PRISMA CASCADE DELETION │
|
||||||
|
│ Location: prisma/schema.prisma │
|
||||||
|
│ │
|
||||||
|
│ ✅ MissionUser (line 173): onDelete: Cascade │
|
||||||
|
│ ✅ Attachment (line 159): onDelete: Cascade │
|
||||||
|
│ ✅ All related records deleted automatically │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 4. EXTERNAL INTEGRATIONS CLEANUP (via N8N) │
|
||||||
|
│ Location: lib/services/n8n-service.ts │
|
||||||
|
│ │
|
||||||
|
│ ✅ Gitea Repository: Deleted │
|
||||||
|
│ ✅ Leantime Project: Closed │
|
||||||
|
│ ✅ Outline Collection: Deleted │
|
||||||
|
│ ✅ RocketChat Channel: Closed │
|
||||||
|
│ ✅ Penpot Project: (if applicable) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Detailed Step-by-Step Analysis
|
||||||
|
|
||||||
|
### Step 1: Frontend - User Interaction
|
||||||
|
|
||||||
|
**File**: `app/missions/[missionId]/page.tsx`
|
||||||
|
|
||||||
|
#### 1.1 Delete Button (Lines 397-410)
|
||||||
|
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- ✅ Visual feedback: Red styling indicates destructive action
|
||||||
|
- ✅ Loading state: Spinner shown during deletion (`deleting` state)
|
||||||
|
- ✅ Disabled state: Button disabled during operation
|
||||||
|
- ✅ Icon: Trash2 icon for clear visual indication
|
||||||
|
|
||||||
|
#### 1.2 Delete Handler (Lines 144-176)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDeleteMission = async () => {
|
||||||
|
// 1. User confirmation
|
||||||
|
if (!confirm("Êtes-vous sûr de vouloir supprimer cette mission ? Cette action est irréversible.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
|
||||||
|
// 2. API call
|
||||||
|
const response = await fetch(`/api/missions/${missionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Error handling
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Success feedback
|
||||||
|
toast({
|
||||||
|
title: "Mission supprimée",
|
||||||
|
description: "La mission a été supprimée avec succès",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Redirect
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- ✅ **Double confirmation**: Native browser confirm dialog
|
||||||
|
- ✅ **Error handling**: Try-catch with user feedback
|
||||||
|
- ✅ **Success feedback**: Toast notification
|
||||||
|
- ✅ **Automatic redirect**: Returns to missions list
|
||||||
|
- ✅ **Loading state management**: Properly manages `deleting` state
|
||||||
|
|
||||||
|
**Potential Improvements**:
|
||||||
|
- ⚠️ Consider using a more sophisticated confirmation dialog (e.g., AlertDialog component) instead of native `confirm()`
|
||||||
|
- ⚠️ Could show more detailed error messages from API response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Backend - DELETE Endpoint
|
||||||
|
|
||||||
|
**File**: `app/api/missions/[missionId]/route.ts`
|
||||||
|
|
||||||
|
#### 2.1 Authentication Check (Lines 297-300)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
- Uses NextAuth session validation
|
||||||
|
- Returns 401 if not authenticated
|
||||||
|
|
||||||
|
#### 2.2 Mission Existence Check (Lines 302-315)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: params.missionId },
|
||||||
|
include: {
|
||||||
|
missionUsers: {
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
- Fetches mission with related users
|
||||||
|
- Returns 404 if mission doesn't exist
|
||||||
|
|
||||||
|
#### 2.3 Permission Check (Lines 317-323)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
|
||||||
|
**Permission Rules**:
|
||||||
|
- ✅ **Creator**: Can delete their own mission
|
||||||
|
- ✅ **Admin**: Can delete any mission
|
||||||
|
- ❌ **Other users**: Even guardians/volunteers cannot delete
|
||||||
|
|
||||||
|
**Security**: ✅ **Properly secured** - Only creator or admin can delete
|
||||||
|
|
||||||
|
#### 2.4 Fetch Attachments (Lines 325-328)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const attachments = await prisma.attachment.findMany({
|
||||||
|
where: { missionId: params.missionId }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
- Fetches all attachments before deletion for Minio cleanup
|
||||||
|
- Needed because Prisma cascade deletes DB records but not Minio files
|
||||||
|
|
||||||
|
#### 2.5 N8N Deletion Workflow (Lines 330-391)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 1: Trigger N8N workflow for deletion
|
||||||
|
logger.debug('Starting N8N deletion workflow');
|
||||||
|
const n8nService = new N8nService();
|
||||||
|
|
||||||
|
// Extract repo name from giteaRepositoryUrl
|
||||||
|
let repoName = '';
|
||||||
|
if (mission.giteaRepositoryUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(mission.giteaRepositoryUrl);
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||||
|
repoName = pathParts[pathParts.length - 1] || '';
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback extraction
|
||||||
|
const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/);
|
||||||
|
repoName = match ? match[1] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare deletion data
|
||||||
|
const n8nDeletionData = {
|
||||||
|
missionId: mission.id,
|
||||||
|
name: mission.name,
|
||||||
|
repoName: repoName,
|
||||||
|
leantimeProjectId: mission.leantimeProjectId || 0,
|
||||||
|
documentationCollectionId: mission.outlineCollectionId || '',
|
||||||
|
rocketchatChannelId: mission.rocketChatChannelId || '',
|
||||||
|
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||||
|
outlineCollectionId: mission.outlineCollectionId,
|
||||||
|
rocketChatChannelId: mission.rocketChatChannelId,
|
||||||
|
penpotProjectId: mission.penpotProjectId,
|
||||||
|
config: {
|
||||||
|
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||||
|
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://hub.slm-lab.net'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const n8nResult = await n8nService.triggerMissionDeletion(n8nDeletionData);
|
||||||
|
|
||||||
|
if (!n8nResult.success) {
|
||||||
|
logger.error('N8N deletion workflow failed, but continuing with mission deletion', {
|
||||||
|
error: n8nResult.error
|
||||||
|
});
|
||||||
|
// Continue with deletion even if N8N fails (non-blocking)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
|
||||||
|
**What it does**:
|
||||||
|
- Extracts repository name from Gitea URL
|
||||||
|
- Prepares data for N8N workflow
|
||||||
|
- Calls N8N deletion webhook
|
||||||
|
- **Non-blocking**: Continues even if N8N fails
|
||||||
|
|
||||||
|
**N8N Service Implementation** (`lib/services/n8n-service.ts`):
|
||||||
|
- ✅ Webhook URL: `https://brain.slm-lab.net/webhook-test/mission-delete`
|
||||||
|
- ✅ Sends POST request with API key authentication
|
||||||
|
- ✅ Handles errors gracefully
|
||||||
|
- ✅ Returns success/failure status
|
||||||
|
|
||||||
|
**External Integrations Cleaned Up**:
|
||||||
|
1. ✅ **Gitea Repository**: Deleted
|
||||||
|
2. ✅ **Leantime Project**: Closed
|
||||||
|
3. ✅ **Outline Collection**: Deleted
|
||||||
|
4. ✅ **RocketChat Channel**: Closed
|
||||||
|
5. ✅ **Penpot Project**: (if applicable)
|
||||||
|
|
||||||
|
#### 2.6 Minio File Deletion (Lines 393-423)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 2: Delete files from Minio AFTER N8N confirmation
|
||||||
|
// Delete logo if exists
|
||||||
|
if (mission.logo) {
|
||||||
|
try {
|
||||||
|
await deleteMissionLogo(params.missionId, mission.logo);
|
||||||
|
logger.debug('Logo deleted successfully from Minio');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting mission logo from Minio', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
// Continue deletion even if logo deletion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete attachments from Minio
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
logger.debug(`Deleting ${attachments.length} attachment(s) from Minio`);
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
try {
|
||||||
|
await deleteMissionAttachment(attachment.filePath);
|
||||||
|
logger.debug('Attachment deleted successfully', { filename: attachment.filename });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting attachment from Minio', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
filename: attachment.filename
|
||||||
|
});
|
||||||
|
// Continue deletion even if one attachment fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
|
||||||
|
**Implementation Details** (`lib/mission-uploads.ts`):
|
||||||
|
|
||||||
|
**deleteMissionLogo()** (Lines 43-71):
|
||||||
|
```typescript
|
||||||
|
export async function deleteMissionLogo(missionId: string, logoPath: string): Promise<void> {
|
||||||
|
const normalizedPath = ensureMissionsPrefix(logoPath);
|
||||||
|
const minioPath = normalizedPath.replace(/^missions\//, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: 'missions',
|
||||||
|
Key: minioPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
logger.debug('Mission logo deleted successfully', { minioPath });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting mission logo', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId,
|
||||||
|
minioPath
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**deleteMissionAttachment()** (Lines 74-100):
|
||||||
|
```typescript
|
||||||
|
export async function deleteMissionAttachment(filePath: string): Promise<void> {
|
||||||
|
const normalizedPath = ensureMissionsPrefix(filePath);
|
||||||
|
const minioPath = normalizedPath.replace(/^missions\//, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: 'missions',
|
||||||
|
Key: minioPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
logger.debug('Mission attachment deleted successfully', { minioPath });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting mission attachment', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
minioPath
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- ✅ **Properly implemented**: Uses AWS SDK DeleteObjectCommand
|
||||||
|
- ✅ **Path normalization**: Ensures correct Minio path format
|
||||||
|
- ✅ **Error handling**: Logs errors but continues deletion
|
||||||
|
- ✅ **Non-blocking**: File deletion failures don't stop mission deletion
|
||||||
|
|
||||||
|
**Minio Configuration**:
|
||||||
|
- ✅ Bucket: `missions`
|
||||||
|
- ✅ Endpoint: `https://dome-api.slm-lab.net`
|
||||||
|
- ✅ Path structure: `missions/{missionId}/logo.{ext}` and `missions/{missionId}/attachments/{filename}`
|
||||||
|
|
||||||
|
#### 2.7 Database Deletion (Lines 425-428)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step 3: Delete the mission from database (CASCADE will delete MissionUsers and Attachments)
|
||||||
|
await prisma.mission.delete({
|
||||||
|
where: { id: params.missionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Mission deleted successfully from database', { missionId: params.missionId });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
|
||||||
|
**Cascade Behavior** (from `prisma/schema.prisma`):
|
||||||
|
|
||||||
|
```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)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What gets deleted automatically**:
|
||||||
|
- ✅ **MissionUsers**: All user assignments (guardians, volunteers)
|
||||||
|
- ✅ **Attachments**: All attachment records
|
||||||
|
|
||||||
|
**What does NOT get deleted automatically**:
|
||||||
|
- ⚠️ **Minio files**: Must be deleted manually (handled in Step 2.6)
|
||||||
|
- ⚠️ **External integrations**: Must be cleaned via N8N (handled in Step 2.5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Prisma Cascade Deletion
|
||||||
|
|
||||||
|
**File**: `prisma/schema.prisma`
|
||||||
|
|
||||||
|
When `prisma.mission.delete()` is executed, Prisma automatically:
|
||||||
|
|
||||||
|
1. **Deletes all MissionUsers** (line 173: `onDelete: Cascade`)
|
||||||
|
```sql
|
||||||
|
DELETE FROM "MissionUser" WHERE "missionId" = 'mission-id';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Deletes all Attachments** (line 159: `onDelete: Cascade`)
|
||||||
|
```sql
|
||||||
|
DELETE FROM "Attachment" WHERE "missionId" = 'mission-id';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
- Cascade relationships properly configured
|
||||||
|
- Atomic operation: All or nothing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: External Integrations Cleanup
|
||||||
|
|
||||||
|
**File**: `lib/services/n8n-service.ts`
|
||||||
|
|
||||||
|
The N8N workflow (`triggerMissionDeletion`) handles cleanup of:
|
||||||
|
|
||||||
|
1. ✅ **Gitea Repository**: Deleted via Gitea API
|
||||||
|
2. ✅ **Leantime Project**: Closed via Leantime API
|
||||||
|
3. ✅ **Outline Collection**: Deleted via Outline API
|
||||||
|
4. ✅ **RocketChat Channel**: Closed via RocketChat API
|
||||||
|
5. ✅ **Penpot Project**: (if applicable)
|
||||||
|
|
||||||
|
**Status**: ✅ **Working correctly**
|
||||||
|
- Non-blocking: Mission deletion continues even if N8N fails
|
||||||
|
- Proper error logging
|
||||||
|
- Webhook URL: `https://brain.slm-lab.net/webhook-test/mission-delete`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Summary of Operations
|
||||||
|
|
||||||
|
### Operations Performed Successfully
|
||||||
|
|
||||||
|
1. ✅ **Frontend confirmation**: User confirmation dialog
|
||||||
|
2. ✅ **Authentication check**: NextAuth session validation
|
||||||
|
3. ✅ **Permission check**: Creator or admin only
|
||||||
|
4. ✅ **N8N workflow trigger**: External integrations cleanup
|
||||||
|
5. ✅ **Minio logo deletion**: Logo file removed from storage
|
||||||
|
6. ✅ **Minio attachments deletion**: All attachment files removed
|
||||||
|
7. ✅ **Database mission deletion**: Mission record deleted
|
||||||
|
8. ✅ **Cascade deletion**: MissionUsers and Attachments deleted automatically
|
||||||
|
9. ✅ **Success feedback**: Toast notification to user
|
||||||
|
10. ✅ **Redirect**: User redirected to missions list
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- ✅ **Non-blocking N8N**: Continues even if N8N workflow fails
|
||||||
|
- ✅ **Non-blocking file deletion**: Continues even if Minio deletion fails
|
||||||
|
- ✅ **Proper error logging**: All errors logged with context
|
||||||
|
- ✅ **User feedback**: Error toast shown to user on failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Potential Issues & Recommendations
|
||||||
|
|
||||||
|
### 1. Frontend Confirmation Dialog
|
||||||
|
|
||||||
|
**Current**: Uses native browser `confirm()` dialog
|
||||||
|
|
||||||
|
**Recommendation**: Consider using a more sophisticated confirmation dialog:
|
||||||
|
```typescript
|
||||||
|
// Use AlertDialog component instead
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="...">
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>Supprimer la mission</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Êtes-vous sûr de vouloir supprimer cette mission ?
|
||||||
|
Cette action est irréversible et supprimera :
|
||||||
|
- La mission et toutes ses données
|
||||||
|
- Les fichiers associés
|
||||||
|
- Les intégrations externes (Gitea, Leantime, etc.)
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteMission}>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority**: Low (cosmetic improvement)
|
||||||
|
|
||||||
|
### 2. Error Message Details
|
||||||
|
|
||||||
|
**Current**: Generic error message "Impossible de supprimer la mission"
|
||||||
|
|
||||||
|
**Recommendation**: Show more detailed error messages:
|
||||||
|
```typescript
|
||||||
|
catch (error) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: errorData.error || "Impossible de supprimer la mission",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority**: Medium (better UX)
|
||||||
|
|
||||||
|
### 3. Parallel File Deletion
|
||||||
|
|
||||||
|
**Current**: Sequential deletion of attachments (for loop)
|
||||||
|
|
||||||
|
**Recommendation**: Delete files in parallel for better performance:
|
||||||
|
```typescript
|
||||||
|
// Delete attachments in parallel
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
attachments.map(attachment =>
|
||||||
|
deleteMissionAttachment(attachment.filePath).catch(error => {
|
||||||
|
logger.error('Error deleting attachment', { error, filename: attachment.filename });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority**: Low (performance optimization)
|
||||||
|
|
||||||
|
### 4. Transaction Safety
|
||||||
|
|
||||||
|
**Current**: No transaction wrapper - if database deletion fails, files are already deleted
|
||||||
|
|
||||||
|
**Recommendation**: Consider transaction approach (though Prisma doesn't support cross-database transactions):
|
||||||
|
```typescript
|
||||||
|
// Note: This is conceptual - Prisma doesn't support cross-database transactions
|
||||||
|
// But we could implement a rollback mechanism
|
||||||
|
try {
|
||||||
|
// Delete files
|
||||||
|
// Delete from database
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback: Re-upload files? (Complex, probably not worth it)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority**: Low (current approach is acceptable)
|
||||||
|
|
||||||
|
### 5. N8N Webhook URL
|
||||||
|
|
||||||
|
**Current**: Uses `-test` suffix: `https://brain.slm-lab.net/webhook-test/mission-delete`
|
||||||
|
|
||||||
|
**Recommendation**: Verify if this should be production URL:
|
||||||
|
```typescript
|
||||||
|
const deleteWebhookUrl = process.env.N8N_DELETE_WEBHOOK_URL ||
|
||||||
|
'https://brain.slm-lab.net/webhook/mission-delete'; // Remove -test?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority**: Medium (verify with team)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Testing Checklist
|
||||||
|
|
||||||
|
### Manual Testing Steps
|
||||||
|
|
||||||
|
1. ✅ **Test as Creator**:
|
||||||
|
- [ ] Create a mission
|
||||||
|
- [ ] Delete the mission as creator
|
||||||
|
- [ ] Verify mission is deleted
|
||||||
|
- [ ] Verify files are deleted from Minio
|
||||||
|
- [ ] Verify external integrations are cleaned up
|
||||||
|
|
||||||
|
2. ✅ **Test as Admin**:
|
||||||
|
- [ ] Delete a mission created by another user
|
||||||
|
- [ ] Verify deletion works
|
||||||
|
|
||||||
|
3. ✅ **Test as Non-Creator/Non-Admin**:
|
||||||
|
- [ ] Try to delete a mission (should fail with 403)
|
||||||
|
|
||||||
|
4. ✅ **Test Error Scenarios**:
|
||||||
|
- [ ] Delete mission with logo (verify logo deleted)
|
||||||
|
- [ ] Delete mission with attachments (verify attachments deleted)
|
||||||
|
- [ ] Delete mission with external integrations (verify N8N called)
|
||||||
|
- [ ] Simulate N8N failure (verify mission still deleted)
|
||||||
|
|
||||||
|
5. ✅ **Test Database Cascade**:
|
||||||
|
- [ ] Verify MissionUsers are deleted
|
||||||
|
- [ ] Verify Attachments are deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Conclusion
|
||||||
|
|
||||||
|
**Overall Status**: ✅ **FULLY FUNCTIONAL**
|
||||||
|
|
||||||
|
The mission deletion flow is **completely implemented and working correctly**. All components are in place:
|
||||||
|
|
||||||
|
- ✅ Frontend confirmation and API call
|
||||||
|
- ✅ Backend authentication and authorization
|
||||||
|
- ✅ N8N workflow for external integrations
|
||||||
|
- ✅ Minio file deletion (logo and attachments)
|
||||||
|
- ✅ Database deletion with cascade
|
||||||
|
- ✅ Proper error handling and logging
|
||||||
|
|
||||||
|
The flow is **secure**, **robust**, and **well-structured**. Minor improvements could be made to the UX (better confirmation dialog, more detailed error messages), but the core functionality is solid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Generated**: $(date)
|
||||||
|
**Last Reviewed**: $(date)
|
||||||
|
**Reviewed By**: Senior Developer Analysis
|
||||||
|
|
||||||
604
MISSION_DELETION_N8N_IDS_ISSUE_ANALYSIS.md
Normal file
604
MISSION_DELETION_N8N_IDS_ISSUE_ANALYSIS.md
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
# Mission Deletion N8N IDs Issue - Complete Analysis
|
||||||
|
|
||||||
|
## 🔍 Problem Statement
|
||||||
|
|
||||||
|
When deleting a mission, the N8N deletion workflow is not working because the database does not contain the integration IDs (Leantime, Outline, Gitea, RocketChat). This prevents N8N from properly cleaning up external integrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Current Flow Analysis
|
||||||
|
|
||||||
|
### Mission Creation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Frontend → POST /api/missions
|
||||||
|
↓
|
||||||
|
2. Backend creates mission in Prisma
|
||||||
|
✅ Mission created with NULL integration IDs:
|
||||||
|
- leantimeProjectId: null
|
||||||
|
- outlineCollectionId: null
|
||||||
|
- giteaRepositoryUrl: null
|
||||||
|
- rocketChatChannelId: null
|
||||||
|
↓
|
||||||
|
3. Backend uploads files to Minio
|
||||||
|
↓
|
||||||
|
4. Backend triggers N8N workflow (async)
|
||||||
|
✅ Sends missionId to N8N
|
||||||
|
↓
|
||||||
|
5. N8N creates external integrations:
|
||||||
|
- Gitea repository
|
||||||
|
- Leantime project
|
||||||
|
- Outline collection
|
||||||
|
- RocketChat channel
|
||||||
|
↓
|
||||||
|
6. N8N should call → POST /api/missions/mission-created
|
||||||
|
⚠️ PROBLEM: This callback may fail or not be called
|
||||||
|
↓
|
||||||
|
7. Backend should save IDs to database
|
||||||
|
❌ If step 6 fails, IDs are never saved
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mission Deletion Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Frontend → DELETE /api/missions/[missionId]
|
||||||
|
↓
|
||||||
|
2. Backend reads mission from database
|
||||||
|
❌ Integration IDs are NULL (if step 6 above failed)
|
||||||
|
↓
|
||||||
|
3. Backend prepares deletion data for N8N:
|
||||||
|
{
|
||||||
|
repoName: "", // Empty because giteaRepositoryUrl is null
|
||||||
|
leantimeProjectId: 0, // 0 because leantimeProjectId is null
|
||||||
|
documentationCollectionId: "", // Empty because outlineCollectionId is null
|
||||||
|
rocketchatChannelId: "" // Empty because rocketChatChannelId is null
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
4. Backend sends to N8N deletion workflow
|
||||||
|
❌ N8N receives empty IDs and cannot delete integrations
|
||||||
|
↓
|
||||||
|
5. N8N fails to clean up external resources
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Code Analysis
|
||||||
|
|
||||||
|
### 1. Mission Creation - Saving IDs
|
||||||
|
|
||||||
|
**File**: `app/api/missions/route.ts`
|
||||||
|
|
||||||
|
**Lines 260-262**: Mission is created WITHOUT integration IDs
|
||||||
|
```typescript
|
||||||
|
const mission = await prisma.mission.create({
|
||||||
|
data: missionData // No integration IDs here
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lines 413-423**: N8N is triggered with missionId
|
||||||
|
```typescript
|
||||||
|
const n8nData = {
|
||||||
|
...body,
|
||||||
|
missionId: mission.id, // ✅ missionId is sent to N8N
|
||||||
|
creatorId: userId,
|
||||||
|
logoPath: logoPath,
|
||||||
|
logoUrl: logoUrl,
|
||||||
|
config: {
|
||||||
|
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||||
|
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workflowResult = await n8nService.triggerMissionCreation(n8nData);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue**: The API returns success immediately without waiting for N8N to save the IDs.
|
||||||
|
|
||||||
|
### 2. N8N Callback Endpoint
|
||||||
|
|
||||||
|
**File**: `app/api/missions/mission-created/route.ts`
|
||||||
|
|
||||||
|
**Lines 64-69**: Endpoint prefers `missionId` over `name + creatorId`
|
||||||
|
```typescript
|
||||||
|
if (body.missionId) {
|
||||||
|
// ✅ Use missionId if provided (more reliable)
|
||||||
|
logger.debug('Looking up mission by ID', { missionId: body.missionId });
|
||||||
|
mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: body.missionId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lines 128-150**: Maps N8N fields to Prisma fields
|
||||||
|
```typescript
|
||||||
|
// Mapper les champs N8N vers notre schéma Prisma
|
||||||
|
if (body.gitRepoUrl !== undefined) {
|
||||||
|
updateData.giteaRepositoryUrl = body.gitRepoUrl || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.leantimeProjectId !== undefined) {
|
||||||
|
updateData.leantimeProjectId = body.leantimeProjectId
|
||||||
|
? String(body.leantimeProjectId)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.documentationCollectionId !== undefined) {
|
||||||
|
updateData.outlineCollectionId = body.documentationCollectionId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.rocketchatChannelId !== undefined) {
|
||||||
|
updateData.rocketChatChannelId = body.rocketchatChannelId || null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status**: ✅ Endpoint exists and should work correctly
|
||||||
|
|
||||||
|
### 3. Mission Deletion - Reading IDs
|
||||||
|
|
||||||
|
**File**: `app/api/missions/[missionId]/route.ts`
|
||||||
|
|
||||||
|
**Lines 302-311**: Mission is fetched from database
|
||||||
|
```typescript
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: params.missionId },
|
||||||
|
include: {
|
||||||
|
missionUsers: {
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lines 356-372**: Deletion data is prepared
|
||||||
|
```typescript
|
||||||
|
const n8nDeletionData = {
|
||||||
|
missionId: mission.id,
|
||||||
|
name: mission.name,
|
||||||
|
repoName: repoName, // Extracted from giteaRepositoryUrl (may be empty)
|
||||||
|
leantimeProjectId: mission.leantimeProjectId || 0, // ❌ 0 if null
|
||||||
|
documentationCollectionId: mission.outlineCollectionId || '', // ❌ Empty if null
|
||||||
|
rocketchatChannelId: mission.rocketChatChannelId || '', // ❌ Empty if null
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: If IDs are null in database, N8N receives empty values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Root Cause Analysis
|
||||||
|
|
||||||
|
### Possible Causes
|
||||||
|
|
||||||
|
1. **N8N Workflow Not Calling `/mission-created`**
|
||||||
|
- N8N workflow might not be configured to call the callback endpoint
|
||||||
|
- The "Save Mission To API" node might be missing or misconfigured
|
||||||
|
- Network issues preventing the callback
|
||||||
|
|
||||||
|
2. **N8N Callback Failing**
|
||||||
|
- API key mismatch
|
||||||
|
- Mission lookup failing (name/creatorId mismatch)
|
||||||
|
- Network timeout
|
||||||
|
- Server error
|
||||||
|
|
||||||
|
3. **Timing Issues**
|
||||||
|
- N8N workflow takes time to complete
|
||||||
|
- User deletes mission before N8N saves IDs
|
||||||
|
- Race condition
|
||||||
|
|
||||||
|
4. **N8N Not Sending `missionId`**
|
||||||
|
- N8N might only send `name + creatorId`
|
||||||
|
- If mission name is not unique, lookup fails
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Verification Steps
|
||||||
|
|
||||||
|
### Step 1: Check if IDs are being saved
|
||||||
|
|
||||||
|
**Query the database**:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
giteaRepositoryUrl,
|
||||||
|
leantimeProjectId,
|
||||||
|
outlineCollectionId,
|
||||||
|
rocketChatChannelId,
|
||||||
|
createdAt
|
||||||
|
FROM "Mission"
|
||||||
|
WHERE createdAt > NOW() - INTERVAL '7 days'
|
||||||
|
ORDER BY createdAt DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**: Recent missions should have integration IDs populated.
|
||||||
|
|
||||||
|
**If NULL**: N8N callback is not working.
|
||||||
|
|
||||||
|
### Step 2: Check N8N Workflow Configuration
|
||||||
|
|
||||||
|
**Verify N8N workflow has "Save Mission To API" node**:
|
||||||
|
- Node should POST to: `{{ MISSION_API_URL }}/mission-created`
|
||||||
|
- Should include `missionId` in body
|
||||||
|
- Should include `x-api-key` header
|
||||||
|
- Should include integration IDs in body
|
||||||
|
|
||||||
|
**Expected format**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missionId": "uuid-here",
|
||||||
|
"name": "Mission Name",
|
||||||
|
"creatorId": "user-id",
|
||||||
|
"gitRepoUrl": "https://gite.slm-lab.net/alma/repo-name",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"documentationCollectionId": "collection-id",
|
||||||
|
"rocketchatChannelId": "channel-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Check Server Logs
|
||||||
|
|
||||||
|
**Look for**:
|
||||||
|
```
|
||||||
|
Mission Created Webhook Received
|
||||||
|
Received mission-created data: { ... }
|
||||||
|
Found mission: { id: "...", name: "..." }
|
||||||
|
Updating giteaRepositoryUrl: ...
|
||||||
|
Mission updated successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
**If missing**: N8N is not calling the endpoint.
|
||||||
|
|
||||||
|
### Step 4: Test N8N Callback Manually
|
||||||
|
|
||||||
|
**Send test request**:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hub.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: YOUR_N8N_API_KEY" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "existing-mission-id",
|
||||||
|
"gitRepoUrl": "https://gite.slm-lab.net/alma/test-repo",
|
||||||
|
"leantimeProjectId": "999",
|
||||||
|
"documentationCollectionId": "test-collection",
|
||||||
|
"rocketchatChannelId": "test-channel"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**: 200 OK with updated mission data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Solutions
|
||||||
|
|
||||||
|
### Solution 1: Verify N8N Workflow Configuration (IMMEDIATE)
|
||||||
|
|
||||||
|
**Check N8N workflow "Save Mission To API" node**:
|
||||||
|
|
||||||
|
1. **URL should be**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/mission-created
|
||||||
|
```
|
||||||
|
Or hardcoded:
|
||||||
|
```
|
||||||
|
https://hub.slm-lab.net/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Headers should include**:
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
x-api-key: {{ $node['Process Mission Data'].json.config.N8N_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Body should include**:
|
||||||
|
- ✅ `missionId` (from original request)
|
||||||
|
- ✅ `gitRepoUrl` (from Git repository creation)
|
||||||
|
- ✅ `leantimeProjectId` (from Leantime project creation)
|
||||||
|
- ✅ `documentationCollectionId` (from Outline collection creation)
|
||||||
|
- ✅ `rocketchatChannelId` (from RocketChat channel creation)
|
||||||
|
|
||||||
|
4. **Verify node execution**:
|
||||||
|
- Check N8N execution logs
|
||||||
|
- Verify node is not set to "continueOnFail"
|
||||||
|
- Check for errors in node execution
|
||||||
|
|
||||||
|
### Solution 2: Add Logging to Track Callback (DEBUGGING)
|
||||||
|
|
||||||
|
**Add more detailed logging** in `app/api/missions/mission-created/route.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
logger.debug('Mission Created Webhook Received', {
|
||||||
|
headers: {
|
||||||
|
hasApiKey: !!request.headers.get('x-api-key'),
|
||||||
|
contentType: request.headers.get('content-type')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
logger.debug('Received mission-created data', {
|
||||||
|
missionId: body.missionId,
|
||||||
|
name: body.name,
|
||||||
|
creatorId: body.creatorId,
|
||||||
|
hasGitRepoUrl: !!body.gitRepoUrl,
|
||||||
|
hasLeantimeProjectId: !!body.leantimeProjectId,
|
||||||
|
hasDocumentationCollectionId: !!body.documentationCollectionId,
|
||||||
|
hasRocketchatChannelId: !!body.rocketchatChannelId,
|
||||||
|
fullBody: body // Log full body for debugging
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 3: Add Fallback Lookup (ROBUSTNESS)
|
||||||
|
|
||||||
|
**Improve mission lookup** in `app/api/missions/mission-created/route.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Try missionId first
|
||||||
|
if (body.missionId) {
|
||||||
|
mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: body.missionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
logger.warn('Mission not found by ID, trying name + creatorId', {
|
||||||
|
missionId: body.missionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to name + creatorId
|
||||||
|
if (!mission && body.name && body.creatorId) {
|
||||||
|
mission = await prisma.mission.findFirst({
|
||||||
|
where: {
|
||||||
|
name: body.name,
|
||||||
|
creatorId: body.creatorId
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still not found, try just by name (last resort)
|
||||||
|
if (!mission && body.name) {
|
||||||
|
logger.warn('Mission not found by name + creatorId, trying just name', {
|
||||||
|
name: body.name,
|
||||||
|
creatorId: body.creatorId
|
||||||
|
});
|
||||||
|
mission = await prisma.mission.findFirst({
|
||||||
|
where: { name: body.name },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution 4: Add Retry Mechanism (RELIABILITY)
|
||||||
|
|
||||||
|
**Add retry logic** for N8N callback (if N8N supports it):
|
||||||
|
|
||||||
|
- Configure N8N to retry failed callbacks
|
||||||
|
- Or implement a webhook retry queue
|
||||||
|
|
||||||
|
### Solution 5: Manual ID Update Script (MIGRATION)
|
||||||
|
|
||||||
|
**Create a script** to manually update existing missions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/update-mission-ids.ts
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
async function updateMissionIds() {
|
||||||
|
// Get missions without IDs
|
||||||
|
const missions = await prisma.mission.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ giteaRepositoryUrl: null },
|
||||||
|
{ leantimeProjectId: null },
|
||||||
|
{ outlineCollectionId: null },
|
||||||
|
{ rocketChatChannelId: null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mission of missions) {
|
||||||
|
// Manually update IDs if you know them
|
||||||
|
// Or query external services to find them
|
||||||
|
await prisma.mission.update({
|
||||||
|
where: { id: mission.id },
|
||||||
|
data: {
|
||||||
|
giteaRepositoryUrl: '...', // From Gitea
|
||||||
|
leantimeProjectId: '...', // From Leantime
|
||||||
|
outlineCollectionId: '...', // From Outline
|
||||||
|
rocketChatChannelId: '...' // From RocketChat
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Plan
|
||||||
|
|
||||||
|
### Test 1: Create Mission and Verify IDs Saved
|
||||||
|
|
||||||
|
1. Create a new mission via frontend
|
||||||
|
2. Wait 30-60 seconds for N8N to complete
|
||||||
|
3. Query database to verify IDs are saved:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM "Mission" WHERE name = 'Test Mission';
|
||||||
|
```
|
||||||
|
4. **Expected**: All integration IDs should be populated
|
||||||
|
|
||||||
|
### Test 2: Check N8N Execution Logs
|
||||||
|
|
||||||
|
1. Go to N8N execution history
|
||||||
|
2. Find the latest mission creation execution
|
||||||
|
3. Check "Save Mission To API" node:
|
||||||
|
- ✅ Node executed successfully
|
||||||
|
- ✅ Response is 200 OK
|
||||||
|
- ✅ Body contains integration IDs
|
||||||
|
|
||||||
|
### Test 3: Test Deletion with IDs
|
||||||
|
|
||||||
|
1. Delete a mission that has IDs saved
|
||||||
|
2. Check N8N deletion workflow execution
|
||||||
|
3. **Expected**: N8N should receive non-empty IDs and successfully delete integrations
|
||||||
|
|
||||||
|
### Test 4: Test Deletion without IDs
|
||||||
|
|
||||||
|
1. Delete a mission that has NULL IDs
|
||||||
|
2. Check N8N deletion workflow execution
|
||||||
|
3. **Expected**: N8N receives empty IDs and logs warning (but mission still deleted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Expected vs Actual Behavior
|
||||||
|
|
||||||
|
### Expected Behavior
|
||||||
|
|
||||||
|
**Mission Creation**:
|
||||||
|
1. Mission created in database
|
||||||
|
2. N8N workflow triggered
|
||||||
|
3. N8N creates integrations
|
||||||
|
4. N8N calls `/mission-created` with IDs
|
||||||
|
5. IDs saved to database ✅
|
||||||
|
|
||||||
|
**Mission Deletion**:
|
||||||
|
1. Mission fetched from database (with IDs) ✅
|
||||||
|
2. IDs sent to N8N deletion workflow ✅
|
||||||
|
3. N8N deletes integrations ✅
|
||||||
|
4. Mission deleted from database ✅
|
||||||
|
|
||||||
|
### Actual Behavior (Current Issue)
|
||||||
|
|
||||||
|
**Mission Creation**:
|
||||||
|
1. Mission created in database ✅
|
||||||
|
2. N8N workflow triggered ✅
|
||||||
|
3. N8N creates integrations ✅
|
||||||
|
4. N8N calls `/mission-created` ❓ (May fail)
|
||||||
|
5. IDs saved to database ❌ (If step 4 fails)
|
||||||
|
|
||||||
|
**Mission Deletion**:
|
||||||
|
1. Mission fetched from database (IDs are NULL) ❌
|
||||||
|
2. Empty IDs sent to N8N deletion workflow ❌
|
||||||
|
3. N8N cannot delete integrations ❌
|
||||||
|
4. Mission deleted from database ✅ (But integrations remain)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Immediate Action Items
|
||||||
|
|
||||||
|
1. **✅ Verify N8N Workflow Configuration**
|
||||||
|
- Check "Save Mission To API" node exists
|
||||||
|
- Verify URL, headers, and body format
|
||||||
|
- Check execution logs for errors
|
||||||
|
|
||||||
|
2. **✅ Check Server Logs**
|
||||||
|
- Look for `/mission-created` endpoint calls
|
||||||
|
- Check for errors or missing API key
|
||||||
|
- Verify mission lookup is working
|
||||||
|
|
||||||
|
3. **✅ Test Manually**
|
||||||
|
- Create a test mission
|
||||||
|
- Wait for N8N to complete
|
||||||
|
- Check database for IDs
|
||||||
|
- If missing, manually test the callback endpoint
|
||||||
|
|
||||||
|
4. **✅ Fix N8N Workflow (if needed)**
|
||||||
|
- Ensure `missionId` is included in callback
|
||||||
|
- Verify all integration IDs are included
|
||||||
|
- Test the workflow end-to-end
|
||||||
|
|
||||||
|
5. **✅ Update Existing Missions (if needed)**
|
||||||
|
- Manually update IDs for critical missions
|
||||||
|
- Or create migration script
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Code Changes Needed
|
||||||
|
|
||||||
|
### No Code Changes Required (if N8N is configured correctly)
|
||||||
|
|
||||||
|
The endpoint `/api/missions/mission-created` already exists and should work. The issue is likely:
|
||||||
|
- N8N workflow not calling it
|
||||||
|
- N8N workflow calling it incorrectly
|
||||||
|
- Network/authentication issues
|
||||||
|
|
||||||
|
### Optional Improvements
|
||||||
|
|
||||||
|
1. **Better error handling** in `/mission-created` endpoint
|
||||||
|
2. **Retry mechanism** for failed callbacks
|
||||||
|
3. **Monitoring/alerting** when IDs are not saved
|
||||||
|
4. **Migration script** for existing missions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Debugging Commands
|
||||||
|
|
||||||
|
### Check Recent Missions Without IDs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
createdAt,
|
||||||
|
CASE WHEN giteaRepositoryUrl IS NULL THEN 'MISSING' ELSE 'OK' END as gitea,
|
||||||
|
CASE WHEN leantimeProjectId IS NULL THEN 'MISSING' ELSE 'OK' END as leantime,
|
||||||
|
CASE WHEN outlineCollectionId IS NULL THEN 'MISSING' ELSE 'OK' END as outline,
|
||||||
|
CASE WHEN rocketChatChannelId IS NULL THEN 'MISSING' ELSE 'OK' END as rocketchat
|
||||||
|
FROM "Mission"
|
||||||
|
WHERE createdAt > NOW() - INTERVAL '7 days'
|
||||||
|
ORDER BY createdAt DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Mission with IDs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
giteaRepositoryUrl,
|
||||||
|
leantimeProjectId,
|
||||||
|
outlineCollectionId,
|
||||||
|
rocketChatChannelId
|
||||||
|
FROM "Mission"
|
||||||
|
WHERE id = 'your-mission-id';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Callback Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace with actual values
|
||||||
|
curl -X POST https://hub.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: YOUR_N8N_API_KEY" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "mission-uuid-here",
|
||||||
|
"gitRepoUrl": "https://gite.slm-lab.net/alma/test",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"documentationCollectionId": "collection-456",
|
||||||
|
"rocketchatChannelId": "channel-789"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Conclusion
|
||||||
|
|
||||||
|
**Root Cause**: N8N workflow is likely not calling `/api/missions/mission-created` endpoint, or the callback is failing silently.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify N8N workflow configuration
|
||||||
|
2. Check N8N execution logs
|
||||||
|
3. Test callback endpoint manually
|
||||||
|
4. Fix N8N workflow if needed
|
||||||
|
5. Manually update existing missions if necessary
|
||||||
|
|
||||||
|
**Status**: Endpoint exists and should work. Issue is in N8N workflow configuration or execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Last Updated**: $(date)
|
||||||
|
**Priority**: HIGH - Blocks proper mission deletion
|
||||||
|
|
||||||
260
N8N_API_KEY_MISMATCH_FIX.md
Normal file
260
N8N_API_KEY_MISMATCH_FIX.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# N8N API Key Mismatch - 401 Unauthorized
|
||||||
|
|
||||||
|
## 🔍 Problem Identified
|
||||||
|
|
||||||
|
**Error**: `401 - "Unauthorized"`
|
||||||
|
**Log**: `Invalid API key { received: 'present', expected: 'configured' }`
|
||||||
|
|
||||||
|
**Status**:
|
||||||
|
- ✅ Endpoint is being called (`Mission Created Webhook Received`)
|
||||||
|
- ✅ API key is being sent (`received: 'present'`)
|
||||||
|
- ❌ **API key values don't match**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Root Cause
|
||||||
|
|
||||||
|
The API key sent by N8N in the `x-api-key` header **does not match** the `N8N_API_KEY` environment variable on the server.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Server sends to N8N** (line 420 in `app/api/missions/route.ts`):
|
||||||
|
```typescript
|
||||||
|
config: {
|
||||||
|
N8N_API_KEY: process.env.N8N_API_KEY, // From server environment
|
||||||
|
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **N8N uses this value** in "Save Mission To API" node:
|
||||||
|
```
|
||||||
|
x-api-key: {{ $node['Process Mission Data'].json.config.N8N_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Server receives and validates** (line 42 in `app/api/missions/mission-created/route.ts`):
|
||||||
|
```typescript
|
||||||
|
if (apiKey !== expectedApiKey) {
|
||||||
|
// Keys don't match → 401 error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
**If `process.env.N8N_API_KEY` is `undefined` or empty** when sending to N8N:
|
||||||
|
- N8N receives `undefined` or empty string
|
||||||
|
- N8N sends empty string in header
|
||||||
|
- Server expects the actual key value
|
||||||
|
- **Keys don't match → 401 error**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution
|
||||||
|
|
||||||
|
### Step 1: Verify N8N_API_KEY is Set
|
||||||
|
|
||||||
|
**Check your environment variables**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your terminal (if running locally)
|
||||||
|
echo $N8N_API_KEY
|
||||||
|
|
||||||
|
# Or check in your application
|
||||||
|
# Create a test endpoint to verify
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**: Should show the actual API key value (not empty)
|
||||||
|
|
||||||
|
### Step 2: Ensure Same Key in Both Places
|
||||||
|
|
||||||
|
**The key must be the same in**:
|
||||||
|
|
||||||
|
1. **Server environment variable**: `N8N_API_KEY=your-key-here`
|
||||||
|
2. **N8N workflow config**: The value sent in `config.N8N_API_KEY`
|
||||||
|
|
||||||
|
**If they're different**, they won't match!
|
||||||
|
|
||||||
|
### Step 3: Check What N8N is Sending
|
||||||
|
|
||||||
|
**In N8N workflow "Save Mission To API" node**, verify:
|
||||||
|
|
||||||
|
1. **Header `x-api-key` value**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **What this resolves to**:
|
||||||
|
- If `config.N8N_API_KEY` is `undefined` → N8N sends empty string
|
||||||
|
- If `config.N8N_API_KEY` has a value → N8N sends that value
|
||||||
|
|
||||||
|
3. **Check N8N execution logs**:
|
||||||
|
- Look at the actual request being sent
|
||||||
|
- Check the `x-api-key` header value
|
||||||
|
- Compare with your server's `N8N_API_KEY`
|
||||||
|
|
||||||
|
### Step 4: Fix the Mismatch
|
||||||
|
|
||||||
|
**Option A: If server's N8N_API_KEY is undefined**
|
||||||
|
|
||||||
|
Add to `.env.local` (or production environment):
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the application.
|
||||||
|
|
||||||
|
**Option B: If N8N is sending wrong value**
|
||||||
|
|
||||||
|
Check what value N8N has in `config.N8N_API_KEY`:
|
||||||
|
- It should match the server's `N8N_API_KEY`
|
||||||
|
- If different, update one to match the other
|
||||||
|
|
||||||
|
**Option C: Hardcode in N8N (not recommended)**
|
||||||
|
|
||||||
|
If you can't sync the values, you could hardcode in N8N:
|
||||||
|
```
|
||||||
|
x-api-key: LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
But this is less secure - better to use environment variable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Test 1: Check Server Environment
|
||||||
|
|
||||||
|
**Create test endpoint**:
|
||||||
|
```typescript
|
||||||
|
// app/api/test-n8n-key/route.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
hasN8NApiKey: !!process.env.N8N_API_KEY,
|
||||||
|
keyLength: process.env.N8N_API_KEY?.length || 0,
|
||||||
|
keyPrefix: process.env.N8N_API_KEY ? process.env.N8N_API_KEY.substring(0, 4) + '...' : 'none'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visit**: `http://localhost:3000/api/test-n8n-key`
|
||||||
|
|
||||||
|
**Expected**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hasN8NApiKey": true,
|
||||||
|
"keyLength": 32,
|
||||||
|
"keyPrefix": "Lwge..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Check What N8N Sends
|
||||||
|
|
||||||
|
**In N8N execution logs**, check the "Save Mission To API" node:
|
||||||
|
- Look at the request headers
|
||||||
|
- Find `x-api-key` header
|
||||||
|
- Note the value
|
||||||
|
|
||||||
|
**Compare** with server's `N8N_API_KEY` - they must match exactly.
|
||||||
|
|
||||||
|
### Test 3: Manual Test
|
||||||
|
|
||||||
|
**Test the endpoint with the correct key**:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hub.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: LwgeE1ntADD20OuWC88S3pR0EaO7FtO4" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "test-id",
|
||||||
|
"name": "Test",
|
||||||
|
"creatorId": "user-id"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**: 200 OK (if mission exists) or 404 (if mission doesn't exist)
|
||||||
|
|
||||||
|
**If 401**: The key in the curl command doesn't match server's `N8N_API_KEY`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Common Issues
|
||||||
|
|
||||||
|
### Issue 1: Key is undefined when sending to N8N
|
||||||
|
|
||||||
|
**Symptom**: N8N receives `undefined` or empty string in `config.N8N_API_KEY`
|
||||||
|
|
||||||
|
**Cause**: `process.env.N8N_API_KEY` is not set when creating mission
|
||||||
|
|
||||||
|
**Fix**: Add `N8N_API_KEY` to environment and restart
|
||||||
|
|
||||||
|
### Issue 2: Different keys in different environments
|
||||||
|
|
||||||
|
**Symptom**: Works in development but not production (or vice versa)
|
||||||
|
|
||||||
|
**Cause**: Different `N8N_API_KEY` values in different environments
|
||||||
|
|
||||||
|
**Fix**: Use the same key in all environments, or update N8N to use environment-specific keys
|
||||||
|
|
||||||
|
### Issue 3: Key has extra spaces or characters
|
||||||
|
|
||||||
|
**Symptom**: Keys look the same but don't match
|
||||||
|
|
||||||
|
**Cause**: Extra spaces, newlines, or special characters
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```env
|
||||||
|
# Correct
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
|
||||||
|
# Wrong (with quotes)
|
||||||
|
N8N_API_KEY="LwgeE1ntADD20OuWC88S3pR0EaO7FtO4"
|
||||||
|
|
||||||
|
# Wrong (with spaces)
|
||||||
|
N8N_API_KEY = LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Debugging Checklist
|
||||||
|
|
||||||
|
- [ ] `N8N_API_KEY` is set in server environment
|
||||||
|
- [ ] Key value matches what N8N is sending
|
||||||
|
- [ ] No extra spaces or characters in key
|
||||||
|
- [ ] Server has been restarted after adding key
|
||||||
|
- [ ] Test endpoint shows key is loaded
|
||||||
|
- [ ] N8N execution logs show correct key in header
|
||||||
|
- [ ] Manual curl test works with the key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Expected Flow After Fix
|
||||||
|
|
||||||
|
1. **Mission created** ✅
|
||||||
|
2. **N8N workflow triggered** ✅
|
||||||
|
3. **Server sends `config.N8N_API_KEY` to N8N** ✅
|
||||||
|
4. **N8N creates integrations** ✅
|
||||||
|
5. **N8N calls `/api/missions/mission-created`** ✅
|
||||||
|
6. **N8N sends `x-api-key` header with same value** ✅
|
||||||
|
7. **Server validates key matches** ✅
|
||||||
|
8. **IDs saved to database** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary
|
||||||
|
|
||||||
|
**Problem**: 401 Unauthorized - API key mismatch
|
||||||
|
|
||||||
|
**Root Cause**: The API key sent by N8N doesn't match the server's `N8N_API_KEY`
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Ensure `N8N_API_KEY` is set in server environment
|
||||||
|
2. Ensure N8N uses the same key value
|
||||||
|
3. Verify keys match exactly (no spaces, same value)
|
||||||
|
|
||||||
|
**After Fix**: The endpoint should return 200 OK and save integration IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Priority**: CRITICAL - Blocks integration IDs from being saved
|
||||||
|
|
||||||
245
N8N_API_KEY_MISSING_FIX.md
Normal file
245
N8N_API_KEY_MISSING_FIX.md
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
# N8N_API_KEY Missing - Server Configuration Error
|
||||||
|
|
||||||
|
## 🔍 Problem Identified
|
||||||
|
|
||||||
|
**Error**: `500 - "Server configuration error"`
|
||||||
|
|
||||||
|
**Cause**: `N8N_API_KEY` is **NOT set** in the server's environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution: Add N8N_API_KEY to Environment Variables
|
||||||
|
|
||||||
|
### The Error
|
||||||
|
|
||||||
|
Looking at `app/api/missions/mission-created/route.ts` (lines 34-39):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!expectedApiKey) {
|
||||||
|
logger.error('N8N_API_KEY not configured in environment');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server configuration error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**This error means**: `process.env.N8N_API_KEY` is `undefined` or empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 How to Fix
|
||||||
|
|
||||||
|
### Step 1: Determine Your Environment
|
||||||
|
|
||||||
|
**Are you running**:
|
||||||
|
- Local development?
|
||||||
|
- Production server?
|
||||||
|
- Docker container?
|
||||||
|
- Vercel/other hosting?
|
||||||
|
|
||||||
|
### Step 2: Add N8N_API_KEY
|
||||||
|
|
||||||
|
#### Option A: Local Development (`.env.local`)
|
||||||
|
|
||||||
|
**Create or edit `.env.local` file** in your project root:
|
||||||
|
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then restart your development server**:
|
||||||
|
```bash
|
||||||
|
# Stop the server (Ctrl+C)
|
||||||
|
# Restart
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Production Server
|
||||||
|
|
||||||
|
**If using Docker**:
|
||||||
|
Add to `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or in `.env` file** (if using docker-compose with env_file):
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
**If using CapRover**:
|
||||||
|
1. Go to App Settings
|
||||||
|
2. App Configs → Environment Variables
|
||||||
|
3. Add: `N8N_API_KEY` = `LwgeE1ntADD20OuWC88S3pR0EaO7FtO4`
|
||||||
|
4. Save and restart the app
|
||||||
|
|
||||||
|
**If using Vercel**:
|
||||||
|
1. Go to Project Settings
|
||||||
|
2. Environment Variables
|
||||||
|
3. Add: `N8N_API_KEY` = `LwgeE1ntADD20OuWC88S3pR0EaO7FtO4`
|
||||||
|
4. Redeploy
|
||||||
|
|
||||||
|
**If using other hosting**:
|
||||||
|
- Add `N8N_API_KEY` to your hosting platform's environment variables
|
||||||
|
- Restart/redeploy the application
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Verification
|
||||||
|
|
||||||
|
### Step 1: Check if Variable is Set
|
||||||
|
|
||||||
|
**Create a test endpoint** to verify:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/test-env/route.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
hasN8NApiKey: !!process.env.N8N_API_KEY,
|
||||||
|
n8nApiKeyLength: process.env.N8N_API_KEY?.length || 0,
|
||||||
|
// Don't expose the actual key!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then visit**: `http://localhost:3000/api/test-env` (or your production URL)
|
||||||
|
|
||||||
|
**Expected**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hasN8NApiKey": true,
|
||||||
|
"n8nApiKeyLength": 32
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Test the Endpoint Manually
|
||||||
|
|
||||||
|
**After adding the variable and restarting**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hub.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: LwgeE1ntADD20OuWC88S3pR0EaO7FtO4" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "test-mission-id",
|
||||||
|
"name": "Test Mission",
|
||||||
|
"creatorId": "test-user-id"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**:
|
||||||
|
- ✅ **200 OK** with JSON response (if mission exists)
|
||||||
|
- ❌ **500 error** if `N8N_API_KEY` is still not set
|
||||||
|
- ❌ **401 error** if API key doesn't match
|
||||||
|
|
||||||
|
### Step 3: Check Server Logs
|
||||||
|
|
||||||
|
**After adding the variable**, check your server logs. You should **NOT** see:
|
||||||
|
```
|
||||||
|
N8N_API_KEY not configured in environment
|
||||||
|
```
|
||||||
|
|
||||||
|
**You SHOULD see** (when endpoint is called):
|
||||||
|
```
|
||||||
|
Mission Created Webhook Received
|
||||||
|
Received mission-created data: { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### Issue 1: Variable Not Loading
|
||||||
|
|
||||||
|
**Symptom**: Still getting 500 error after adding variable
|
||||||
|
|
||||||
|
**Possible causes**:
|
||||||
|
1. **Wrong file**: Using `.env` instead of `.env.local` (Next.js prefers `.env.local`)
|
||||||
|
2. **Not restarted**: Server needs restart after adding env variable
|
||||||
|
3. **Wrong location**: `.env.local` must be in project root (same level as `package.json`)
|
||||||
|
4. **Syntax error**: Check for quotes, spaces, or special characters
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
```env
|
||||||
|
# Correct
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
|
||||||
|
# Wrong (with quotes)
|
||||||
|
N8N_API_KEY="LwgeE1ntADD20OuWC88S3pR0EaO7FtO4"
|
||||||
|
|
||||||
|
# Wrong (with spaces)
|
||||||
|
N8N_API_KEY = LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Different Key in N8N
|
||||||
|
|
||||||
|
**Symptom**: 401 Unauthorized error
|
||||||
|
|
||||||
|
**Cause**: The API key in N8N workflow doesn't match the one in environment
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- Use the same key in both places
|
||||||
|
- Or update N8N workflow to use the key from environment
|
||||||
|
|
||||||
|
### Issue 3: Production vs Development
|
||||||
|
|
||||||
|
**Symptom**: Works locally but not in production
|
||||||
|
|
||||||
|
**Cause**: Environment variable only set in development
|
||||||
|
|
||||||
|
**Fix**: Add the variable to production environment as well
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Complete Checklist
|
||||||
|
|
||||||
|
- [ ] `N8N_API_KEY` added to `.env.local` (development) or production environment
|
||||||
|
- [ ] Variable has correct value (no quotes, no spaces)
|
||||||
|
- [ ] Application restarted after adding variable
|
||||||
|
- [ ] Test endpoint shows `hasN8NApiKey: true`
|
||||||
|
- [ ] Manual curl test returns 200 (not 500)
|
||||||
|
- [ ] Server logs show "Mission Created Webhook Received" (not "N8N_API_KEY not configured")
|
||||||
|
- [ ] N8N workflow uses same API key in header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Expected Flow After Fix
|
||||||
|
|
||||||
|
1. **Mission created** ✅
|
||||||
|
2. **N8N workflow triggered** ✅
|
||||||
|
3. **N8N creates integrations** ✅
|
||||||
|
4. **N8N calls `/api/missions/mission-created`** ✅
|
||||||
|
5. **Endpoint receives request** ✅
|
||||||
|
6. **API key validated** ✅
|
||||||
|
7. **IDs saved to database** ✅
|
||||||
|
8. **Mission has integration IDs** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary
|
||||||
|
|
||||||
|
**Problem**: 500 "Server configuration error"
|
||||||
|
|
||||||
|
**Root Cause**: `N8N_API_KEY` environment variable is not set
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Add `N8N_API_KEY` to environment variables
|
||||||
|
2. Use the same key value that N8N is sending in the `x-api-key` header
|
||||||
|
3. Restart the application
|
||||||
|
4. Test the endpoint
|
||||||
|
|
||||||
|
**After Fix**: The endpoint should return 200 OK and save integration IDs to the database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Priority**: CRITICAL - Blocks integration IDs from being saved
|
||||||
|
|
||||||
170
N8N_API_KEY_SOLUTION.md
Normal file
170
N8N_API_KEY_SOLUTION.md
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
# Solution: N8N API Key Mismatch
|
||||||
|
|
||||||
|
## 🔍 Problème
|
||||||
|
|
||||||
|
**Avant** : Vous pouviez créer des missions sans `N8N_API_KEY`
|
||||||
|
- Mission créée ✅
|
||||||
|
- N8N callback échouait silencieusement ❌
|
||||||
|
- Mission restait en base sans IDs ❌
|
||||||
|
|
||||||
|
**Maintenant** : Avec `N8N_API_KEY` ajouté
|
||||||
|
- Mission créée ✅
|
||||||
|
- N8N callback appelé ✅
|
||||||
|
- **Mais clé API ne correspond pas → 401 → Mission création échoue** ❌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution 1: Utiliser la Même Clé (RECOMMANDÉ)
|
||||||
|
|
||||||
|
### Étape 1: Trouver la Clé Générée par N8N
|
||||||
|
|
||||||
|
**Dans N8N** :
|
||||||
|
1. Allez dans les paramètres de votre workflow
|
||||||
|
2. Trouvez la clé API que N8N utilise
|
||||||
|
3. Ou regardez dans les logs d'exécution N8N pour voir quelle clé est envoyée
|
||||||
|
|
||||||
|
### Étape 2: Utiliser Cette Clé sur le Serveur
|
||||||
|
|
||||||
|
**Ajoutez la même clé dans votre environnement** :
|
||||||
|
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=la-cle-generee-par-n8n
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important** : Utilisez **exactement la même clé** que celle générée par N8N.
|
||||||
|
|
||||||
|
### Étape 3: Redémarrer le Serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redémarrer l'application
|
||||||
|
npm run dev
|
||||||
|
# ou
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution 2: Rendre la Vérification Plus Flexible (TEMPORAIRE)
|
||||||
|
|
||||||
|
Si vous voulez permettre la création de mission même si les clés ne correspondent pas :
|
||||||
|
|
||||||
|
**Modifier `app/api/missions/mission-created/route.ts`** :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vérifier l'API key
|
||||||
|
const apiKey = request.headers.get('x-api-key');
|
||||||
|
const expectedApiKey = process.env.N8N_API_KEY;
|
||||||
|
|
||||||
|
// Si pas de clé configurée, accepter (mais logger un warning)
|
||||||
|
if (!expectedApiKey) {
|
||||||
|
logger.warn('N8N_API_KEY not configured, accepting request without validation');
|
||||||
|
// Continue without validation
|
||||||
|
} else if (apiKey && apiKey !== expectedApiKey) {
|
||||||
|
logger.error('Invalid API key', {
|
||||||
|
received: apiKey ? 'present' : 'missing',
|
||||||
|
expected: expectedApiKey ? 'configured' : 'missing'
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
} else if (!apiKey && expectedApiKey) {
|
||||||
|
logger.warn('API key expected but not provided, accepting anyway');
|
||||||
|
// Continue without validation (less secure but works)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Note** : Cette solution est moins sécurisée mais permet de continuer à fonctionner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution 3: Utiliser la Clé du Serveur dans N8N
|
||||||
|
|
||||||
|
**Au lieu d'utiliser la clé générée par N8N**, utilisez celle du serveur :
|
||||||
|
|
||||||
|
### Dans N8N "Save Mission To API" Node
|
||||||
|
|
||||||
|
**Header `x-api-key`** :
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cette valeur vient de** :
|
||||||
|
- `config.N8N_API_KEY` envoyé par le serveur (ligne 420)
|
||||||
|
- Qui vient de `process.env.N8N_API_KEY`
|
||||||
|
|
||||||
|
**Donc** : Si vous mettez la même clé dans `process.env.N8N_API_KEY`, N8N l'utilisera automatiquement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Solution Recommandée
|
||||||
|
|
||||||
|
**Utiliser la clé générée par N8N dans l'environnement du serveur** :
|
||||||
|
|
||||||
|
1. **Copier la clé générée par N8N**
|
||||||
|
2. **L'ajouter dans `.env.local`** (ou variables d'environnement production) :
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=votre-cle-generee-par-n8n
|
||||||
|
```
|
||||||
|
3. **Redémarrer le serveur**
|
||||||
|
4. **Tester la création de mission**
|
||||||
|
|
||||||
|
**Avantage** :
|
||||||
|
- ✅ Sécurisé (vérification de clé)
|
||||||
|
- ✅ Fonctionne correctement
|
||||||
|
- ✅ IDs sauvegardés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Comment Trouver la Clé N8N
|
||||||
|
|
||||||
|
### Option 1: Dans N8N Workflow
|
||||||
|
|
||||||
|
1. Ouvrez le workflow N8N
|
||||||
|
2. Regardez le node "Save Mission To API"
|
||||||
|
3. Vérifiez la valeur de `x-api-key` header
|
||||||
|
4. Ou regardez dans `config.N8N_API_KEY` dans "Process Mission Data"
|
||||||
|
|
||||||
|
### Option 2: Dans N8N Execution Logs
|
||||||
|
|
||||||
|
1. Allez dans N8N → Executions
|
||||||
|
2. Trouvez une exécution récente
|
||||||
|
3. Regardez le node "Save Mission To API"
|
||||||
|
4. Vérifiez les headers de la requête
|
||||||
|
5. Trouvez la valeur de `x-api-key`
|
||||||
|
|
||||||
|
### Option 3: Générer une Nouvelle Clé
|
||||||
|
|
||||||
|
**Si vous ne trouvez pas la clé**, vous pouvez :
|
||||||
|
1. Générer une nouvelle clé (ex: `openssl rand -hex 16`)
|
||||||
|
2. L'ajouter dans l'environnement du serveur
|
||||||
|
3. L'utiliser dans N8N workflow (hardcoder temporairement)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checklist
|
||||||
|
|
||||||
|
- [ ] Trouver la clé API générée par N8N
|
||||||
|
- [ ] Ajouter cette clé dans `N8N_API_KEY` environnement serveur
|
||||||
|
- [ ] Vérifier que N8N utilise `{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}`
|
||||||
|
- [ ] Redémarrer le serveur
|
||||||
|
- [ ] Tester création de mission
|
||||||
|
- [ ] Vérifier que les IDs sont sauvegardés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Résumé
|
||||||
|
|
||||||
|
**Problème** : Clé API N8N ≠ Clé API serveur → 401 Unauthorized
|
||||||
|
|
||||||
|
**Solution** : Utiliser la **même clé** dans les deux endroits :
|
||||||
|
1. Environnement serveur : `N8N_API_KEY=cle-commune`
|
||||||
|
2. N8N workflow : Utilise automatiquement via `config.N8N_API_KEY`
|
||||||
|
|
||||||
|
**Après fix** : Mission création fonctionne et IDs sont sauvegardés ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Priority**: HIGH - Blocks mission creation
|
||||||
|
|
||||||
292
N8N_CONFIGURATION_FIX.md
Normal file
292
N8N_CONFIGURATION_FIX.md
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
# N8N Configuration Fix - Environment Variables & Webhook Activation
|
||||||
|
|
||||||
|
## 🔍 Problems Identified
|
||||||
|
|
||||||
|
Based on your error logs, there are **THREE critical issues**:
|
||||||
|
|
||||||
|
1. ❌ **N8N_API_KEY is not set in environment variables**
|
||||||
|
2. ❌ **404 Error**: Webhook "mission-created" is not registered (workflow not active)
|
||||||
|
3. ❌ **500 Error**: "Error in workflow" (workflow is running but failing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Fix 1: Set N8N_API_KEY Environment Variable
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
```
|
||||||
|
N8N_API_KEY is not set in environment variables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
|
||||||
|
**Add to your `.env` or `.env.local` file**:
|
||||||
|
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or if using a different key**, use your actual N8N API key.
|
||||||
|
|
||||||
|
### Where to Add
|
||||||
|
|
||||||
|
1. **Local Development** (`.env.local`):
|
||||||
|
```env
|
||||||
|
N8N_API_KEY=your-actual-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Production** (environment variables in your hosting platform):
|
||||||
|
- Vercel: Settings → Environment Variables
|
||||||
|
- Docker: `docker-compose.yml` or `.env` file
|
||||||
|
- CapRover: App Settings → App Configs → Environment Variables
|
||||||
|
|
||||||
|
### Verify It's Set
|
||||||
|
|
||||||
|
After adding, restart your application and check logs. You should **NOT** see:
|
||||||
|
```
|
||||||
|
N8N_API_KEY is not set in environment variables
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Fix 2: Activate N8N Workflow
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
```
|
||||||
|
404 Error: The requested webhook "mission-created" is not registered.
|
||||||
|
Hint: Click the 'Execute workflow' button on the canvas, then try again.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
|
||||||
|
**In N8N Interface**:
|
||||||
|
|
||||||
|
1. **Open your workflow** in N8N (the one with the webhook node)
|
||||||
|
2. **Click "Active" toggle** in the top right to activate the workflow
|
||||||
|
- The toggle should be **GREEN/ON** ✅
|
||||||
|
- If it's gray/off, click it to activate
|
||||||
|
|
||||||
|
3. **Verify the webhook node**:
|
||||||
|
- The webhook node should show as "Active"
|
||||||
|
- The webhook path should be: `mission-created`
|
||||||
|
- The full URL should be: `https://brain.slm-lab.net/webhook/mission-created`
|
||||||
|
|
||||||
|
### Alternative: Test Mode
|
||||||
|
|
||||||
|
If you're testing:
|
||||||
|
1. Click **"Execute Workflow"** button on the canvas
|
||||||
|
2. This activates the webhook for **one test call**
|
||||||
|
3. After the test, activate the workflow permanently
|
||||||
|
|
||||||
|
### Verify Webhook is Active
|
||||||
|
|
||||||
|
**Test the webhook URL**:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://brain.slm-lab.net/webhook/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"test": "data"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**:
|
||||||
|
- If active: Should trigger the workflow (may return error if data is invalid, but should not be 404)
|
||||||
|
- If not active: Returns 404 with message about webhook not registered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Fix 3: Fix Workflow Errors (500 Error)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
```
|
||||||
|
500 Error: {"message":"Error in workflow"}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the workflow is running but encountering an error. Common causes:
|
||||||
|
|
||||||
|
### Common Issues & Fixes
|
||||||
|
|
||||||
|
#### Issue 3.1: Missing missionId in Process Mission Data
|
||||||
|
|
||||||
|
**Check**: The "Process Mission Data" node should include `missionId` in its output.
|
||||||
|
|
||||||
|
**Fix**: Ensure the node includes:
|
||||||
|
```javascript
|
||||||
|
missionId: missionData?.missionId || missionData?.body?.missionId
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Issue 3.2: Incorrect URL in Save Mission To API Node
|
||||||
|
|
||||||
|
**Check**: The "Save Mission To API" node URL should be:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOT**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Issue 3.3: Missing missionId in Save Mission To API Body
|
||||||
|
|
||||||
|
**Check**: The "Save Mission To API" node body should include:
|
||||||
|
- Parameter: `missionId`
|
||||||
|
- Value: `{{ $node['Process Mission Data'].json.missionId }}`
|
||||||
|
|
||||||
|
#### Issue 3.4: API Key Mismatch
|
||||||
|
|
||||||
|
**Check**: The API key in the "Save Mission To API" node header should match your `N8N_API_KEY` environment variable.
|
||||||
|
|
||||||
|
**Fix**: Use:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug Workflow Errors
|
||||||
|
|
||||||
|
1. **Check N8N Execution Logs**:
|
||||||
|
- Go to N8N → Executions
|
||||||
|
- Find the failed execution
|
||||||
|
- Click on it to see which node failed
|
||||||
|
- Check the error message
|
||||||
|
|
||||||
|
2. **Test Each Node Individually**:
|
||||||
|
- Execute the workflow step by step
|
||||||
|
- Check each node's output
|
||||||
|
- Verify data is flowing correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Complete Checklist
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- [ ] `N8N_API_KEY` is set in `.env.local` or production environment
|
||||||
|
- [ ] Value matches the API key used in N8N workflow
|
||||||
|
- [ ] Application has been restarted after adding the variable
|
||||||
|
|
||||||
|
### N8N Workflow Configuration
|
||||||
|
- [ ] Workflow is **ACTIVE** (green toggle in N8N)
|
||||||
|
- [ ] Webhook path is: `mission-created`
|
||||||
|
- [ ] Webhook URL is: `https://brain.slm-lab.net/webhook/mission-created`
|
||||||
|
- [ ] "Process Mission Data" node includes `missionId` in output
|
||||||
|
- [ ] "Save Mission To API" node URL is correct: `{{ MISSION_API_URL }}/api/missions/mission-created`
|
||||||
|
- [ ] "Save Mission To API" node includes `missionId` in body parameters
|
||||||
|
- [ ] "Save Mission To API" node includes `x-api-key` header with correct value
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Test webhook URL returns 200 (not 404)
|
||||||
|
- [ ] Create a test mission
|
||||||
|
- [ ] Check N8N execution logs for errors
|
||||||
|
- [ ] Verify mission IDs are saved to database after creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Step-by-Step Testing
|
||||||
|
|
||||||
|
### Step 1: Verify Environment Variable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your terminal (if running locally)
|
||||||
|
echo $N8N_API_KEY
|
||||||
|
|
||||||
|
# Or check in your application logs
|
||||||
|
# Should NOT see: "N8N_API_KEY is not set in environment variables"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Test Webhook is Active
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://brain.slm-lab.net/webhook/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"test": "data"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- ✅ **200/400/500 with workflow error**: Webhook is active (workflow may fail due to invalid data, but webhook is registered)
|
||||||
|
- ❌ **404 with "webhook not registered"**: Webhook is NOT active → Activate workflow in N8N
|
||||||
|
|
||||||
|
### Step 3: Test Mission Creation
|
||||||
|
|
||||||
|
1. Create a mission via your frontend
|
||||||
|
2. Check server logs - should NOT see:
|
||||||
|
- ❌ "N8N_API_KEY is not set"
|
||||||
|
- ❌ "404 webhook not registered"
|
||||||
|
3. Check N8N execution logs - should see successful execution
|
||||||
|
4. Check database - mission should have integration IDs saved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Quick Fix Commands
|
||||||
|
|
||||||
|
### Add N8N_API_KEY to .env.local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to .env.local file
|
||||||
|
echo "N8N_API_KEY=LwgeE1ntADD20OuWC88S3pR0EaO7FtO4" >> .env.local
|
||||||
|
|
||||||
|
# Restart your development server
|
||||||
|
# npm run dev
|
||||||
|
# or
|
||||||
|
# yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Environment Variable is Loaded
|
||||||
|
|
||||||
|
Create a test endpoint to verify:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/test-n8n-config/route.ts
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
hasN8NApiKey: !!process.env.N8N_API_KEY,
|
||||||
|
n8nWebhookUrl: process.env.N8N_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/mission-created',
|
||||||
|
missionApiUrl: process.env.NEXT_PUBLIC_API_URL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit: `http://localhost:3000/api/test-n8n-config`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary of Fixes
|
||||||
|
|
||||||
|
1. **Add `N8N_API_KEY` to environment variables**
|
||||||
|
- File: `.env.local` (development) or production environment
|
||||||
|
- Value: Your actual N8N API key
|
||||||
|
- Restart application after adding
|
||||||
|
|
||||||
|
2. **Activate N8N Workflow**
|
||||||
|
- Open workflow in N8N
|
||||||
|
- Click "Active" toggle (should be green/on)
|
||||||
|
- Verify webhook is registered
|
||||||
|
|
||||||
|
3. **Fix Workflow Configuration**
|
||||||
|
- Ensure "Save Mission To API" URL is correct
|
||||||
|
- Ensure `missionId` is included in body
|
||||||
|
- Check N8N execution logs for specific errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 If Still Not Working
|
||||||
|
|
||||||
|
### Check N8N Execution Logs
|
||||||
|
|
||||||
|
1. Go to N8N → Executions
|
||||||
|
2. Find the latest failed execution
|
||||||
|
3. Click on it
|
||||||
|
4. Check which node failed
|
||||||
|
5. Look at the error message
|
||||||
|
6. Fix the specific issue
|
||||||
|
|
||||||
|
### Common Additional Issues
|
||||||
|
|
||||||
|
- **Network connectivity**: N8N can't reach your API
|
||||||
|
- **CORS issues**: If calling from browser
|
||||||
|
- **Authentication**: API key mismatch
|
||||||
|
- **Data format**: Body parameters don't match expected format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Priority**: CRITICAL - Blocks mission creation
|
||||||
|
|
||||||
267
N8N_SAVE_MISSION_API_FIX.md
Normal file
267
N8N_SAVE_MISSION_API_FIX.md
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
# N8N Save Mission To API Node - Fix Required
|
||||||
|
|
||||||
|
## 🔍 Problem Analysis
|
||||||
|
|
||||||
|
Based on the N8N workflow configuration you provided, I've identified **TWO CRITICAL ISSUES**:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Issue 1: Incorrect URL
|
||||||
|
|
||||||
|
### Current Configuration
|
||||||
|
```
|
||||||
|
URL: {{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### What This Produces
|
||||||
|
- `MISSION_API_URL` = `https://hub.slm-lab.net` (from your config)
|
||||||
|
- Result: `https://hub.slm-lab.net/mission-created` ❌
|
||||||
|
|
||||||
|
### Actual Endpoint
|
||||||
|
- Should be: `https://hub.slm-lab.net/api/missions/mission-created` ✅
|
||||||
|
|
||||||
|
### Fix Required
|
||||||
|
```
|
||||||
|
URL: {{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Remove the `+` operator and add `/api/missions` before `/mission-created`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Issue 2: Missing `missionId` in Body
|
||||||
|
|
||||||
|
### Current Configuration
|
||||||
|
Looking at your `base.json`, I can see the body parameters, but **`missionId` is MISSING**!
|
||||||
|
|
||||||
|
### What the Endpoint Expects
|
||||||
|
From `app/api/missions/mission-created/route.ts`:
|
||||||
|
- `missionId` ⚠️ **REQUIRED** - Used to find the mission (preferred over name + creatorId)
|
||||||
|
- `gitRepoUrl` → maps to `giteaRepositoryUrl` in database
|
||||||
|
- `leantimeProjectId` → maps to `leantimeProjectId` in database
|
||||||
|
- `documentationCollectionId` → maps to `outlineCollectionId` in database
|
||||||
|
- `rocketchatChannelId` → maps to `rocketChatChannelId` in database
|
||||||
|
- `creatorId` ✅ (you have this)
|
||||||
|
- `name` ✅ (you have this)
|
||||||
|
|
||||||
|
### What the Endpoint Expects
|
||||||
|
From `app/api/missions/mission-created/route.ts`:
|
||||||
|
- `gitRepoUrl` → maps to `giteaRepositoryUrl` in database
|
||||||
|
- `leantimeProjectId` → maps to `leantimeProjectId` in database
|
||||||
|
- `documentationCollectionId` → maps to `outlineCollectionId` in database
|
||||||
|
- `rocketchatChannelId` → maps to `rocketChatChannelId` in database
|
||||||
|
- `missionId` ✅ (you have this)
|
||||||
|
- `creatorId` ✅ (you have this)
|
||||||
|
- `name` ✅ (you have this)
|
||||||
|
|
||||||
|
### What N8N Should Send
|
||||||
|
|
||||||
|
**Body Parameters** (in N8N HTTP Request node):
|
||||||
|
|
||||||
|
| Field Name | Value Expression |
|
||||||
|
|------------|------------------|
|
||||||
|
| `name` | `{{ $node['Process Mission Data'].json.missionProcessed.name }}` |
|
||||||
|
| `niveau` | `{{ $node['Process Mission Data'].json.missionProcessed.niveau || 'default' }}` |
|
||||||
|
| `intention` | `{{ $node['Process Mission Data'].json.missionProcessed.intention }}` |
|
||||||
|
| `gitRepoUrl` | `{{ $node['Combine Results'].json.gitRepo?.html_url || '' }}` |
|
||||||
|
| `leantimeProjectId` | `{{ $node['Combine Results'].json.leantimeProject?.result?.[0] || '' }}` |
|
||||||
|
| `documentationCollectionId` | `{{ $node['Combine Results'].json.docCollection?.data?.id || '' }}` |
|
||||||
|
| `rocketchatChannelId` | `{{ $node['Combine Results'].json.rocketChatChannel?.channel?._id || '' }}` |
|
||||||
|
| `missionId` | `{{ $node['Process Mission Data'].json.missionId }}` |
|
||||||
|
| `creatorId` | `{{ $node['Process Mission Data'].json.creatorId }}` |
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: The field names must match exactly:
|
||||||
|
- `gitRepoUrl` (not `gitRepo` or `giteaRepositoryUrl`)
|
||||||
|
- `leantimeProjectId` (not `leantimeProject` or `leantimeId`)
|
||||||
|
- `documentationCollectionId` (not `docCollection` or `outlineCollectionId`)
|
||||||
|
- `rocketchatChannelId` (not `rocketChatChannel` or `rocketChatChannelId`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Complete Fix for N8N Node
|
||||||
|
|
||||||
|
### Step 1: Fix the URL
|
||||||
|
|
||||||
|
In the "Save Mission To API" HTTP Request node:
|
||||||
|
|
||||||
|
**Current (WRONG)**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed (CORRECT)**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure Body Parameters
|
||||||
|
|
||||||
|
In the "Save Mission To API" HTTP Request node, set **Body Parameters**:
|
||||||
|
|
||||||
|
**Method**: `POST`
|
||||||
|
**Send Body**: `Yes`
|
||||||
|
**Body Content Type**: `JSON` (or use Body Parameters)
|
||||||
|
|
||||||
|
**Body Parameters** (add each as a parameter):
|
||||||
|
|
||||||
|
1. **Parameter Name**: `name`
|
||||||
|
**Value**: `{{ $node['Process Mission Data'].json.missionProcessed.name }}`
|
||||||
|
|
||||||
|
2. **Parameter Name**: `niveau`
|
||||||
|
**Value**: `{{ $node['Process Mission Data'].json.missionProcessed.niveau || 'default' }}`
|
||||||
|
|
||||||
|
3. **Parameter Name**: `intention`
|
||||||
|
**Value**: `{{ $node['Process Mission Data'].json.missionProcessed.intention }}`
|
||||||
|
|
||||||
|
4. **Parameter Name**: `gitRepoUrl` ⚠️ (MUST be this exact name)
|
||||||
|
**Value**: `{{ $node['Combine Results'].json.gitRepo?.html_url || '' }}`
|
||||||
|
|
||||||
|
5. **Parameter Name**: `leantimeProjectId` ⚠️ (MUST be this exact name)
|
||||||
|
**Value**: `{{ $node['Combine Results'].json.leantimeProject?.result?.[0] || '' }}`
|
||||||
|
|
||||||
|
6. **Parameter Name**: `documentationCollectionId` ⚠️ (MUST be this exact name)
|
||||||
|
**Value**: `{{ $node['Combine Results'].json.docCollection?.data?.id || '' }}`
|
||||||
|
|
||||||
|
7. **Parameter Name**: `rocketchatChannelId` ⚠️ (MUST be this exact name)
|
||||||
|
**Value**: `{{ $node['Combine Results'].json.rocketChatChannel?.channel?._id || '' }}`
|
||||||
|
|
||||||
|
8. **Parameter Name**: `missionId` ⚠️ **MISSING - MUST ADD THIS**
|
||||||
|
**Value**: `{{ $node['Process Mission Data'].json.missionId }}`
|
||||||
|
|
||||||
|
9. **Parameter Name**: `creatorId`
|
||||||
|
**Value**: `{{ $node['Process Mission Data'].json.creatorId }}`
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: The `missionId` field is **MISSING** from your current configuration. The endpoint prefers `missionId` over `name + creatorId` for more reliable mission lookup.
|
||||||
|
|
||||||
|
### Step 3: Verify Headers
|
||||||
|
|
||||||
|
**Headers** should include:
|
||||||
|
- `Content-Type`: `application/json`
|
||||||
|
- `x-api-key`: `{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing the Fix
|
||||||
|
|
||||||
|
### Test 1: Check URL
|
||||||
|
|
||||||
|
After fixing, the URL should resolve to:
|
||||||
|
```
|
||||||
|
https://hub.slm-lab.net/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Check Request Body
|
||||||
|
|
||||||
|
After fixing, the request body should look like:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Mission Name",
|
||||||
|
"niveau": "default",
|
||||||
|
"intention": "Mission description",
|
||||||
|
"gitRepoUrl": "https://gite.slm-lab.net/alma/repo-name",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"documentationCollectionId": "collection-id",
|
||||||
|
"rocketchatChannelId": "channel-id",
|
||||||
|
"missionId": "mission-uuid",
|
||||||
|
"creatorId": "user-uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Check Server Response
|
||||||
|
|
||||||
|
The endpoint should return:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Mission updated successfully",
|
||||||
|
"mission": {
|
||||||
|
"id": "mission-uuid",
|
||||||
|
"name": "Mission Name",
|
||||||
|
"giteaRepositoryUrl": "https://gite.slm-lab.net/alma/repo-name",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"outlineCollectionId": "collection-id",
|
||||||
|
"rocketChatChannelId": "channel-id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Verification Checklist
|
||||||
|
|
||||||
|
After applying the fix:
|
||||||
|
|
||||||
|
- [ ] URL is correct: `{{ MISSION_API_URL }}/api/missions/mission-created`
|
||||||
|
- [ ] Body includes `gitRepoUrl` field (not `gitRepo` or `giteaRepositoryUrl`)
|
||||||
|
- [ ] Body includes `leantimeProjectId` field (not `leantimeProject` or `leantimeId`)
|
||||||
|
- [ ] Body includes `documentationCollectionId` field (not `docCollection` or `outlineCollectionId`)
|
||||||
|
- [ ] Body includes `rocketchatChannelId` field (not `rocketChatChannel`)
|
||||||
|
- [ ] Body includes `missionId` field
|
||||||
|
- [ ] Body includes `creatorId` field
|
||||||
|
- [ ] Headers include `x-api-key`
|
||||||
|
- [ ] Headers include `Content-Type: application/json`
|
||||||
|
- [ ] Test execution shows 200 OK response
|
||||||
|
- [ ] Database shows IDs saved after mission creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Debugging
|
||||||
|
|
||||||
|
### If Still Not Working
|
||||||
|
|
||||||
|
1. **Check N8N Execution Logs**:
|
||||||
|
- Look at "Save Mission To API" node execution
|
||||||
|
- Check the actual URL being called
|
||||||
|
- Check the actual body being sent
|
||||||
|
- Check the response status code
|
||||||
|
|
||||||
|
2. **Check Server Logs**:
|
||||||
|
- Look for `/api/missions/mission-created` endpoint calls
|
||||||
|
- Check for 404 errors (wrong URL)
|
||||||
|
- Check for 400 errors (missing fields)
|
||||||
|
- Check for 401 errors (wrong API key)
|
||||||
|
|
||||||
|
3. **Test Manually**:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://hub.slm-lab.net/api/missions/mission-created \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-api-key: YOUR_N8N_API_KEY" \
|
||||||
|
-d '{
|
||||||
|
"missionId": "test-mission-id",
|
||||||
|
"name": "Test Mission",
|
||||||
|
"creatorId": "test-user-id",
|
||||||
|
"gitRepoUrl": "https://gite.slm-lab.net/alma/test",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"documentationCollectionId": "collection-456",
|
||||||
|
"rocketchatChannelId": "channel-789"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Summary
|
||||||
|
|
||||||
|
**Two critical fixes required**:
|
||||||
|
|
||||||
|
1. **URL Fix**: Change from:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add Missing `missionId` Field**: Add this parameter to the body:
|
||||||
|
- **Name**: `missionId`
|
||||||
|
- **Value**: `{{ $node['Process Mission Data'].json.missionId }}`
|
||||||
|
|
||||||
|
**Note**: Your field names are already correct (`gitRepoUrl`, `leantimeProjectId`, etc.), but `missionId` is missing which is critical for reliable mission lookup.
|
||||||
|
|
||||||
|
After these fixes, the N8N workflow should successfully save integration IDs to the database, and mission deletion should work correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Priority**: CRITICAL - Blocks mission deletion functionality
|
||||||
|
|
||||||
210
N8N_WRONG_URL_FIX.md
Normal file
210
N8N_WRONG_URL_FIX.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
# N8N Wrong URL - Getting HTML Instead of JSON
|
||||||
|
|
||||||
|
## 🔍 Problem Identified
|
||||||
|
|
||||||
|
**N8N "Save Mission To API" node is receiving HTML (404 page) instead of JSON response.**
|
||||||
|
|
||||||
|
### What N8N Receives
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
...
|
||||||
|
<h1>404</h1>
|
||||||
|
<h2>This page could not be found.</h2>
|
||||||
|
...
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is a Next.js 404 page**, not the API endpoint response!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Root Cause
|
||||||
|
|
||||||
|
**The URL in N8N is pointing to a Next.js page route instead of the API endpoint.**
|
||||||
|
|
||||||
|
### Current (WRONG) URL
|
||||||
|
|
||||||
|
N8N is probably calling:
|
||||||
|
```
|
||||||
|
https://hub.slm-lab.net/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches Next.js route: `app/[section]/page.tsx`
|
||||||
|
- Next.js tries to find a page at `/mission-created`
|
||||||
|
- No page exists, so it returns 404 HTML page
|
||||||
|
- N8N receives HTML instead of JSON
|
||||||
|
|
||||||
|
### Correct URL
|
||||||
|
|
||||||
|
Should be:
|
||||||
|
```
|
||||||
|
https://hub.slm-lab.net/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches API route: `app/api/missions/mission-created/route.ts`
|
||||||
|
- Next.js routes to the API endpoint
|
||||||
|
- Returns JSON response
|
||||||
|
- N8N receives proper JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solution
|
||||||
|
|
||||||
|
### Fix the URL in N8N "Save Mission To API" Node
|
||||||
|
|
||||||
|
**Current (WRONG)**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/mission-created' }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed (CORRECT)**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step-by-Step Fix
|
||||||
|
|
||||||
|
1. **Open N8N workflow**
|
||||||
|
2. **Find "Save Mission To API" node**
|
||||||
|
3. **Click on it to edit**
|
||||||
|
4. **In the URL field**, change from:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Save the node**
|
||||||
|
6. **Activate the workflow** (if not already active)
|
||||||
|
7. **Test by creating a new mission**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Verification
|
||||||
|
|
||||||
|
### After Fix, N8N Should Receive
|
||||||
|
|
||||||
|
**Expected JSON Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Mission updated successfully",
|
||||||
|
"mission": {
|
||||||
|
"id": "mission-uuid",
|
||||||
|
"name": "Mission Name",
|
||||||
|
"giteaRepositoryUrl": "https://gite.slm-lab.net/alma/repo-name",
|
||||||
|
"leantimeProjectId": "123",
|
||||||
|
"outlineCollectionId": "collection-456",
|
||||||
|
"rocketChatChannelId": "channel-789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOT HTML**:
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Server Logs
|
||||||
|
|
||||||
|
After fix, you should see:
|
||||||
|
```
|
||||||
|
Mission Created Webhook Received
|
||||||
|
Received mission-created data: { ... }
|
||||||
|
Found mission: { id: "...", name: "..." }
|
||||||
|
Updating giteaRepositoryUrl: ...
|
||||||
|
Mission updated successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Complete URL Configuration
|
||||||
|
|
||||||
|
### In N8N "Save Mission To API" Node
|
||||||
|
|
||||||
|
**URL**:
|
||||||
|
```
|
||||||
|
{{ $node['Process Mission Data'].json.config.MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**Method**: `POST`
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
- `Content-Type`: `application/json`
|
||||||
|
- `x-api-key`: `{{ $node['Process Mission Data'].json.config.N8N_API_KEY }}`
|
||||||
|
|
||||||
|
**Body Parameters**:
|
||||||
|
- `missionId`: `{{ $node['Process Mission Data'].json.missionId }}`
|
||||||
|
- `name`: `{{ $node['Process Mission Data'].json.missionProcessed.name }}`
|
||||||
|
- `creatorId`: `{{ $node['Process Mission Data'].json.creatorId }}`
|
||||||
|
- `gitRepoUrl`: `{{ $node['Combine Results'].json.gitRepo?.html_url || '' }}`
|
||||||
|
- `leantimeProjectId`: `{{ $node['Combine Results'].json.leantimeProject?.result?.[0] || '' }}`
|
||||||
|
- `documentationCollectionId`: `{{ $node['Combine Results'].json.docCollection?.data?.id || '' }}`
|
||||||
|
- `rocketchatChannelId`: `{{ $node['Combine Results'].json.rocketChatChannel?.channel?._id || '' }}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Why This Happens
|
||||||
|
|
||||||
|
### Next.js Routing
|
||||||
|
|
||||||
|
Next.js has two types of routes:
|
||||||
|
|
||||||
|
1. **Page Routes** (`app/[section]/page.tsx`):
|
||||||
|
- Matches: `/mission-created`
|
||||||
|
- Returns: HTML page
|
||||||
|
- Used for: Frontend pages
|
||||||
|
|
||||||
|
2. **API Routes** (`app/api/missions/mission-created/route.ts`):
|
||||||
|
- Matches: `/api/missions/mission-created`
|
||||||
|
- Returns: JSON response
|
||||||
|
- Used for: API endpoints
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
When N8N calls `/mission-created`:
|
||||||
|
- Next.js matches it to `app/[section]/page.tsx`
|
||||||
|
- `section = "mission-created"`
|
||||||
|
- Page doesn't exist in `menuItems`
|
||||||
|
- Returns 404 HTML page
|
||||||
|
|
||||||
|
When N8N calls `/api/missions/mission-created`:
|
||||||
|
- Next.js matches it to `app/api/missions/mission-created/route.ts`
|
||||||
|
- Executes the API handler
|
||||||
|
- Returns JSON response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Summary
|
||||||
|
|
||||||
|
**Problem**: N8N receives HTML 404 page instead of JSON
|
||||||
|
|
||||||
|
**Cause**: URL is missing `/api/missions` prefix
|
||||||
|
|
||||||
|
**Fix**: Change URL from:
|
||||||
|
```
|
||||||
|
{{ MISSION_API_URL }}/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
{{ MISSION_API_URL }}/api/missions/mission-created
|
||||||
|
```
|
||||||
|
|
||||||
|
**After Fix**: N8N will receive JSON response and IDs will be saved to database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Priority**: CRITICAL - Blocks integration IDs from being saved
|
||||||
|
|
||||||
139
NeahMissionGeneratePlan.json
Normal file
139
NeahMissionGeneratePlan.json
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"name": "NeahMissionGeneratePlan",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "GeneratePlan",
|
||||||
|
"responseMode": "lastNode",
|
||||||
|
"responseData": "allEntries",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"name": "Webhook GeneratePlan",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
-1040,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "28206383-afc0-472a-81f2-c99dc1e14f24",
|
||||||
|
"webhookId": "633b32e3-07c3-4e82-8e27-9ea4d6ec28e9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Build Project Action Plan Prompt (Senior Project Manager)\n\n// 1. Read input safely\nconst query = $input.item.json.query || \"\";\nconst mission = $input.item.json.mission;\nconst model = $input.item.json.model || \"qwen3:8b\";\n\n// 2. Handle case: no mission provided\nif (!mission) {\n return {\n json: {\n response_prompt: `No mission details were provided.\nPolitely ask the user to supply the mission context required to build a project action plan.`,\n num_predict: 300,\n model: model,\n query: query\n }\n };\n}\n\n// 3. Normalize mission fields (safe defaults)\nconst {\n name = \"Unnamed Mission\",\n oddScope = [],\n niveau = \"B\",\n intention = \"\",\n missionType = \"\",\n donneurDOrdre = \"\",\n projection = \"\",\n services = [],\n profils = []\n} = mission;\n\n// 4. Construct the Senior Project Manager Prompt\nconst prompt = `\nYou are a Senior Project Manager with extensive experience leading large-scale, complex and cross-functional projects for organizations, NGOs and startups.\n\nMISSION CONTEXT:\n- Mission name: ${name}\n- Mission scope (UN SDGs): ${oddScope.length ? oddScope.join(\", \") : \"Not specified\"}\n- Mission complexity level: ${niveau}\n- Mission intention: ${intention}\n- Mission type: ${missionType}\n- Ordering organization: ${donneurDOrdre}\n- Time projection: ${projection}\n- Services involved: ${services.length ? services.join(\", \") : \"None specified\"}\n- Required profiles: ${profils.length ? profils.join(\", \") : \"Not specified\"}\n\nTASK:\nProduce a clear, structured, and actionable ACTION PLAN as a senior project manager would do at the start of a major project.\n\nCRITICAL INSTRUCTIONS:\n- Respond ONLY in English.\n- Write as a senior project manager, not as a consultant or academic.\n- Focus on execution, structure, governance, and delivery.\n- Do NOT restate the mission description.\n- Do NOT use motivational or generic language.\n- Assume a complex, long-term mission with multiple stakeholders.\n- Be concise, concrete, and pragmatic.\n\nSTRUCTURE YOUR RESPONSE USING THE FOLLOWING SECTIONS:\n\n1. Mission Framing & Strategic Intent \nClarify the real objective of the mission, key constraints, and strategic priorities.\n\n2. Success Criteria & KPIs \nDefine how success will be measured (operational, impact, and sustainability metrics).\n\n3. Execution Roadmap \nBreak the mission into clear phases (e.g. initiation, build, deployment, scaling) with concrete outcomes per phase.\n\n4. Team Structure & Governance \nExplain how the different profiles will collaborate, decision-making model, and coordination mechanisms.\n\n5. Key Risks & Mitigation Plan \nIdentify major delivery, technical, organizational, and stakeholder risks and how to mitigate them.\n\n6. Delivery & Impact Measurement \nExplain how results will be delivered, validated, and aligned with the relevant UN SDGs over time.\n\nWrite in a professional, structured format, using short paragraphs or bullet points where relevant.\n`;\n\n// 5. Return LLM payload\nreturn {\n json: {\n response_prompt: prompt,\n num_predict: 2500,\n model: model,\n query: query\n }\n};\n"
|
||||||
|
},
|
||||||
|
"name": "Process Prompt for Ollama",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-736,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "6f0cdeb3-b5b5-4d2c-b13c-468eb92f0a52"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://172.16.0.117:11434/api/generate",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ {\n \"model\": $json.model,\n \"prompt\": $json.response_prompt,\n \"stream\": false,\n \"options\": {\n \"temperature\": 0.3,\n \"top_p\": 0.9,\n \"num_predict\": $json.num_predict\n }\n} }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.3,
|
||||||
|
"position": [
|
||||||
|
-528,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "5918cf24-0473-44f7-9d36-c05d9b73039b",
|
||||||
|
"name": "HTTP Request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Clean Theme Response\nconst response = $input.item.json.response || $input.item.json.body?.response || '';\nlet cleanedResponse = response;\nif (cleanedResponse.includes('<think>')) {\n cleanedResponse = cleanedResponse.split('</think>')[1] || cleanedResponse;\n}\nreturn { json: { response: cleanedResponse.trim(), query: $input.item.json.query } };"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-320,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "5b029d07-b152-49b8-a269-8331e57b898f",
|
||||||
|
"name": "Clean Response"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "{{ { \"response\": $json.response } }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.4,
|
||||||
|
"position": [
|
||||||
|
-112,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"id": "38099abd-8ac8-42e6-a400-dbbffbf14f04",
|
||||||
|
"name": "Respond to Webhook"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"Webhook GeneratePlan": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Process Prompt for Ollama",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Process Prompt for Ollama": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HTTP Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Clean Response",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Clean Response": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Respond to Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
},
|
||||||
|
"versionId": "6ce946c6-c278-43d0-acfe-1fe814c4f963",
|
||||||
|
"meta": {
|
||||||
|
"instanceId": "21947434c58170635d41cc9137ebeab13a628beaa4cf8318a6d7c90f9b354219"
|
||||||
|
},
|
||||||
|
"id": "k34Oeva3jxsmDg9M",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
753
PROJECT_DEEP_ANALYSIS.md
Normal file
753
PROJECT_DEEP_ANALYSIS.md
Normal file
@ -0,0 +1,753 @@
|
|||||||
|
# Neah Project - Deep Technical Analysis
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document provides a comprehensive analysis of the Neah project architecture, focusing on:
|
||||||
|
- Update Services & Refresh Management
|
||||||
|
- Widgets Architecture
|
||||||
|
- Notifications System
|
||||||
|
- Authentication & Token Refresh
|
||||||
|
- Performance & Memory Management
|
||||||
|
- API Routes Tracing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Update Services & Refresh Management
|
||||||
|
|
||||||
|
### 1.1 Unified Refresh Manager (`lib/services/refresh-manager.ts`)
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- **Singleton Pattern**: Single instance manages all refresh operations
|
||||||
|
- **Resource-Based**: Each refreshable resource has its own configuration
|
||||||
|
- **Deduplication**: Prevents duplicate refresh requests
|
||||||
|
- **Interval Management**: Centralized interval control
|
||||||
|
|
||||||
|
**Refreshable Resources:**
|
||||||
|
```typescript
|
||||||
|
type RefreshableResource =
|
||||||
|
| 'notifications'
|
||||||
|
| 'notifications-count'
|
||||||
|
| 'calendar'
|
||||||
|
| 'news'
|
||||||
|
| 'email'
|
||||||
|
| 'parole'
|
||||||
|
| 'duties'
|
||||||
|
| 'navbar-time';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
|
||||||
|
1. **Request Deduplication**
|
||||||
|
- Minimum 1 second between refreshes for same resource
|
||||||
|
- Tracks pending requests to prevent duplicates
|
||||||
|
- Uses `pendingRequests` Map with promise tracking
|
||||||
|
|
||||||
|
2. **Interval Management**
|
||||||
|
- Each resource can have different refresh intervals
|
||||||
|
- Automatic cleanup on unregister
|
||||||
|
- Pause/Resume functionality for all resources
|
||||||
|
|
||||||
|
3. **Error Handling**
|
||||||
|
- Errors don't update `lastRefresh` timestamp (allows retry)
|
||||||
|
- Comprehensive logging for debugging
|
||||||
|
- Graceful degradation on failures
|
||||||
|
|
||||||
|
**Memory Impact:**
|
||||||
|
- **Low**: Uses Maps for efficient lookups
|
||||||
|
- **Cleanup**: Proper cleanup on component unmount
|
||||||
|
- **Potential Issue**: If components don't unregister, intervals persist
|
||||||
|
|
||||||
|
**Performance Considerations:**
|
||||||
|
- ✅ Deduplication prevents unnecessary API calls
|
||||||
|
- ✅ Minimum 1s throttle prevents excessive refreshes
|
||||||
|
- ⚠️ Multiple resources = multiple intervals (but necessary)
|
||||||
|
- ⚠️ No priority-based scheduling (all resources treated equally)
|
||||||
|
|
||||||
|
### 1.2 Unified Refresh Hook (`hooks/use-unified-refresh.ts`)
|
||||||
|
|
||||||
|
**Purpose:** React hook wrapper for RefreshManager
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- Automatic registration/unregistration on mount/unmount
|
||||||
|
- Session-aware (only active when authenticated)
|
||||||
|
- Callback ref pattern to avoid stale closures
|
||||||
|
- Manual refresh trigger with force option
|
||||||
|
|
||||||
|
**Usage Pattern:**
|
||||||
|
```typescript
|
||||||
|
const { refresh, isActive } = useUnifiedRefresh({
|
||||||
|
resource: 'calendar',
|
||||||
|
interval: 300000, // 5 minutes
|
||||||
|
enabled: status === 'authenticated',
|
||||||
|
onRefresh: fetchEvents,
|
||||||
|
priority: 'low',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Memory Leak Prevention:**
|
||||||
|
- ✅ Cleanup in useEffect return
|
||||||
|
- ✅ isMountedRef prevents state updates after unmount
|
||||||
|
- ✅ Automatic unregister on unmount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Widgets Architecture
|
||||||
|
|
||||||
|
### 2.1 Widget Components Overview
|
||||||
|
|
||||||
|
**Main Dashboard Widgets** (`app/page.tsx`):
|
||||||
|
1. **QuoteCard** - Inspirational quotes
|
||||||
|
2. **Calendar** - Upcoming events (7 events)
|
||||||
|
3. **News** - News articles (100 limit)
|
||||||
|
4. **Duties** - Leantime tasks (7 tasks)
|
||||||
|
5. **Email** - Email preview (5 emails)
|
||||||
|
6. **Parole** - RocketChat messages
|
||||||
|
|
||||||
|
### 2.2 Widget Refresh Patterns
|
||||||
|
|
||||||
|
**Current Implementation Issues:**
|
||||||
|
|
||||||
|
1. **Calendar Widget** (`components/calendar.tsx`)
|
||||||
|
- ❌ No unified refresh integration
|
||||||
|
- ❌ Manual refresh only via button
|
||||||
|
- ❌ Fetches on mount only
|
||||||
|
- ⚠️ Uses `?refresh=true` parameter (bypasses cache)
|
||||||
|
|
||||||
|
2. **News Widget** (`components/news.tsx`)
|
||||||
|
- ❌ No unified refresh integration
|
||||||
|
- ✅ Manual refresh button
|
||||||
|
- ✅ Fetches on authentication
|
||||||
|
- ⚠️ Uses `?refresh=true` parameter
|
||||||
|
|
||||||
|
3. **Email Widget** (`components/email.tsx`)
|
||||||
|
- ❌ No unified refresh integration
|
||||||
|
- ✅ Manual refresh button
|
||||||
|
- ⚠️ Fetches on mount only
|
||||||
|
- ⚠️ Uses `?refresh=true` parameter
|
||||||
|
|
||||||
|
4. **Parole Widget** (`components/parole.tsx`)
|
||||||
|
- ❌ No unified refresh integration
|
||||||
|
- ⚠️ **Custom polling**: `setInterval(() => fetchMessages(), 30000)` (30s)
|
||||||
|
- ⚠️ **Memory Leak Risk**: Interval not cleared if component unmounts during fetch
|
||||||
|
- ✅ Manual refresh button
|
||||||
|
|
||||||
|
5. **Duties Widget** (`components/flow.tsx`)
|
||||||
|
- ❌ No unified refresh integration
|
||||||
|
- ❌ Fetches on mount only
|
||||||
|
- ⚠️ Uses `?refresh=true` parameter
|
||||||
|
|
||||||
|
### 2.3 Widget Memory & Performance Issues
|
||||||
|
|
||||||
|
**Critical Issues:**
|
||||||
|
|
||||||
|
1. **Multiple Polling Mechanisms**
|
||||||
|
- Parole widget uses `setInterval` (30s)
|
||||||
|
- No coordination with RefreshManager
|
||||||
|
- Risk of memory leaks if cleanup fails
|
||||||
|
|
||||||
|
2. **Cache Bypassing**
|
||||||
|
- Most widgets use `?refresh=true`
|
||||||
|
- Bypasses Redis cache
|
||||||
|
- Increases server load
|
||||||
|
|
||||||
|
3. **No Unified Refresh**
|
||||||
|
- Widgets don't use `useUnifiedRefresh` hook
|
||||||
|
- Inconsistent refresh patterns
|
||||||
|
- Hard to manage globally
|
||||||
|
|
||||||
|
4. **State Management**
|
||||||
|
- Each widget manages its own state
|
||||||
|
- No shared state/cache
|
||||||
|
- Potential duplicate API calls
|
||||||
|
|
||||||
|
**Recommendations:**
|
||||||
|
- ✅ Migrate all widgets to use `useUnifiedRefresh`
|
||||||
|
- ✅ Remove custom `setInterval` implementations
|
||||||
|
- ✅ Use cache-first strategy (remove `?refresh=true` by default)
|
||||||
|
- ✅ Implement widget-level error boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Notifications System
|
||||||
|
|
||||||
|
### 3.1 Architecture Overview
|
||||||
|
|
||||||
|
**Service Pattern:** Singleton with adapter pattern
|
||||||
|
|
||||||
|
**Location:** `lib/services/notifications/notification-service.ts`
|
||||||
|
|
||||||
|
**Adapters:**
|
||||||
|
- `LeantimeAdapter` (implemented)
|
||||||
|
- NextcloudAdapter (planned)
|
||||||
|
- GiteaAdapter (planned)
|
||||||
|
- DolibarrAdapter (planned)
|
||||||
|
- MoodleAdapter (planned)
|
||||||
|
|
||||||
|
### 3.2 Caching Strategy
|
||||||
|
|
||||||
|
**Redis Cache Keys:**
|
||||||
|
```typescript
|
||||||
|
NOTIFICATION_COUNT_CACHE_KEY = `notifications:count:${userId}`
|
||||||
|
NOTIFICATIONS_LIST_CACHE_KEY = `notifications:list:${userId}:${page}:${limit}`
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache TTL:**
|
||||||
|
- Count cache: 30 seconds
|
||||||
|
- List cache: 30 seconds
|
||||||
|
- Refresh lock: 30 seconds
|
||||||
|
|
||||||
|
**Cache Invalidation:**
|
||||||
|
- On `markAsRead`: Invalidates all user caches
|
||||||
|
- Uses Redis SCAN for pattern matching
|
||||||
|
- Prevents blocking operations
|
||||||
|
|
||||||
|
### 3.3 Refresh Management
|
||||||
|
|
||||||
|
**Integration with RefreshManager:**
|
||||||
|
- ✅ Uses unified refresh system
|
||||||
|
- ✅ Registered as 'notifications' and 'notifications-count'
|
||||||
|
- ✅ 30-second refresh interval (aligned with cache TTL)
|
||||||
|
|
||||||
|
**Hook Usage** (`hooks/use-notifications.ts`):
|
||||||
|
- Request deduplication (2-second window)
|
||||||
|
- Automatic refresh on mount
|
||||||
|
- Manual refresh capability
|
||||||
|
- Error handling with retry
|
||||||
|
|
||||||
|
### 3.4 Performance Characteristics
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
- ✅ Redis caching reduces database load
|
||||||
|
- ✅ Adapter pattern allows easy extension
|
||||||
|
- ✅ Parallel fetching from multiple adapters
|
||||||
|
- ✅ Request deduplication prevents duplicate calls
|
||||||
|
|
||||||
|
**Potential Issues:**
|
||||||
|
- ⚠️ SCAN operations can be slow with many keys
|
||||||
|
- ⚠️ No pagination limits on adapter results
|
||||||
|
- ⚠️ All adapters fetched in parallel (could be optimized)
|
||||||
|
|
||||||
|
**Memory Impact:**
|
||||||
|
- **Low**: Cached data in Redis, not memory
|
||||||
|
- **Medium**: Notification objects in React state
|
||||||
|
- **Low**: Adapter instances are singletons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication & Token Refresh
|
||||||
|
|
||||||
|
### 4.1 Keycloak Integration
|
||||||
|
|
||||||
|
**Provider:** NextAuth with KeycloakProvider
|
||||||
|
|
||||||
|
**Location:** `app/api/auth/options.ts`
|
||||||
|
|
||||||
|
### 4.2 Token Refresh Flow
|
||||||
|
|
||||||
|
**JWT Callback Logic:**
|
||||||
|
|
||||||
|
1. **Initial Sign-In:**
|
||||||
|
- Stores access token, refresh token, ID token
|
||||||
|
- Extracts roles from access token
|
||||||
|
- Sets expiration timestamp
|
||||||
|
|
||||||
|
2. **Subsequent Requests:**
|
||||||
|
- Checks if token is expired
|
||||||
|
- If expired, calls `refreshAccessToken()`
|
||||||
|
- Updates token with new values
|
||||||
|
|
||||||
|
**Refresh Function** (`refreshAccessToken`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function refreshAccessToken(token: ExtendedJWT) {
|
||||||
|
// Calls Keycloak token endpoint
|
||||||
|
// Handles various error scenarios:
|
||||||
|
// - SessionNotActive (user logged out)
|
||||||
|
// - RefreshTokenExpired (inactivity)
|
||||||
|
// - InvalidGrant (session invalidated)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
- ✅ Detects session invalidation
|
||||||
|
- ✅ Handles refresh token expiration
|
||||||
|
- ✅ Clears tokens on critical errors
|
||||||
|
- ✅ Returns null session to trigger re-auth
|
||||||
|
|
||||||
|
### 4.3 Session Management
|
||||||
|
|
||||||
|
**Session Configuration:**
|
||||||
|
- Strategy: JWT (stateless)
|
||||||
|
- Max Age: 4 hours (14,400 seconds)
|
||||||
|
- Automatic refresh on activity
|
||||||
|
|
||||||
|
**Cookie Configuration:**
|
||||||
|
- HttpOnly: true
|
||||||
|
- SameSite: 'lax'
|
||||||
|
- Secure: Based on NEXTAUTH_URL
|
||||||
|
|
||||||
|
### 4.4 Email OAuth Token Refresh
|
||||||
|
|
||||||
|
**Service:** `lib/services/token-refresh.ts`
|
||||||
|
|
||||||
|
**Purpose:** Refresh Microsoft OAuth tokens for email access
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Check Redis cache for credentials
|
||||||
|
2. If cache miss, check Prisma database
|
||||||
|
3. Validate token expiration (5-minute buffer)
|
||||||
|
4. Refresh if needed via Microsoft OAuth
|
||||||
|
5. Update both Redis and Prisma
|
||||||
|
|
||||||
|
**Dual Storage:**
|
||||||
|
- **Redis**: Fast access, 24-hour TTL
|
||||||
|
- **Prisma**: Persistent storage, survives Redis restarts
|
||||||
|
|
||||||
|
**Memory Impact:**
|
||||||
|
- **Low**: Credentials stored in Redis/DB, not memory
|
||||||
|
- **Medium**: Token refresh operations are async
|
||||||
|
- **Low**: No memory leaks (proper cleanup)
|
||||||
|
|
||||||
|
### 4.5 Performance Considerations
|
||||||
|
|
||||||
|
**Token Refresh Frequency:**
|
||||||
|
- Keycloak: On every request if expired
|
||||||
|
- Email OAuth: Only when expired (5-min buffer)
|
||||||
|
|
||||||
|
**Optimization Opportunities:**
|
||||||
|
- ⚠️ Token refresh happens synchronously in JWT callback
|
||||||
|
- ⚠️ Could implement background refresh
|
||||||
|
- ✅ Caching reduces refresh frequency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Performance & Memory Management
|
||||||
|
|
||||||
|
### 5.1 Next.js Configuration
|
||||||
|
|
||||||
|
**Build Configuration** (`next.config.mjs`):
|
||||||
|
```javascript
|
||||||
|
experimental: {
|
||||||
|
webpackBuildWorker: true,
|
||||||
|
parallelServerBuildTraces: true,
|
||||||
|
parallelServerCompiles: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Memory Impact:**
|
||||||
|
- ✅ Parallel builds reduce build time
|
||||||
|
- ⚠️ Multiple workers increase memory during build
|
||||||
|
- ✅ Production builds are optimized
|
||||||
|
|
||||||
|
### 5.2 Redis Connection Management
|
||||||
|
|
||||||
|
**Singleton Pattern** (`lib/redis.ts`):
|
||||||
|
- Single Redis client instance
|
||||||
|
- Connection pooling
|
||||||
|
- Automatic reconnection with retry strategy
|
||||||
|
|
||||||
|
**Memory Impact:**
|
||||||
|
- **Low**: Single connection per process
|
||||||
|
- **Medium**: Connection pool (if configured)
|
||||||
|
- **Low**: Proper cleanup on disconnect
|
||||||
|
|
||||||
|
**Connection Strategy:**
|
||||||
|
- Max reconnect attempts: 5
|
||||||
|
- Exponential backoff
|
||||||
|
- Connection timeout: 10 seconds
|
||||||
|
- Keep-alive: 10 seconds
|
||||||
|
|
||||||
|
### 5.3 Caching Strategy
|
||||||
|
|
||||||
|
**Redis Cache TTLs:**
|
||||||
|
```typescript
|
||||||
|
CREDENTIALS: 24 hours
|
||||||
|
SESSION: 4 hours
|
||||||
|
EMAIL_LIST: 5 minutes
|
||||||
|
EMAIL_CONTENT: 15 minutes
|
||||||
|
CALENDAR: 10 minutes
|
||||||
|
NEWS: 15 minutes
|
||||||
|
TASKS: 10 minutes
|
||||||
|
MESSAGES: 2 minutes
|
||||||
|
NOTIFICATIONS: 30 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Memory Impact:**
|
||||||
|
- **Low**: Data in Redis, not application memory
|
||||||
|
- **Medium**: Large cache can consume Redis memory
|
||||||
|
- **Low**: TTL ensures automatic cleanup
|
||||||
|
|
||||||
|
### 5.4 Component Memory Management
|
||||||
|
|
||||||
|
**Potential Memory Leaks:**
|
||||||
|
|
||||||
|
1. **Parole Widget** (`components/parole.tsx`):
|
||||||
|
```typescript
|
||||||
|
// ⚠️ RISK: Interval might not clear if component unmounts during fetch
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'authenticated') {
|
||||||
|
fetchMessages();
|
||||||
|
const interval = setInterval(() => fetchMessages(), 30000);
|
||||||
|
return () => clearInterval(interval); // ✅ Good, but...
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
```
|
||||||
|
**Issue**: If `fetchMessages()` is async and component unmounts, state updates may occur
|
||||||
|
|
||||||
|
2. **Widget State:**
|
||||||
|
- Each widget maintains its own state
|
||||||
|
- No cleanup on unmount for pending requests
|
||||||
|
- Potential memory leaks with large data arrays
|
||||||
|
|
||||||
|
3. **Event Listeners:**
|
||||||
|
- No evidence of unregistered event listeners
|
||||||
|
- ✅ React handles most cleanup automatically
|
||||||
|
|
||||||
|
### 5.5 API Route Performance
|
||||||
|
|
||||||
|
**Common Patterns:**
|
||||||
|
|
||||||
|
1. **Session Validation:**
|
||||||
|
```typescript
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
```
|
||||||
|
- Called on every request
|
||||||
|
- JWT validation overhead
|
||||||
|
- Could be optimized with middleware
|
||||||
|
|
||||||
|
2. **Database Queries:**
|
||||||
|
- Prisma ORM adds overhead
|
||||||
|
- No query optimization visible
|
||||||
|
- Connection pooling handled by Prisma
|
||||||
|
|
||||||
|
3. **Redis Operations:**
|
||||||
|
- Most routes check cache first
|
||||||
|
- SCAN operations for pattern matching
|
||||||
|
- Could be optimized with better key patterns
|
||||||
|
|
||||||
|
### 5.6 Memory Optimization Recommendations
|
||||||
|
|
||||||
|
**High Priority:**
|
||||||
|
1. ✅ Fix Parole widget interval cleanup
|
||||||
|
2. ✅ Migrate widgets to unified refresh
|
||||||
|
3. ✅ Implement request cancellation for unmounted components
|
||||||
|
4. ✅ Add error boundaries to prevent memory leaks
|
||||||
|
|
||||||
|
**Medium Priority:**
|
||||||
|
1. ⚠️ Implement API route middleware for auth
|
||||||
|
2. ⚠️ Optimize Redis SCAN operations
|
||||||
|
3. ⚠️ Add request timeout handling
|
||||||
|
4. ⚠️ Implement connection pooling for external APIs
|
||||||
|
|
||||||
|
**Low Priority:**
|
||||||
|
1. ⚠️ Consider React Query for state management
|
||||||
|
2. ⚠️ Implement virtual scrolling for large lists
|
||||||
|
3. ⚠️ Add memory profiling tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API Routes Tracing
|
||||||
|
|
||||||
|
### 6.1 Logging Infrastructure
|
||||||
|
|
||||||
|
**Logger** (`lib/logger.ts`):
|
||||||
|
- Environment-aware (silent in production for debug/info)
|
||||||
|
- Always logs errors
|
||||||
|
- Simple console-based logging
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- ❌ No structured logging (JSON)
|
||||||
|
- ❌ No log levels in production
|
||||||
|
- ❌ No centralized log aggregation
|
||||||
|
- ❌ No request tracing IDs
|
||||||
|
|
||||||
|
### 6.2 Current Logging Patterns
|
||||||
|
|
||||||
|
**API Routes:**
|
||||||
|
- 343 `console.log/error/warn` calls across 68 files
|
||||||
|
- Inconsistent logging patterns
|
||||||
|
- Some routes have detailed logging, others minimal
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
1. **Good Logging** (`app/api/missions/mission-created/route.ts`):
|
||||||
|
```typescript
|
||||||
|
logger.debug('Mission Created Webhook Received');
|
||||||
|
logger.debug('Received mission-created data', { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Inconsistent Logging** (`app/api/courrier/route.ts`):
|
||||||
|
```typescript
|
||||||
|
console.log(`[API] Received request with: ...`);
|
||||||
|
// Mix of console.log and logger
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 API Route Categories
|
||||||
|
|
||||||
|
**Authentication Routes:**
|
||||||
|
- `/api/auth/[...nextauth]` - NextAuth handler
|
||||||
|
- `/api/auth/refresh-keycloak-session` - Session refresh
|
||||||
|
- `/api/auth/debug-keycloak` - Debug endpoint
|
||||||
|
|
||||||
|
**Email Routes (Courrier):**
|
||||||
|
- `/api/courrier` - Email list
|
||||||
|
- `/api/courrier/emails` - Email list (alternative)
|
||||||
|
- `/api/courrier/[id]` - Single email
|
||||||
|
- `/api/courrier/refresh` - Token refresh
|
||||||
|
- `/api/courrier/session` - IMAP session
|
||||||
|
- `/api/courrier/account` - Account management
|
||||||
|
|
||||||
|
**Calendar Routes:**
|
||||||
|
- `/api/calendars` - Calendar list
|
||||||
|
- `/api/calendars/[id]` - Single calendar
|
||||||
|
- `/api/calendars/[id]/events` - Calendar events
|
||||||
|
- `/api/events` - Event CRUD
|
||||||
|
|
||||||
|
**Notification Routes:**
|
||||||
|
- `/api/notifications` - Notification list
|
||||||
|
- `/api/notifications/count` - Notification count
|
||||||
|
- `/api/notifications/[id]/read` - Mark as read
|
||||||
|
- `/api/notifications/read-all` - Mark all as read
|
||||||
|
|
||||||
|
**Mission Routes:**
|
||||||
|
- `/api/missions` - Mission list
|
||||||
|
- `/api/missions/[missionId]` - Single mission
|
||||||
|
- `/api/missions/upload` - File upload
|
||||||
|
- `/api/missions/mission-created` - Webhook handler
|
||||||
|
|
||||||
|
### 6.4 Tracing Recommendations
|
||||||
|
|
||||||
|
**Immediate Improvements:**
|
||||||
|
|
||||||
|
1. **Request ID Tracking:**
|
||||||
|
```typescript
|
||||||
|
// Add to middleware or API route wrapper
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
logger.info('Request started', { requestId, path, method });
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Structured Logging:**
|
||||||
|
```typescript
|
||||||
|
logger.info('API Request', {
|
||||||
|
requestId,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
userId,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Error Tracking:**
|
||||||
|
```typescript
|
||||||
|
logger.error('API Error', {
|
||||||
|
requestId,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
path,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Performance Monitoring:**
|
||||||
|
```typescript
|
||||||
|
const startTime = Date.now();
|
||||||
|
// ... route logic
|
||||||
|
logger.debug('API Response', {
|
||||||
|
requestId,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
statusCode,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advanced Tracing:**
|
||||||
|
|
||||||
|
1. **OpenTelemetry Integration:**
|
||||||
|
- Distributed tracing
|
||||||
|
- Performance metrics
|
||||||
|
- Error tracking
|
||||||
|
|
||||||
|
2. **APM Tools:**
|
||||||
|
- New Relic
|
||||||
|
- Datadog
|
||||||
|
- Sentry
|
||||||
|
|
||||||
|
3. **Custom Middleware:**
|
||||||
|
```typescript
|
||||||
|
// app/api/middleware.ts
|
||||||
|
export function withTracing(handler: Function) {
|
||||||
|
return async (req: Request, res: Response) => {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(req, res);
|
||||||
|
logger.info('Request completed', {
|
||||||
|
requestId,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Request failed', {
|
||||||
|
requestId,
|
||||||
|
error,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 API Route Performance Metrics
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- ❌ No performance metrics collected
|
||||||
|
- ❌ No request duration tracking
|
||||||
|
- ❌ No error rate monitoring
|
||||||
|
- ❌ No cache hit/miss tracking
|
||||||
|
|
||||||
|
**Recommended Metrics:**
|
||||||
|
1. Request duration (p50, p95, p99)
|
||||||
|
2. Error rate by route
|
||||||
|
3. Cache hit/miss ratio
|
||||||
|
4. Database query count
|
||||||
|
5. Redis operation count
|
||||||
|
6. External API call duration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Critical Issues & Recommendations
|
||||||
|
|
||||||
|
### 7.1 Critical Issues
|
||||||
|
|
||||||
|
1. **Memory Leak Risk - Parole Widget**
|
||||||
|
- Custom `setInterval` without proper cleanup
|
||||||
|
- **Fix**: Migrate to `useUnifiedRefresh`
|
||||||
|
|
||||||
|
2. **Inconsistent Refresh Patterns**
|
||||||
|
- Widgets don't use unified refresh system
|
||||||
|
- **Fix**: Migrate all widgets to `useUnifiedRefresh`
|
||||||
|
|
||||||
|
3. **Cache Bypassing**
|
||||||
|
- Widgets use `?refresh=true` by default
|
||||||
|
- **Fix**: Use cache-first strategy
|
||||||
|
|
||||||
|
4. **No Request Tracing**
|
||||||
|
- Difficult to debug production issues
|
||||||
|
- **Fix**: Implement request ID tracking
|
||||||
|
|
||||||
|
5. **No Performance Monitoring**
|
||||||
|
- No visibility into slow routes
|
||||||
|
- **Fix**: Add performance metrics
|
||||||
|
|
||||||
|
### 7.2 High Priority Recommendations
|
||||||
|
|
||||||
|
1. ✅ Migrate all widgets to unified refresh system
|
||||||
|
2. ✅ Fix Parole widget interval cleanup
|
||||||
|
3. ✅ Implement request ID tracking
|
||||||
|
4. ✅ Add performance metrics
|
||||||
|
5. ✅ Standardize logging patterns
|
||||||
|
|
||||||
|
### 7.3 Medium Priority Recommendations
|
||||||
|
|
||||||
|
1. ⚠️ Implement API route middleware
|
||||||
|
2. ⚠️ Optimize Redis SCAN operations
|
||||||
|
3. ⚠️ Add error boundaries
|
||||||
|
4. ⚠️ Implement request cancellation
|
||||||
|
5. ⚠️ Add structured logging
|
||||||
|
|
||||||
|
### 7.4 Low Priority Recommendations
|
||||||
|
|
||||||
|
1. ⚠️ Consider React Query
|
||||||
|
2. ⚠️ Implement virtual scrolling
|
||||||
|
3. ⚠️ Add memory profiling
|
||||||
|
4. ⚠️ Consider OpenTelemetry
|
||||||
|
5. ⚠️ Add APM tooling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Architecture Strengths
|
||||||
|
|
||||||
|
### 8.1 Well-Designed Components
|
||||||
|
|
||||||
|
1. **Unified Refresh Manager**
|
||||||
|
- Excellent abstraction
|
||||||
|
- Proper deduplication
|
||||||
|
- Clean API
|
||||||
|
|
||||||
|
2. **Notification Service**
|
||||||
|
- Adapter pattern allows extension
|
||||||
|
- Good caching strategy
|
||||||
|
- Proper error handling
|
||||||
|
|
||||||
|
3. **Redis Integration**
|
||||||
|
- Comprehensive caching
|
||||||
|
- Proper TTL management
|
||||||
|
- Good key naming conventions
|
||||||
|
|
||||||
|
4. **Token Refresh**
|
||||||
|
- Dual storage (Redis + Prisma)
|
||||||
|
- Proper error handling
|
||||||
|
- Automatic refresh
|
||||||
|
|
||||||
|
### 8.2 Code Quality
|
||||||
|
|
||||||
|
- ✅ TypeScript throughout
|
||||||
|
- ✅ Consistent component structure
|
||||||
|
- ✅ Proper error handling in most places
|
||||||
|
- ✅ Good separation of concerns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Conclusion
|
||||||
|
|
||||||
|
The Neah project demonstrates a well-architected Next.js application with several sophisticated systems:
|
||||||
|
|
||||||
|
**Strengths:**
|
||||||
|
- Unified refresh management system
|
||||||
|
- Comprehensive caching strategy
|
||||||
|
- Robust authentication flow
|
||||||
|
- Extensible notification system
|
||||||
|
|
||||||
|
**Areas for Improvement:**
|
||||||
|
- Widget refresh consistency
|
||||||
|
- Memory leak prevention
|
||||||
|
- API route tracing
|
||||||
|
- Performance monitoring
|
||||||
|
|
||||||
|
**Overall Assessment:**
|
||||||
|
The codebase is production-ready but would benefit from the recommended improvements, particularly around widget refresh management and observability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: File Reference Map
|
||||||
|
|
||||||
|
### Core Services
|
||||||
|
- `lib/services/refresh-manager.ts` - Unified refresh management
|
||||||
|
- `lib/services/notifications/notification-service.ts` - Notification system
|
||||||
|
- `lib/services/token-refresh.ts` - Email OAuth token refresh
|
||||||
|
- `lib/redis.ts` - Redis caching utilities
|
||||||
|
- `lib/logger.ts` - Logging utility
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
- `hooks/use-unified-refresh.ts` - Unified refresh hook
|
||||||
|
- `hooks/use-notifications.ts` - Notification hook
|
||||||
|
|
||||||
|
### Widgets
|
||||||
|
- `components/calendar.tsx` - Calendar widget
|
||||||
|
- `components/news.tsx` - News widget
|
||||||
|
- `components/email.tsx` - Email widget
|
||||||
|
- `components/parole.tsx` - Messages widget
|
||||||
|
- `components/flow.tsx` - Tasks widget
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
- `app/api/auth/options.ts` - NextAuth configuration
|
||||||
|
- `app/api/notifications/` - Notification endpoints
|
||||||
|
- `app/api/courrier/` - Email endpoints
|
||||||
|
- `app/api/calendars/` - Calendar endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document generated: 2024*
|
||||||
|
*Last updated: Analysis session*
|
||||||
|
|
||||||
288
Untitled
Normal file
288
Untitled
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
alma@central:~/nextgen/NeahNew$ sudo npm start
|
||||||
|
|
||||||
|
> neah@0.1.0 start
|
||||||
|
> next start
|
||||||
|
|
||||||
|
▲ Next.js 15.3.1
|
||||||
|
- Local: http://localhost:3000
|
||||||
|
- Network: http://172.16.0.102:3000
|
||||||
|
|
||||||
|
✓ Starting...
|
||||||
|
✓ Ready in 1313ms
|
||||||
|
Connecting to Redis using environment variables
|
||||||
|
Microsoft OAuth Configuration: {
|
||||||
|
tenantId: 'cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2',
|
||||||
|
authorizeUrl: 'https://login.microsoftonline.com/cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2/oauth2/v2.0/authorize',
|
||||||
|
tokenUrl: 'https://login.microsoftonline.com/cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2/oauth2/v2.0/token',
|
||||||
|
clientIdFirstChars: 'afaff...',
|
||||||
|
redirectUri: 'https://hub.slm-lab.net/ms'
|
||||||
|
}
|
||||||
|
Microsoft OAuth Configuration: {
|
||||||
|
tenantId: 'cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2',
|
||||||
|
authorizeUrl: 'https://login.microsoftonline.com/cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2/oauth2/v2.0/authorize',
|
||||||
|
tokenUrl: 'https://login.microsoftonline.com/cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2/oauth2/v2.0/token',
|
||||||
|
clientIdFirstChars: 'afaff...',
|
||||||
|
redirectUri: 'https://hub.slm-lab.net/ms'
|
||||||
|
}
|
||||||
|
Successfully connected to Redis
|
||||||
|
Redis connection warmed up
|
||||||
|
⨯ SyntaxError: Unexpected identifier 'http'
|
||||||
|
at Object.Function [as get] (<anonymous>) {
|
||||||
|
digest: '2421336728'
|
||||||
|
}
|
||||||
|
Redis connection warmed up
|
||||||
|
=== SESSION CALLBACK START ===
|
||||||
|
Token error: undefined
|
||||||
|
Has accessToken: true
|
||||||
|
Has refreshToken: true
|
||||||
|
Token role: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Token sub: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Token email: a.tmiri@clm.foundation
|
||||||
|
Token name: Amine TMIRI
|
||||||
|
Token username: aminetmiri
|
||||||
|
User roles for session: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Creating session user object...
|
||||||
|
Setting session tokens...
|
||||||
|
✅ Session created successfully
|
||||||
|
Session user id: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Session user email: a.tmiri@clm.foundation
|
||||||
|
Session user roles: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
=== SESSION CALLBACK END ===
|
||||||
|
Using Rocket.Chat base URL: https://parole.slm-lab.net
|
||||||
|
Users list response: { success: true, count: 13, usersCount: 13 }
|
||||||
|
Found Rocket.Chat user: { username: 'aminetmiri', id: 'a9HwLtHagiRnTWeS5' }
|
||||||
|
Filtered user subscriptions: {
|
||||||
|
userId: 'a9HwLtHagiRnTWeS5',
|
||||||
|
username: 'aminetmiri',
|
||||||
|
totalSubscriptions: 1,
|
||||||
|
subscriptionDetails: [
|
||||||
|
{
|
||||||
|
type: 'd',
|
||||||
|
name: 'Rocket.Cat',
|
||||||
|
rid: 'a9HwLtHagiRnTWeS5rocket.cat',
|
||||||
|
alert: true,
|
||||||
|
unread: 3,
|
||||||
|
userMentions: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Messages for room Rocket.Cat: { success: true, count: 5, hasMessages: true }
|
||||||
|
Messages data cached for user 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
No valid session or email found
|
||||||
|
=== SESSION CALLBACK START ===
|
||||||
|
Token error: undefined
|
||||||
|
Has accessToken: true
|
||||||
|
Has refreshToken: true
|
||||||
|
Token role: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Token sub: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Token email: a.tmiri@clm.foundation
|
||||||
|
Token name: Amine TMIRI
|
||||||
|
Token username: aminetmiri
|
||||||
|
User roles for session: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Creating session user object...
|
||||||
|
Setting session tokens...
|
||||||
|
✅ Session created successfully
|
||||||
|
Session user id: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Session user email: a.tmiri@clm.foundation
|
||||||
|
Session user roles: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
=== SESSION CALLBACK END ===
|
||||||
|
Using cached messages data for user 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
=== SESSION CALLBACK START ===
|
||||||
|
Token error: undefined
|
||||||
|
Has accessToken: true
|
||||||
|
Has refreshToken: true
|
||||||
|
Token role: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Token sub: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Token email: a.tmiri@clm.foundation
|
||||||
|
Token name: Amine TMIRI
|
||||||
|
Token username: aminetmiri
|
||||||
|
User roles for session: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Creating session user object...
|
||||||
|
Setting session tokens...
|
||||||
|
✅ Session created successfully
|
||||||
|
Session user id: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Session user email: a.tmiri@clm.foundation
|
||||||
|
Session user roles: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
=== SESSION CALLBACK END ===
|
||||||
|
[NOTIFICATION_SERVICE] Creating new notification service instance
|
||||||
|
[NOTIFICATION_SERVICE] Initializing notification service
|
||||||
|
[LEANTIME_ADAPTER] Initialized with API URL and token
|
||||||
|
[NOTIFICATION_SERVICE] Registered notification adapter: leantime
|
||||||
|
[NOTIFICATION_SERVICE] Registered adapters: [ 'leantime' ]
|
||||||
|
[NOTIFICATION_SERVICE] getNotificationCount called for user 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
[NOTIFICATION_SERVICE] Fetching notification counts for user 203cbc91-61ab-47a2-95d2-b5e1159327d7 from 1 adapters
|
||||||
|
[NOTIFICATION_SERVICE] Available adapters for count: leantime
|
||||||
|
[NOTIFICATION_SERVICE] Checking if adapter leantime is configured for count
|
||||||
|
[NOTIFICATION_SERVICE] Adapter leantime is configured for count: true
|
||||||
|
[NOTIFICATION_SERVICE] Fetching notification count from leantime for user 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
[LEANTIME_ADAPTER] getNotificationCount called for userId: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
[LEANTIME_ADAPTER] getNotifications called for userId: 203cbc91-61ab-47a2-95d2-b5e1159327d7, page: 1, limit: 100
|
||||||
|
=== SESSION CALLBACK START ===
|
||||||
|
Token error: undefined
|
||||||
|
Has accessToken: true
|
||||||
|
Has refreshToken: true
|
||||||
|
Token role: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Token sub: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Token email: a.tmiri@clm.foundation
|
||||||
|
Token name: Amine TMIRI
|
||||||
|
Token username: aminetmiri
|
||||||
|
User roles for session: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Creating session user object...
|
||||||
|
Setting session tokens...
|
||||||
|
✅ Session created successfully
|
||||||
|
Session user id: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Session user email: a.tmiri@clm.foundation
|
||||||
|
Session user roles: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
=== SESSION CALLBACK END ===
|
||||||
|
[LEANTIME_ADAPTER] Retrieved email from session: a.tmiri@clm.foundation
|
||||||
|
[LEANTIME_ADAPTER] Retrieved Leantime userId for email a.tmiri@clm.foundation: 2
|
||||||
|
[LEANTIME_ADAPTER] Sending request to get all notifications
|
||||||
|
[LEANTIME_ADAPTER] Request body: {"jsonrpc":"2.0","method":"leantime.rpc.Notifications.Notifications.getAllNotifications","params":{"userId":2,"showNewOnly":0,"limitStart":0,"limitEnd":100,"filterOptions":[]},"id":1}
|
||||||
|
[LEANTIME_ADAPTER] Response status: 200
|
||||||
|
[LEANTIME_ADAPTER] Raw response (truncated): {"jsonrpc":"2.0","result":[{"id":2732,"0":2732,"userId":2,"1":2,"read":0,"2":0,"type":"projectUpdate","3":"projectUpdate","module":"tickets","4":"tickets","moduleId":225,"5":225,"datetime":"2025-12-24...
|
||||||
|
[LEANTIME_ADAPTER] Parsed response data: {
|
||||||
|
hasResult: true,
|
||||||
|
resultIsArray: true,
|
||||||
|
resultLength: 100,
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
[LEANTIME_ADAPTER] Transformed notifications count: 100
|
||||||
|
[LEANTIME_ADAPTER] Notification counts: { total: 100, unread: 66 }
|
||||||
|
[NOTIFICATION_SERVICE] Got count from leantime: {
|
||||||
|
total: 100,
|
||||||
|
unread: 66,
|
||||||
|
sources: { leantime: { total: 100, unread: 66 } }
|
||||||
|
}
|
||||||
|
[NOTIFICATION_SERVICE] Adding counts from leantime: total=100, unread=66
|
||||||
|
[NOTIFICATION_SERVICE] Aggregated counts for user 203cbc91-61ab-47a2-95d2-b5e1159327d7: {
|
||||||
|
total: 100,
|
||||||
|
unread: 66,
|
||||||
|
sources: { leantime: { total: 100, unread: 66 } }
|
||||||
|
}
|
||||||
|
[NOTIFICATION_SERVICE] Cached notification counts for user 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
[IMAP POOL] Size: 0, Active: 0, Connecting: 0, Max: 20
|
||||||
|
=== SESSION CALLBACK START ===
|
||||||
|
Token error: undefined
|
||||||
|
Has accessToken: true
|
||||||
|
Has refreshToken: true
|
||||||
|
Token role: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Token sub: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Token email: a.tmiri@clm.foundation
|
||||||
|
Token name: Amine TMIRI
|
||||||
|
Token username: aminetmiri
|
||||||
|
User roles for session: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
Creating session user object...
|
||||||
|
Setting session tokens...
|
||||||
|
✅ Session created successfully
|
||||||
|
Session user id: 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
Session user email: a.tmiri@clm.foundation
|
||||||
|
Session user roles: [
|
||||||
|
'expression',
|
||||||
|
'entrepreneurship',
|
||||||
|
'admin',
|
||||||
|
'dataintelligence',
|
||||||
|
'mediation',
|
||||||
|
'mentors'
|
||||||
|
]
|
||||||
|
=== SESSION CALLBACK END ===
|
||||||
|
Using cached messages data for user 203cbc91-61ab-47a2-95d2-b5e1159327d7
|
||||||
|
[IMAP POOL] Size: 0, Active: 0, Connecting: 0, Max: 20
|
||||||
|
|
||||||
144
VERIFY_INTEGRATION_IDS_SAVED.md
Normal file
144
VERIFY_INTEGRATION_IDS_SAVED.md
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
# Verify Integration IDs Are Being Saved
|
||||||
|
|
||||||
|
## 🔍 Current Status
|
||||||
|
|
||||||
|
From your deletion logs, I can see:
|
||||||
|
- ✅ `API key present { present: true }` - N8N_API_KEY is now set!
|
||||||
|
- ✅ Deletion workflow executes successfully
|
||||||
|
- ⚠️ `hasRepoName: false` - Mission had no integration IDs
|
||||||
|
|
||||||
|
**This suggests**: The mission was created **before** the fixes were applied, so it didn't have integration IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Next Steps: Verify IDs Are Being Saved
|
||||||
|
|
||||||
|
### Step 1: Create a New Mission
|
||||||
|
|
||||||
|
1. Create a new mission via the frontend
|
||||||
|
2. Wait for N8N workflow to complete (30-60 seconds)
|
||||||
|
3. Check the server logs for:
|
||||||
|
```
|
||||||
|
Mission Created Webhook Received ← Should appear now!
|
||||||
|
Received mission-created data: { ... }
|
||||||
|
Found mission: { id: "...", name: "..." }
|
||||||
|
Updating giteaRepositoryUrl: ...
|
||||||
|
Updating leantimeProjectId: ...
|
||||||
|
Mission updated successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Check Database
|
||||||
|
|
||||||
|
**Query the database** to verify IDs are saved:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
giteaRepositoryUrl,
|
||||||
|
leantimeProjectId,
|
||||||
|
outlineCollectionId,
|
||||||
|
rocketChatChannelId,
|
||||||
|
createdAt
|
||||||
|
FROM "Mission"
|
||||||
|
WHERE createdAt > NOW() - INTERVAL '1 hour'
|
||||||
|
ORDER BY createdAt DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected**: Recent missions should have integration IDs populated (not null).
|
||||||
|
|
||||||
|
### Step 3: Check Server Logs During Creation
|
||||||
|
|
||||||
|
**Look for these logs** when creating a mission:
|
||||||
|
|
||||||
|
```
|
||||||
|
Starting N8N workflow
|
||||||
|
POST /mission-created 200 ← N8N receiving webhook
|
||||||
|
Mission Created Webhook Received ← Our endpoint being called! ✅
|
||||||
|
Received mission-created data: { ... }
|
||||||
|
Updating giteaRepositoryUrl: ...
|
||||||
|
Updating leantimeProjectId: ...
|
||||||
|
Mission updated successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
**If you see "Mission Created Webhook Received"**: ✅ IDs are being saved!
|
||||||
|
|
||||||
|
**If you DON'T see it**: ❌ N8N is still not calling the endpoint correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Test Checklist
|
||||||
|
|
||||||
|
After creating a new mission:
|
||||||
|
|
||||||
|
- [ ] Server logs show "Mission Created Webhook Received"
|
||||||
|
- [ ] Server logs show "Updating giteaRepositoryUrl" (if Gitea was created)
|
||||||
|
- [ ] Server logs show "Updating leantimeProjectId" (if Leantime was created)
|
||||||
|
- [ ] Server logs show "Updating outlineCollectionId" (if Outline was created)
|
||||||
|
- [ ] Server logs show "Updating rocketChatChannelId" (if RocketChat was created)
|
||||||
|
- [ ] Server logs show "Mission updated successfully"
|
||||||
|
- [ ] Database query shows non-null integration IDs
|
||||||
|
- [ ] Mission deletion receives non-empty IDs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Expected vs Actual
|
||||||
|
|
||||||
|
### Expected (After Fix)
|
||||||
|
|
||||||
|
**Mission Creation Logs**:
|
||||||
|
```
|
||||||
|
Starting N8N workflow
|
||||||
|
POST /mission-created 200
|
||||||
|
Mission Created Webhook Received ✅
|
||||||
|
Received mission-created data: { missionId: "...", ... }
|
||||||
|
Updating giteaRepositoryUrl: https://gite.slm-lab.net/alma/repo-name
|
||||||
|
Updating leantimeProjectId: 123
|
||||||
|
Mission updated successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database**:
|
||||||
|
```
|
||||||
|
giteaRepositoryUrl: "https://gite.slm-lab.net/alma/repo-name"
|
||||||
|
leantimeProjectId: "123"
|
||||||
|
outlineCollectionId: "collection-456"
|
||||||
|
rocketChatChannelId: "channel-789"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mission Deletion**:
|
||||||
|
```
|
||||||
|
hasRepoName: true ✅
|
||||||
|
leantimeProjectId: 123 ✅
|
||||||
|
documentationCollectionId: "collection-456" ✅
|
||||||
|
rocketchatChannelId: "channel-789" ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actual (From Your Logs)
|
||||||
|
|
||||||
|
**Mission Deletion**:
|
||||||
|
```
|
||||||
|
hasRepoName: false ❌ (Mission created before fix)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Action Required
|
||||||
|
|
||||||
|
**Create a NEW mission** and check:
|
||||||
|
|
||||||
|
1. **Server logs** during creation - should show "Mission Created Webhook Received"
|
||||||
|
2. **Database** after creation - should have integration IDs
|
||||||
|
3. **Deletion logs** - should show non-empty IDs
|
||||||
|
|
||||||
|
If the new mission has IDs saved, then the fix is working! ✅
|
||||||
|
|
||||||
|
If not, we need to check:
|
||||||
|
- N8N workflow configuration
|
||||||
|
- N8N execution logs
|
||||||
|
- Server logs for errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Created**: $(date)
|
||||||
|
**Status**: Waiting for verification that new missions have IDs saved
|
||||||
|
|
||||||
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
33
app/[section]/page.tsx
Normal file
33
app/[section]/page.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
const menuItems = {
|
||||||
|
board: "https://example.com/board",
|
||||||
|
chapter: "https://example.com/chapter",
|
||||||
|
flow: "https://example.com/flow",
|
||||||
|
design: "https://example.com/design",
|
||||||
|
gitlab: "https://gitlab.com",
|
||||||
|
crm: "https://example.com/crm",
|
||||||
|
missions: "https://example.com/missions"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SectionPage({ params }: { params: Promise<{ section: string }> }) {
|
||||||
|
const { section } = await params;
|
||||||
|
|
||||||
|
const iframeUrl = menuItems[section as keyof typeof menuItems];
|
||||||
|
|
||||||
|
if (!iframeUrl) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[calc(100vh-8rem)]">
|
||||||
|
<iframe
|
||||||
|
src={iframeUrl}
|
||||||
|
className="w-full h-full border-none"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
121
app/agenda/page.tsx
Normal file
121
app/agenda/page.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { CalendarClient } from "@/components/calendar/calendar-client";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import { CalendarDays, Users, Bookmark, Clock } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { add } from 'date-fns';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Enkun - Calendrier | Gestion d'événements professionnelle",
|
||||||
|
description: "Plateforme avancée pour la gestion de vos rendez-vous, réunions et événements professionnels",
|
||||||
|
keywords: "calendrier, rendez-vous, événements, gestion du temps, enkun",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
location?: string | null;
|
||||||
|
isAllDay: boolean;
|
||||||
|
type?: string;
|
||||||
|
attendees?: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Calendar {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description?: string | null;
|
||||||
|
events: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CalendarPage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/api/auth/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.username || session.user.email || '';
|
||||||
|
|
||||||
|
// Get all calendars for the user
|
||||||
|
let calendars = await prisma.calendar.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session?.user?.id || '',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
orderBy: {
|
||||||
|
start: 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no calendars exist, create default ones
|
||||||
|
if (calendars.length === 0) {
|
||||||
|
const defaultCalendars = [
|
||||||
|
{
|
||||||
|
name: "Default",
|
||||||
|
color: "#4F46E5",
|
||||||
|
description: "Your default calendar"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
calendars = await Promise.all(
|
||||||
|
defaultCalendars.map(async (cal) => {
|
||||||
|
return prisma.calendar.create({
|
||||||
|
data: {
|
||||||
|
...cal,
|
||||||
|
userId: session?.user?.id || '',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
events: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextWeek = add(now, { days: 7 });
|
||||||
|
|
||||||
|
const upcomingEvents = calendars.flatMap(cal =>
|
||||||
|
cal.events.filter(event =>
|
||||||
|
new Date(event.start) >= now &&
|
||||||
|
new Date(event.start) <= nextWeek
|
||||||
|
)
|
||||||
|
).sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const totalEvents = calendars.flatMap(cal => cal.events).length;
|
||||||
|
|
||||||
|
const totalMeetingHours = calendars
|
||||||
|
.flatMap(cal => cal.events)
|
||||||
|
.reduce((total, event) => {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
const end = new Date(event.end);
|
||||||
|
const hours = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
|
||||||
|
return total + (isNaN(hours) ? 0 : hours);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-10">
|
||||||
|
<CalendarClient
|
||||||
|
initialCalendars={calendars}
|
||||||
|
userId={session.user.id}
|
||||||
|
userProfile={{
|
||||||
|
name: session.user.name || '',
|
||||||
|
email: session.user.email || '',
|
||||||
|
avatar: session.user.image || undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
app/agilite/page.tsx
Normal file
23
app/agilite/page.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="w-full h-screen bg-black">
|
||||||
|
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||||
|
<ResponsiveIframe
|
||||||
|
src={process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || ''}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/alma/page.tsx
Normal file
24
app/alma/page.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ResponsiveIframe } from "@/app/components/responsive-iframe";
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="w-full h-screen bg-black">
|
||||||
|
<div className="w-full h-full px-4 pt-12 pb-4">
|
||||||
|
<ResponsiveIframe
|
||||||
|
src={process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || ''}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
27
app/announcement/page.tsx
Normal file
27
app/announcement/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AnnouncementsPage } from "@/components/announcement/announcements-page";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Announcements",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AnnouncementPage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user role(s)
|
||||||
|
const userRole = session.user.role || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-white'>
|
||||||
|
<div className='container mx-auto py-10'>
|
||||||
|
<AnnouncementsPage userRole={userRole} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
app/api/admin/restore-credentials/route.ts
Normal file
146
app/api/admin/restore-credentials/route.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getRedisClient } from '@/lib/redis';
|
||||||
|
|
||||||
|
// This is an admin-only route to restore email credentials from Redis to the database
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow authorized users
|
||||||
|
if (!session.user.role.includes('admin')) {
|
||||||
|
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redis = getRedisClient();
|
||||||
|
|
||||||
|
// Get all email credential keys
|
||||||
|
const keys = await redis.keys('email:credentials:*');
|
||||||
|
console.log(`Found ${keys.length} credential records in Redis`);
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
total: keys.length,
|
||||||
|
processed: 0,
|
||||||
|
success: 0,
|
||||||
|
errors: [] as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each key
|
||||||
|
for (const key of keys) {
|
||||||
|
results.processed++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract user ID from key
|
||||||
|
const userId = key.split(':')[2];
|
||||||
|
if (!userId) {
|
||||||
|
results.errors.push(`Invalid key format: ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get credentials from Redis
|
||||||
|
const credStr = await redis.get(key);
|
||||||
|
if (!credStr) {
|
||||||
|
results.errors.push(`No data found for key: ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse credentials
|
||||||
|
const creds = JSON.parse(credStr);
|
||||||
|
console.log(`Processing credentials for user ${userId}`, {
|
||||||
|
email: creds.email,
|
||||||
|
host: creds.host
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const userExists = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userExists) {
|
||||||
|
// Create dummy user if needed (this is optional and might not be appropriate in all cases)
|
||||||
|
// Remove or modify this section if you don't want to create placeholder users
|
||||||
|
console.log(`User ${userId} not found, creating placeholder`);
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId,
|
||||||
|
email: creds.email || 'placeholder@example.com',
|
||||||
|
password: 'PLACEHOLDER_HASH_CHANGE_THIS', // You should set a proper password
|
||||||
|
// Add any other required fields
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Created placeholder user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing credentials first
|
||||||
|
const existingCredentials = await prisma.mailCredentials.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
email: creds.email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCredentials) {
|
||||||
|
// Update existing credentials
|
||||||
|
await prisma.mailCredentials.update({
|
||||||
|
where: { id: existingCredentials.id },
|
||||||
|
data: {
|
||||||
|
password: creds.encryptedPassword || 'encrypted_placeholder',
|
||||||
|
host: creds.host,
|
||||||
|
port: creds.port,
|
||||||
|
// Optional fields
|
||||||
|
...(creds.secure !== undefined && { secure: creds.secure }),
|
||||||
|
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
|
||||||
|
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
|
||||||
|
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
|
||||||
|
...(creds.display_name && { display_name: creds.display_name }),
|
||||||
|
...(creds.color && { color: creds.color })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new credentials
|
||||||
|
await prisma.mailCredentials.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
email: creds.email,
|
||||||
|
password: creds.encryptedPassword || 'encrypted_placeholder',
|
||||||
|
host: creds.host,
|
||||||
|
port: creds.port,
|
||||||
|
// Optional fields
|
||||||
|
...(creds.secure !== undefined && { secure: creds.secure }),
|
||||||
|
...(creds.smtp_host && { smtp_host: creds.smtp_host }),
|
||||||
|
...(creds.smtp_port && { smtp_port: creds.smtp_port }),
|
||||||
|
...(creds.smtp_secure !== undefined && { smtp_secure: creds.smtp_secure }),
|
||||||
|
...(creds.display_name && { display_name: creds.display_name }),
|
||||||
|
...(creds.color && { color: creds.color })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
console.log(`Successfully restored credentials for user ${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
results.errors.push(`Error processing ${key}: ${message}`);
|
||||||
|
console.error(`Error processing ${key}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Credential restoration process completed',
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in restore credentials route:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to restore credentials', details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/api/admin/view-redis-credentials/route.ts
Normal file
65
app/api/admin/view-redis-credentials/route.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getRedisClient } from '@/lib/redis';
|
||||||
|
|
||||||
|
// This route just views Redis email credentials without making any changes
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redis = getRedisClient();
|
||||||
|
|
||||||
|
// Get all email credential keys
|
||||||
|
const keys = await redis.keys('email:credentials:*');
|
||||||
|
console.log(`Found ${keys.length} credential records in Redis`);
|
||||||
|
|
||||||
|
const credentials = [];
|
||||||
|
|
||||||
|
// Process each key
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
// Extract user ID from key
|
||||||
|
const userId = key.split(':')[2];
|
||||||
|
|
||||||
|
// Get credentials from Redis
|
||||||
|
const credStr = await redis.get(key);
|
||||||
|
if (!credStr) continue;
|
||||||
|
|
||||||
|
// Parse credentials
|
||||||
|
const creds = JSON.parse(credStr);
|
||||||
|
|
||||||
|
// Add to results (remove sensitive data)
|
||||||
|
credentials.push({
|
||||||
|
userId,
|
||||||
|
email: creds.email,
|
||||||
|
host: creds.host,
|
||||||
|
port: creds.port,
|
||||||
|
hasPassword: !!creds.encryptedPassword,
|
||||||
|
// Include other non-sensitive fields
|
||||||
|
smtp_host: creds.smtp_host,
|
||||||
|
smtp_port: creds.smtp_port,
|
||||||
|
display_name: creds.display_name,
|
||||||
|
color: creds.color
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing ${key}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
count: credentials.length,
|
||||||
|
credentials
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error viewing Redis credentials:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to view credentials', details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/api/announcements/[id]/route.ts
Normal file
141
app/api/announcements/[id]/route.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user exists in the database
|
||||||
|
*/
|
||||||
|
async function userExists(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return !!user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking if user exists:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET - Retrieve a specific announcement
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Find announcement by ID
|
||||||
|
const announcement = await prisma.announcement.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!announcement) {
|
||||||
|
return NextResponse.json({ error: "Announcement not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this announcement
|
||||||
|
const userRole = session.user.role || [];
|
||||||
|
const roles = Array.isArray(userRole) ? userRole : [userRole];
|
||||||
|
|
||||||
|
const hasAccess =
|
||||||
|
announcement.targetRoles.includes("all") ||
|
||||||
|
announcement.targetRoles.some((role: string) => roles.includes(role));
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(announcement);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching announcement:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Failed to fetch announcement",
|
||||||
|
details: errorMessage
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE - Remove an announcement
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user exists in database
|
||||||
|
const userExistsInDB = await userExists(session.user.id);
|
||||||
|
|
||||||
|
if (!userExistsInDB) {
|
||||||
|
console.error("User not found in database:", session.user.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "User not found",
|
||||||
|
details: `The user ID from your session (${session.user.id}) doesn't exist in the database.`
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin, entrepreneurship, or communication role
|
||||||
|
const userRole = session.user.role || [];
|
||||||
|
const roles = Array.isArray(userRole) ? userRole : [userRole];
|
||||||
|
const hasAdminAccess = roles.some(role =>
|
||||||
|
["admin", "entrepreneurship", "communication"].includes(role)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasAdminAccess) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Check if announcement exists
|
||||||
|
const announcement = await prisma.announcement.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!announcement) {
|
||||||
|
return NextResponse.json({ error: "Announcement not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the announcement
|
||||||
|
await prisma.announcement.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Announcement deleted successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting announcement:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
// Use a type guard to safely access the 'code' property
|
||||||
|
const errorCode = typeof error === 'object' && error !== null && 'code' in error
|
||||||
|
? (error as { code: unknown }).code?.toString() || "UNKNOWN"
|
||||||
|
: "UNKNOWN";
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Failed to delete announcement",
|
||||||
|
details: errorMessage,
|
||||||
|
code: errorCode
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/api/announcements/route.ts
Normal file
130
app/api/announcements/route.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user exists in the database
|
||||||
|
*/
|
||||||
|
async function userExists(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return !!user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking if user exists:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET - Retrieve all announcements (with role filtering)
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user role from session
|
||||||
|
const userRole = session.user.role || [];
|
||||||
|
const roles = Array.isArray(userRole) ? userRole : [userRole];
|
||||||
|
|
||||||
|
// Query announcements based on role
|
||||||
|
const announcements = await prisma.announcement.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ targetRoles: { has: "all" } },
|
||||||
|
{ targetRoles: { hasSome: roles } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(announcements);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching announcements:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch announcements" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Create a new announcement
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin, entrepreneurship, or communication role
|
||||||
|
const userRole = session.user.role || [];
|
||||||
|
const roles = Array.isArray(userRole) ? userRole : [userRole];
|
||||||
|
const hasAdminAccess = roles.some(role =>
|
||||||
|
["admin", "entrepreneurship", "communication"].includes(role)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasAdminAccess) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const { title, content, targetRoles } = await req.json();
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
if (!title || !content || !targetRoles || !targetRoles.length) {
|
||||||
|
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user exists in database (using session user id)
|
||||||
|
console.log("Verifying user ID:", session.user.id);
|
||||||
|
|
||||||
|
const userExistsInDB = await userExists(session.user.id);
|
||||||
|
|
||||||
|
if (!userExistsInDB) {
|
||||||
|
console.error("User not found in database:", session.user.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "User not found",
|
||||||
|
details: `The user ID from your session (${session.user.id}) doesn't exist in the database. This may be due to a session/database mismatch or the user hasn't been synced to the application database.`
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new announcement
|
||||||
|
const announcement = await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
targetRoles,
|
||||||
|
authorId: session.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(announcement, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating announcement:", error);
|
||||||
|
// Return more detailed error information
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
// Use a type guard to safely access the 'code' property
|
||||||
|
const errorCode = typeof error === 'object' && error !== null && 'code' in error
|
||||||
|
? (error as { code: unknown }).code?.toString() || "UNKNOWN"
|
||||||
|
: "UNKNOWN";
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Failed to create announcement",
|
||||||
|
details: errorMessage,
|
||||||
|
code: errorCode
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "../options";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
|
|
||||||
113
app/api/auth/debug-keycloak/route.ts
Normal file
113
app/api/auth/debug-keycloak/route.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Get Keycloak URL
|
||||||
|
const keycloakUrl = process.env.KEYCLOAK_BASE_URL || process.env.KEYCLOAK_ISSUER || process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
|
||||||
|
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||||
|
const adminUsername = process.env.KEYCLOAK_ADMIN_USERNAME;
|
||||||
|
const adminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD;
|
||||||
|
const realm = process.env.KEYCLOAK_REALM;
|
||||||
|
|
||||||
|
// Log all relevant environment variables (without exposing secrets)
|
||||||
|
const envVars = {
|
||||||
|
hasKeycloakUrl: !!keycloakUrl,
|
||||||
|
keycloakUrl,
|
||||||
|
hasClientId: !!clientId,
|
||||||
|
clientId,
|
||||||
|
hasClientSecret: !!clientSecret,
|
||||||
|
hasAdminUsername: !!adminUsername,
|
||||||
|
hasAdminPassword: !!adminPassword,
|
||||||
|
realm,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!keycloakUrl) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing Keycloak URL',
|
||||||
|
message: 'KEYCLOAK_BASE_URL, KEYCLOAK_ISSUER, or NEXT_PUBLIC_KEYCLOAK_ISSUER is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing Client ID',
|
||||||
|
message: 'KEYCLOAK_CLIENT_ID is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!realm) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing Realm',
|
||||||
|
message: 'KEYCLOAK_REALM is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Environment variables check:', envVars);
|
||||||
|
|
||||||
|
// Try direct authentication using the application realm, not master
|
||||||
|
const url = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
|
||||||
|
// Try client credentials if available
|
||||||
|
if (clientSecret) {
|
||||||
|
formData.append('client_id', clientId);
|
||||||
|
formData.append('client_secret', clientSecret);
|
||||||
|
formData.append('grant_type', 'client_credentials');
|
||||||
|
console.log('Using client credentials flow');
|
||||||
|
}
|
||||||
|
// Fall back to password grant
|
||||||
|
else if (adminUsername && adminPassword) {
|
||||||
|
formData.append('client_id', clientId);
|
||||||
|
formData.append('username', adminUsername);
|
||||||
|
formData.append('password', adminPassword);
|
||||||
|
formData.append('grant_type', 'password');
|
||||||
|
console.log('Using password grant flow');
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing authentication credentials',
|
||||||
|
message: 'Either client_secret or admin username/password is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Testing authentication to: ${url}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Response status: ${response.status}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Authentication error:', data);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Authentication failed',
|
||||||
|
details: data,
|
||||||
|
requestedUrl: url,
|
||||||
|
clientId: clientId,
|
||||||
|
grantType: formData.get('grant_type')
|
||||||
|
}, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success! Return sanitized token info (not the actual tokens)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
tokenType: data.token_type,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
hasAccessToken: !!data.access_token,
|
||||||
|
hasRefreshToken: !!data.refresh_token,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing Keycloak connection:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to test Keycloak connection',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/api/auth/end-sso-session/route.ts
Normal file
102
app/api/auth/end-sso-session/route.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '../options';
|
||||||
|
import { getKeycloakAdminClient } from '@/lib/keycloak';
|
||||||
|
import { jwtDecode } from 'jwt-decode';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint to end the Keycloak SSO session for the current user
|
||||||
|
* This uses Keycloak Admin API to explicitly logout the user from all sessions,
|
||||||
|
* which clears the realm-wide SSO session, not just the client session.
|
||||||
|
*
|
||||||
|
* This ensures that when a user logs out from the dashboard, they are also
|
||||||
|
* logged out from all other applications that share the same Keycloak realm.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get the current session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized', message: 'No active session' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the ID token to extract user information
|
||||||
|
const idToken = session.idToken;
|
||||||
|
if (!idToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing ID token', message: 'Cannot end SSO session without ID token' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the ID token to get the user's Keycloak subject (user ID)
|
||||||
|
let userId: string;
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode<{ sub: string }>(idToken);
|
||||||
|
userId = decoded.sub;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decoding ID token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid ID token', message: 'Failed to decode ID token' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Keycloak Admin Client
|
||||||
|
let adminClient;
|
||||||
|
try {
|
||||||
|
adminClient = await getKeycloakAdminClient();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Keycloak admin client:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Keycloak admin error', message: 'Failed to connect to Keycloak admin API' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout the user from all sessions using Admin API
|
||||||
|
// This will end the SSO session, not just the client session
|
||||||
|
try {
|
||||||
|
await adminClient.users.logout({ id: userId });
|
||||||
|
console.log(`Successfully ended SSO session for user: ${userId}`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'SSO session ended successfully',
|
||||||
|
userId: userId
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error ending SSO session:', error);
|
||||||
|
|
||||||
|
// If the error is that the user doesn't exist or session doesn't exist,
|
||||||
|
// that's okay - they're already logged out
|
||||||
|
if (error?.response?.status === 404 || error?.status === 404) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'User session not found (already logged out)',
|
||||||
|
userId: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to end SSO session',
|
||||||
|
message: error?.message || 'Unknown error',
|
||||||
|
details: error?.response?.data || error
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error in end-sso-session endpoint:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
app/api/auth/mark-logout/route.ts
Normal file
36
app/api/auth/mark-logout/route.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint to mark that a logout has occurred
|
||||||
|
* This sets a server-side cookie that will force the login prompt on next sign-in
|
||||||
|
*
|
||||||
|
* This ensures that after logout, users are asked for credentials even if
|
||||||
|
* a Keycloak SSO session still exists.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Logout marked successfully'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set HttpOnly cookie to mark logout (5 minutes)
|
||||||
|
// This cookie will be checked in signin page to force prompt=login
|
||||||
|
response.cookies.set('force_login_prompt', 'true', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 300 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking logout:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
579
app/api/auth/options.ts
Normal file
579
app/api/auth/options.ts
Normal file
@ -0,0 +1,579 @@
|
|||||||
|
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||||
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
interface KeycloakProfile {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
roles?: string[];
|
||||||
|
preferred_username?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
realm_access?: {
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
groups?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecodedToken {
|
||||||
|
realm_access?: {
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
username: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
role: string[];
|
||||||
|
nextcloudInitialized?: boolean;
|
||||||
|
};
|
||||||
|
accessToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JWT {
|
||||||
|
sub?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
accessTokenExpires?: number;
|
||||||
|
role?: string[];
|
||||||
|
username?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
error?: string;
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredEnvVar(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtendedJWT = {
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
accessTokenExpires?: number;
|
||||||
|
sub?: string;
|
||||||
|
role?: string[];
|
||||||
|
username?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
error?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Circuit breaker to prevent infinite refresh loops
|
||||||
|
// Tracks last failed refresh attempt per user to implement cooldown
|
||||||
|
const refreshCooldown = new Map<string, number>();
|
||||||
|
const REFRESH_COOLDOWN_MS = 5000; // 5 seconds - don't retry refresh for 5s after failure
|
||||||
|
|
||||||
|
// Cleanup old entries periodically to prevent memory leak
|
||||||
|
function cleanupRefreshCooldown() {
|
||||||
|
if (refreshCooldown.size > 1000) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [userId, failureTime] of refreshCooldown.entries()) {
|
||||||
|
// Remove entries older than 10x cooldown period
|
||||||
|
if (now - failureTime > REFRESH_COOLDOWN_MS * 10) {
|
||||||
|
refreshCooldown.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(token: ExtendedJWT) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: token.refreshToken || '',
|
||||||
|
}),
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshedTokens = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Check if the error is due to invalid session (e.g., user logged out from iframe)
|
||||||
|
const errorType = refreshedTokens.error;
|
||||||
|
const errorDescription = refreshedTokens.error_description || '';
|
||||||
|
|
||||||
|
// Session invalide (logout depuis iframe ou Keycloak)
|
||||||
|
if (errorType === 'invalid_grant' ||
|
||||||
|
errorDescription.includes('Session not active') ||
|
||||||
|
errorDescription.includes('Token is not active') ||
|
||||||
|
errorDescription.includes('Session expired')) {
|
||||||
|
logger.info("Keycloak session invalidated, marking token for removal");
|
||||||
|
// Return token with specific error to trigger session invalidation
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh token expiré (inactivité prolongée)
|
||||||
|
if (errorType === 'invalid_grant' &&
|
||||||
|
errorDescription.includes('Refresh token expired')) {
|
||||||
|
logger.info("Refresh token expired, user needs to re-authenticate");
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "RefreshTokenExpired",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw refreshedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: refreshedTokens.access_token,
|
||||||
|
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
|
||||||
|
// Keep existing ID token (Keycloak doesn't return new ID token on refresh)
|
||||||
|
idToken: token.idToken,
|
||||||
|
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
||||||
|
error: undefined, // Clear any previous errors
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Error refreshing access token", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
errorType: error?.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if it's an invalid_grant error (session invalidated)
|
||||||
|
if (error?.error === 'invalid_grant' ||
|
||||||
|
error?.error_description?.includes('Session not active') ||
|
||||||
|
error?.error_description?.includes('Token is not active') ||
|
||||||
|
error?.error_description?.includes('Session expired')) {
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "RefreshAccessTokenError",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
providers: [
|
||||||
|
KeycloakProvider({
|
||||||
|
clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"),
|
||||||
|
clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"),
|
||||||
|
issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"),
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: "openid profile email roles",
|
||||||
|
// prompt: "login" removed - will be added conditionally after logout
|
||||||
|
// This allows SSO to work naturally for legitimate users
|
||||||
|
// prompt will be forced via custom signin route when needed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
profile(profile) {
|
||||||
|
// Note: realm_access.roles might not be in ID token
|
||||||
|
// Roles will be extracted from access token in JWT callback
|
||||||
|
const roles = profile.realm_access?.roles || [];
|
||||||
|
|
||||||
|
// Clean up roles by removing ROLE_ prefix and converting to lowercase
|
||||||
|
const cleanRoles = roles.map((role: string) =>
|
||||||
|
role.replace(/^ROLE_/, '').toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name ?? profile.preferred_username,
|
||||||
|
email: profile.email,
|
||||||
|
first_name: profile.given_name ?? '',
|
||||||
|
last_name: profile.family_name ?? '',
|
||||||
|
username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '',
|
||||||
|
role: cleanRoles, // Will be updated in JWT callback from access token
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
// 4 hours session timeout for security (reduces attack window from 30 days to 4 hours)
|
||||||
|
// Token refresh mechanism automatically renews session if user is active
|
||||||
|
// Users only need to re-authenticate if inactive longer than Keycloak refresh token lifetime
|
||||||
|
maxAge: 4 * 60 * 60, // 4 hours (14,400 seconds)
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: `next-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callbackUrl: {
|
||||||
|
name: `next-auth.callback-url`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
csrfToken: {
|
||||||
|
name: `next-auth.csrf-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
name: `next-auth.state`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, account, profile }) {
|
||||||
|
// Initial sign-in: account and profile are present
|
||||||
|
if (account && profile) {
|
||||||
|
|
||||||
|
const keycloakProfile = profile as KeycloakProfile;
|
||||||
|
|
||||||
|
// Extract roles from access token (not from profile/ID token)
|
||||||
|
// Keycloak typically puts realm_access.roles in the access token, not the ID token
|
||||||
|
let roles: string[] = [];
|
||||||
|
|
||||||
|
// First, try to get roles from profile (ID token) - may be empty
|
||||||
|
if (keycloakProfile.realm_access?.roles) {
|
||||||
|
roles = keycloakProfile.realm_access.roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no roles in profile, try to decode access token
|
||||||
|
if (roles.length === 0 && account.access_token) {
|
||||||
|
try {
|
||||||
|
const decodedAccessToken = jwtDecode<DecodedToken>(account.access_token);
|
||||||
|
|
||||||
|
if (decodedAccessToken.realm_access?.roles) {
|
||||||
|
roles = decodedAccessToken.realm_access.roles;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error decoding access token for roles', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still no roles, try groups as fallback (some Keycloak configs use groups instead)
|
||||||
|
if (roles.length === 0 && keycloakProfile.groups && Array.isArray(keycloakProfile.groups)) {
|
||||||
|
roles = keycloakProfile.groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanRoles = roles.map((role: string) =>
|
||||||
|
role.replace(/^ROLE_/, '').toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
token.accessToken = account.access_token ?? '';
|
||||||
|
token.refreshToken = account.refresh_token ?? '';
|
||||||
|
token.idToken = account.id_token ?? '';
|
||||||
|
// expires_at from Keycloak is in seconds since epoch, convert to milliseconds
|
||||||
|
token.accessTokenExpires = account.expires_at ? account.expires_at * 1000 : Date.now() + 3600 * 1000;
|
||||||
|
token.sub = keycloakProfile.sub;
|
||||||
|
token.role = cleanRoles;
|
||||||
|
token.username = keycloakProfile.preferred_username ?? '';
|
||||||
|
token.first_name = keycloakProfile.given_name ?? '';
|
||||||
|
token.last_name = keycloakProfile.family_name ?? '';
|
||||||
|
// IMPORTANT: Set email and name for session callback
|
||||||
|
token.email = keycloakProfile.email ?? null;
|
||||||
|
token.name = keycloakProfile.name ?? keycloakProfile.preferred_username ?? null;
|
||||||
|
|
||||||
|
// Return immediately on initial sign-in - don't try to refresh tokens we just received
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent requests: check existing token
|
||||||
|
if (token.accessToken) {
|
||||||
|
try {
|
||||||
|
const decoded = jwtDecode<DecodedToken>(token.accessToken as string);
|
||||||
|
if (decoded.realm_access?.roles) {
|
||||||
|
const roles = decoded.realm_access.roles;
|
||||||
|
const cleanRoles = roles.map((role: string) =>
|
||||||
|
role.replace(/^ROLE_/, '').toLowerCase()
|
||||||
|
);
|
||||||
|
token.role = cleanRoles;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error decoding token for roles', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIRCUIT BREAKER: If we already know the session is invalid, don't try to refresh again
|
||||||
|
// This prevents infinite refresh loops when session is invalidated
|
||||||
|
if (token.error === "SessionNotActive") {
|
||||||
|
const userId = token.sub || 'unknown';
|
||||||
|
const lastFailure = refreshCooldown.get(userId) || 0;
|
||||||
|
const timeSinceFailure = Date.now() - lastFailure;
|
||||||
|
|
||||||
|
// If we recently failed, return error immediately (cooldown active)
|
||||||
|
if (timeSinceFailure < REFRESH_COOLDOWN_MS) {
|
||||||
|
logger.debug('Refresh cooldown active, skipping refresh attempt', {
|
||||||
|
userId,
|
||||||
|
timeSinceFailure,
|
||||||
|
cooldownRemaining: REFRESH_COOLDOWN_MS - timeSinceFailure,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown expired, but session still invalid - don't retry
|
||||||
|
// Return error token to trigger sign-out
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired and needs refresh
|
||||||
|
// accessTokenExpires is in milliseconds
|
||||||
|
const expiresAt = token.accessTokenExpires as number;
|
||||||
|
if (expiresAt && Date.now() < expiresAt) {
|
||||||
|
// Token is still valid, return as-is
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIRCUIT BREAKER: Check if we recently failed to refresh for this user
|
||||||
|
const userId = token.sub || 'unknown';
|
||||||
|
const lastFailure = refreshCooldown.get(userId) || 0;
|
||||||
|
const timeSinceFailure = Date.now() - lastFailure;
|
||||||
|
|
||||||
|
if (timeSinceFailure < REFRESH_COOLDOWN_MS) {
|
||||||
|
// Too soon after failure, return error token immediately
|
||||||
|
logger.debug('Refresh cooldown active, skipping refresh attempt', {
|
||||||
|
userId,
|
||||||
|
timeSinceFailure,
|
||||||
|
cooldownRemaining: REFRESH_COOLDOWN_MS - timeSinceFailure,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired or invalidated, try to refresh
|
||||||
|
if (!token.refreshToken) {
|
||||||
|
console.log("No refresh token available, cannot refresh");
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
error: "NoRefreshToken",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedToken = await refreshAccessToken(token);
|
||||||
|
|
||||||
|
// If refresh failed due to invalid session, record failure and set cooldown
|
||||||
|
if (refreshedToken.error === "SessionNotActive") {
|
||||||
|
refreshCooldown.set(userId, Date.now());
|
||||||
|
cleanupRefreshCooldown(); // Prevent memory leak
|
||||||
|
|
||||||
|
logger.info("Keycloak session invalidated, setting cooldown", {
|
||||||
|
userId,
|
||||||
|
cooldownMs: REFRESH_COOLDOWN_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a token that will cause session callback to return null
|
||||||
|
return {
|
||||||
|
...refreshedToken,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If refresh failed with invalid_grant (token not active), also record failure
|
||||||
|
if (refreshedToken.error === "RefreshAccessTokenError" && !refreshedToken.accessToken) {
|
||||||
|
refreshCooldown.set(userId, Date.now());
|
||||||
|
cleanupRefreshCooldown();
|
||||||
|
|
||||||
|
logger.info("Refresh token invalid, setting cooldown", {
|
||||||
|
userId,
|
||||||
|
cooldownMs: REFRESH_COOLDOWN_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...refreshedToken,
|
||||||
|
accessToken: undefined,
|
||||||
|
refreshToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
error: "SessionNotActive",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - clear any previous failure record
|
||||||
|
if (refreshedToken.accessToken) {
|
||||||
|
refreshCooldown.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshedToken;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
try {
|
||||||
|
// Minimal structured trace without leaking full token contents
|
||||||
|
logger.debug('[SESSION_CALLBACK] Start', {
|
||||||
|
hasError: !!token.error,
|
||||||
|
hasAccessToken: !!token.accessToken,
|
||||||
|
hasRefreshToken: !!token.refreshToken,
|
||||||
|
});
|
||||||
|
// If session was invalidated or tokens are missing, return null to sign out
|
||||||
|
if (token.error === "SessionNotActive" ||
|
||||||
|
token.error === "NoRefreshToken" ||
|
||||||
|
!token.accessToken ||
|
||||||
|
!token.refreshToken) {
|
||||||
|
logger.info("[SESSION_CALLBACK] Session invalidated or tokens missing, user will be signed out", {
|
||||||
|
error: token.error,
|
||||||
|
hasAccessToken: !!token.accessToken,
|
||||||
|
hasRefreshToken: !!token.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return null to make NextAuth treat user as unauthenticated
|
||||||
|
// This will trigger automatic redirect to sign-in page
|
||||||
|
// The client-side code will detect session invalidation by checking for
|
||||||
|
// session cookie existence when status is unauthenticated
|
||||||
|
return null as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, throw to trigger error handling
|
||||||
|
if (token.error) {
|
||||||
|
logger.error("Token error in session callback", { error: token.error });
|
||||||
|
throw new Error(token.error as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoles = Array.isArray(token.role) ? token.role : [];
|
||||||
|
// Validate required fields
|
||||||
|
if (!token.sub) {
|
||||||
|
logger.error('Missing token.sub (user ID) in session callback');
|
||||||
|
throw new Error('Missing user ID in token');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.user = {
|
||||||
|
id: token.sub as string,
|
||||||
|
email: (token.email ?? null) as string | null,
|
||||||
|
name: (token.name ?? null) as string | null,
|
||||||
|
image: null,
|
||||||
|
username: (token.username ?? '') as string,
|
||||||
|
first_name: (token.first_name ?? '') as string,
|
||||||
|
last_name: (token.last_name ?? '') as string,
|
||||||
|
role: userRoles,
|
||||||
|
nextcloudInitialized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
session.accessToken = token.accessToken as string | undefined;
|
||||||
|
session.idToken = token.idToken as string | undefined;
|
||||||
|
session.refreshToken = token.refreshToken as string | undefined;
|
||||||
|
|
||||||
|
logger.debug('[SESSION_CALLBACK] Session created', {
|
||||||
|
userId: session.user.id,
|
||||||
|
hasEmail: !!session.user.email,
|
||||||
|
rolesCount: session.user.role.length,
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Critical error in session callback', {
|
||||||
|
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
hasSub: !!token.sub,
|
||||||
|
hasEmail: !!token.email,
|
||||||
|
hasAccessToken: !!token.accessToken,
|
||||||
|
hasRefreshToken: !!token.refreshToken,
|
||||||
|
});
|
||||||
|
// Re-throw to let NextAuth handle it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/signin',
|
||||||
|
error: '/signin',
|
||||||
|
},
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
// Add error handling events
|
||||||
|
events: {
|
||||||
|
async signIn({ user, account, profile }) {
|
||||||
|
logger.info('[NEXTAUTH] Sign-in event', {
|
||||||
|
userId: user?.id,
|
||||||
|
emailPresent: !!user?.email,
|
||||||
|
provider: account?.provider,
|
||||||
|
});
|
||||||
|
// Don't return anything - NextAuth events should return void
|
||||||
|
},
|
||||||
|
async signOut() {
|
||||||
|
logger.info('[NEXTAUTH] Sign-out event');
|
||||||
|
},
|
||||||
|
// Note: 'error' event doesn't exist in NextAuth EventCallbacks
|
||||||
|
// Errors are handled in callbacks and pages.error
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// JWT interface is declared in the module declaration above
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
101
app/api/auth/refresh-keycloak-session/route.ts
Normal file
101
app/api/auth/refresh-keycloak-session/route.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from '../options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint to refresh Keycloak session cookies
|
||||||
|
* This ensures Keycloak session is active before loading iframe applications
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check if user just logged out (prevent refresh after logout)
|
||||||
|
const logoutCookie = request.cookies.get('logout_in_progress');
|
||||||
|
if (logoutCookie?.value === 'true') {
|
||||||
|
console.log('Logout in progress, refusing to refresh session');
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'SessionInvalidated',
|
||||||
|
message: 'User is logging out. Please sign in again.'
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.accessToken || !session?.refreshToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No active session' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the Keycloak token to renew session cookies
|
||||||
|
// This will also refresh Keycloak session cookies in the browser
|
||||||
|
const keycloakIssuer = process.env.KEYCLOAK_ISSUER;
|
||||||
|
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!keycloakIssuer || !clientId || !clientSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Keycloak configuration missing' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the refresh token to get new tokens
|
||||||
|
// This will also refresh Keycloak session cookies
|
||||||
|
const response = await fetch(`${keycloakIssuer}/protocol/openid-connect/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: session.refreshToken as string,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
console.error('Failed to refresh Keycloak session:', error);
|
||||||
|
|
||||||
|
// If token is invalid (user logged out from Keycloak), return specific error
|
||||||
|
if (error.error === 'invalid_grant' ||
|
||||||
|
error.error_description?.includes('Token is not active') ||
|
||||||
|
error.error_description?.includes('Session not active')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'SessionInvalidated',
|
||||||
|
message: 'Keycloak session was invalidated. Please sign in again.',
|
||||||
|
details: error
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to refresh Keycloak session', details: error },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await response.json();
|
||||||
|
|
||||||
|
// Return success - the Keycloak session cookies are now refreshed
|
||||||
|
// The new tokens will be stored in NextAuth on next request
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Keycloak session refreshed',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing Keycloak session:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
167
app/api/calendar/route.ts
Normal file
167
app/api/calendar/route.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// GET events
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const start = searchParams.get("start");
|
||||||
|
const end = searchParams.get("end");
|
||||||
|
|
||||||
|
// First get all calendars for the user
|
||||||
|
const calendars = await prisma.calendar.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then get events with calendar information
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
calendarId: {
|
||||||
|
in: calendars.map(cal => cal.id)
|
||||||
|
},
|
||||||
|
...(start && end
|
||||||
|
? {
|
||||||
|
start: {
|
||||||
|
gte: new Date(start),
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
lte: new Date(end),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
calendar: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
start: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map the events to include calendar color and name
|
||||||
|
const eventsWithCalendarInfo = events.map(event => ({
|
||||||
|
...event,
|
||||||
|
calendarColor: event.calendar.color,
|
||||||
|
calendarName: event.calendar.name,
|
||||||
|
calendar: undefined, // Remove the full calendar object
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(eventsWithCalendarInfo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching events:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors du chargement des événements" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST new event
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Non autorisé" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await req.json();
|
||||||
|
const { title, description, start, end, location, calendarId } = data;
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: new Date(start),
|
||||||
|
end: new Date(end),
|
||||||
|
isAllDay: data.allDay || false,
|
||||||
|
location: location || null,
|
||||||
|
calendarId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating event:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la création de l'événement" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT update event
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { id, ...data } = body;
|
||||||
|
|
||||||
|
const event = await prisma.event.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating event:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error updating event" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE event
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Event ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting event:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Error deleting event" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
273
app/api/calendars/[id]/events/[eventId]/route.ts
Normal file
273
app/api/calendars/[id]/events/[eventId]/route.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the GET request to retrieve a specific event from a calendar.
|
||||||
|
*
|
||||||
|
* @param req - The incoming Next.js request object.
|
||||||
|
* @param params - An object containing the route parameters.
|
||||||
|
* @param params.id - The ID of the calendar.
|
||||||
|
* @param params.eventId - The ID of the event.
|
||||||
|
* @returns A JSON response containing the event data or an error message.
|
||||||
|
*
|
||||||
|
* The function performs the following steps:
|
||||||
|
* 1. Checks if the user is authenticated.
|
||||||
|
* 2. Verifies that the calendar exists and belongs to the authenticated user.
|
||||||
|
* 3. Verifies that the event exists and belongs to the specified calendar.
|
||||||
|
* 4. Returns the event data if all checks pass.
|
||||||
|
*
|
||||||
|
* Possible error responses:
|
||||||
|
* - 401: User is not authenticated.
|
||||||
|
* - 403: User is not authorized to access the calendar.
|
||||||
|
* - 404: Calendar or event not found.
|
||||||
|
* - 500: Server error occurred while retrieving the event.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
props: { params: Promise<{ id: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier que le calendrier appartient à l'utilisateur
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que l'événement appartient bien au calendrier
|
||||||
|
if (event.calendarId !== params.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé dans ce calendrier" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la récupération de l'événement:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the PUT request to update an event in a calendar.
|
||||||
|
*
|
||||||
|
* @param req - The incoming request object.
|
||||||
|
* @param params - The route parameters containing the calendar ID and event ID.
|
||||||
|
* @returns A JSON response indicating the result of the update operation.
|
||||||
|
*
|
||||||
|
* The function performs the following steps:
|
||||||
|
* 1. Retrieves the server session to check if the user is authenticated.
|
||||||
|
* 2. Verifies that the calendar belongs to the authenticated user.
|
||||||
|
* 3. Checks if the event exists and belongs to the specified calendar.
|
||||||
|
* 4. Validates the request payload to ensure required fields are present.
|
||||||
|
* 5. Updates the event with the provided data.
|
||||||
|
* 6. Returns the updated event or an appropriate error response.
|
||||||
|
*
|
||||||
|
* Possible error responses:
|
||||||
|
* - 401: User is not authenticated.
|
||||||
|
* - 403: User is not authorized to update the calendar.
|
||||||
|
* - 404: Calendar or event not found.
|
||||||
|
* - 400: Validation error for missing required fields.
|
||||||
|
* - 500: Server error during the update process.
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
props: { params: Promise<{ id: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier que le calendrier appartient à l'utilisateur
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que l'événement existe et appartient au calendrier
|
||||||
|
const existingEvent = await prisma.event.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingEvent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingEvent.calendarId !== params.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé dans ce calendrier" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, start, end, location, isAllDay } =
|
||||||
|
await req.json();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Le titre est requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start || !end) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Les dates de début et de fin sont requises" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEvent = await prisma.event.update({
|
||||||
|
where: {
|
||||||
|
id: params.eventId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: new Date(start),
|
||||||
|
end: new Date(end),
|
||||||
|
location,
|
||||||
|
isAllDay: isAllDay || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updatedEvent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la mise à jour de l'événement:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the DELETE request to remove an event from a calendar.
|
||||||
|
*
|
||||||
|
* @param req - The incoming Next.js request object.
|
||||||
|
* @param params - An object containing the parameters from the request URL.
|
||||||
|
* @param params.id - The ID of the calendar.
|
||||||
|
* @param params.eventId - The ID of the event to be deleted.
|
||||||
|
* @returns A JSON response indicating the result of the deletion operation.
|
||||||
|
*
|
||||||
|
* @throws Will return a 401 status if the user is not authenticated.
|
||||||
|
* @throws Will return a 404 status if the calendar or event is not found.
|
||||||
|
* @throws Will return a 403 status if the user is not authorized to delete the event.
|
||||||
|
* @throws Will return a 500 status if there is a server error during the deletion process.
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
props: { params: Promise<{ id: string; eventId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier que le calendrier appartient à l'utilisateur
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que l'événement existe et appartient au calendrier
|
||||||
|
const existingEvent = await prisma.event.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingEvent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingEvent.calendarId !== params.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé dans ce calendrier" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: {
|
||||||
|
id: params.eventId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression de l'événement:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/api/calendars/[id]/events/route.ts
Normal file
168
app/api/calendars/[id]/events/route.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the GET request to retrieve events for a specific calendar.
|
||||||
|
*
|
||||||
|
* @param req - The incoming request object.
|
||||||
|
* @param params - An object containing the route parameters.
|
||||||
|
* @param params.id - The ID of the calendar.
|
||||||
|
* @returns A JSON response containing the events or an error message.
|
||||||
|
*
|
||||||
|
* The function performs the following steps:
|
||||||
|
* 1. Retrieves the server session to check if the user is authenticated.
|
||||||
|
* 2. Verifies that the calendar exists and belongs to the authenticated user.
|
||||||
|
* 3. Retrieves and filters events based on optional date parameters (`start` and `end`).
|
||||||
|
* 4. Returns the filtered events in ascending order of their start date.
|
||||||
|
*
|
||||||
|
* Possible response statuses:
|
||||||
|
* - 200: Successfully retrieved events.
|
||||||
|
* - 401: User is not authenticated.
|
||||||
|
* - 403: User is not authorized to access the calendar.
|
||||||
|
* - 404: Calendar not found.
|
||||||
|
* - 500: Server error occurred while retrieving events.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier que le calendrier appartient à l'utilisateur
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les paramètres de filtrage de date s'ils existent
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const startParam = searchParams.get("start");
|
||||||
|
const endParam = searchParams.get("end");
|
||||||
|
|
||||||
|
let whereClause: any = {
|
||||||
|
calendarId: params.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startParam && endParam) {
|
||||||
|
whereClause.AND = [
|
||||||
|
{
|
||||||
|
start: {
|
||||||
|
lte: new Date(endParam),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
end: {
|
||||||
|
gte: new Date(startParam),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: {
|
||||||
|
start: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la récupération des événements:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the creation of a new event for a specific calendar.
|
||||||
|
*
|
||||||
|
* @param req - The incoming request object.
|
||||||
|
* @param params - An object containing the route parameters.
|
||||||
|
* @param params.id - The ID of the calendar to which the event will be added.
|
||||||
|
* @returns A JSON response with the created event data or an error message.
|
||||||
|
*
|
||||||
|
* @throws {401} If the user is not authenticated.
|
||||||
|
* @throws {404} If the specified calendar is not found.
|
||||||
|
* @throws {403} If the user is not authorized to add events to the specified calendar.
|
||||||
|
* @throws {400} If the required fields (title, start, end) are missing.
|
||||||
|
* @throws {500} If there is a server error during event creation.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, start, end, location, isAllDay } =
|
||||||
|
await req.json();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Le titre est requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!start || !end) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Les dates de début et de fin sont requises" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: new Date(start),
|
||||||
|
end: new Date(end),
|
||||||
|
location,
|
||||||
|
isAllDay: isAllDay || false,
|
||||||
|
calendarId: params.id,
|
||||||
|
userId: session.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(event, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création de l'événement:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
185
app/api/calendars/[id]/route.ts
Normal file
185
app/api/calendars/[id]/route.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles GET requests to retrieve a calendar by its ID.
|
||||||
|
*
|
||||||
|
* @param req - The incoming request object.
|
||||||
|
* @param params - An object containing the route parameters.
|
||||||
|
* @param params.id - The ID of the calendar to retrieve.
|
||||||
|
* @returns A JSON response containing the calendar data if found and authorized,
|
||||||
|
* or an error message with the appropriate HTTP status code.
|
||||||
|
*
|
||||||
|
* - 401: If the user is not authenticated.
|
||||||
|
* - 403: If the user is not authorized to access the calendar.
|
||||||
|
* - 404: If the calendar is not found.
|
||||||
|
* - 500: If there is a server error during the retrieval process.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification que l'utilisateur est bien le propriétaire
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(calendar);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la récupération du calendrier:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the PUT request to update a calendar.
|
||||||
|
*
|
||||||
|
* @param req - The incoming request object.
|
||||||
|
* @param params - An object containing the route parameters.
|
||||||
|
* @param params.id - The ID of the calendar to update.
|
||||||
|
* @returns A JSON response with the updated calendar data or an error message.
|
||||||
|
*
|
||||||
|
* @throws {401} If the user is not authenticated.
|
||||||
|
* @throws {404} If the calendar is not found.
|
||||||
|
* @throws {403} If the user is not authorized to update the calendar.
|
||||||
|
* @throws {400} If the calendar name is not provided.
|
||||||
|
* @throws {500} If there is a server error during the update process.
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Vérifier que le calendrier existe et appartient à l'utilisateur
|
||||||
|
const existingCalendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingCalendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingCalendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, color, description } = await req.json();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Le nom du calendrier est requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCalendar = await prisma.calendar.update({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updatedCalendar);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la mise à jour du calendrier:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the DELETE request to remove a calendar by its ID.
|
||||||
|
*
|
||||||
|
* @param req - The incoming Next.js request object.
|
||||||
|
* @param params - An object containing the route parameters.
|
||||||
|
* @param params.id - The ID of the calendar to be deleted.
|
||||||
|
* @returns A JSON response indicating the result of the deletion operation.
|
||||||
|
*
|
||||||
|
* - If the user is not authenticated, returns a 401 status with an error message.
|
||||||
|
* - If the calendar does not exist, returns a 404 status with an error message.
|
||||||
|
* - If the calendar does not belong to the authenticated user, returns a 403 status with an error message.
|
||||||
|
* - If the calendar is successfully deleted, returns a 204 status with no content.
|
||||||
|
* - If an error occurs during the deletion process, returns a 500 status with an error message.
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify calendar ownership
|
||||||
|
const calendar = await prisma.calendar.findFirst({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé ou non autorisé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the calendar (this will also delete all associated events due to the cascade delete)
|
||||||
|
await prisma.calendar.delete({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression du calendrier:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/calendars/[id]/share/route.ts
Normal file
51
app/api/calendars/[id]/share/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// Non testé, généré automatiquement par IA
|
||||||
|
export async function POST(req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier que le calendrier appartient à l'utilisateur
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calendar.userId !== session.user.username) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer un token de partage
|
||||||
|
const shareToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// Dans une implémentation réelle, on stockerait ce token dans la base de données
|
||||||
|
// avec une date d'expiration et des permissions
|
||||||
|
|
||||||
|
const shareUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/calendars/shared/${shareToken}`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
shareUrl,
|
||||||
|
shareToken,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création du lien de partage:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/api/calendars/default/route.ts
Normal file
57
app/api/calendars/default/route.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the creation of a default calendar for an authenticated user.
|
||||||
|
*
|
||||||
|
* This function checks if the user already has a default calendar named "Calendrier principal".
|
||||||
|
* If such a calendar exists, it returns the existing calendar.
|
||||||
|
* Otherwise, it creates a new default calendar for the user.
|
||||||
|
*
|
||||||
|
* @param req - The incoming request object.
|
||||||
|
* @returns A JSON response containing the existing or newly created calendar, or an error message.
|
||||||
|
*
|
||||||
|
* @throws Will return a 401 status if the user is not authenticated.
|
||||||
|
* @throws Will return a 500 status if there is a server error during the calendar creation process.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.username) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérifier si l'utilisateur a déjà un calendrier par défaut
|
||||||
|
const existingCalendar = await prisma.calendar.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: session.user.username,
|
||||||
|
name: "Calendrier principal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCalendar) {
|
||||||
|
return NextResponse.json(existingCalendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un calendrier par défaut
|
||||||
|
const calendar = await prisma.calendar.create({
|
||||||
|
data: {
|
||||||
|
name: "Calendrier principal",
|
||||||
|
color: "#0082c9",
|
||||||
|
description: "Calendrier principal",
|
||||||
|
userId: session.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(calendar, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Erreur lors de la création du calendrier par défaut:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/api/calendars/route.ts
Normal file
132
app/api/calendars/route.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getCachedCalendarData, cacheCalendarData } from "@/lib/redis";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the GET request to retrieve calendars for the authenticated user.
|
||||||
|
*
|
||||||
|
* @param {NextRequest} req - The incoming request object.
|
||||||
|
* @returns {Promise<NextResponse>} - A promise that resolves to a JSON response containing the calendars or an error message.
|
||||||
|
*
|
||||||
|
* The function performs the following steps:
|
||||||
|
* 1. Retrieves the server session using `getServerSession`.
|
||||||
|
* 2. Checks if the user is authenticated by verifying the presence of `session.user.id`.
|
||||||
|
* - If not authenticated, returns a 401 response with an error message.
|
||||||
|
* 3. Attempts to fetch the calendars associated with the authenticated user from the database.
|
||||||
|
* - If successful, returns the calendars in a JSON response.
|
||||||
|
* - If an error occurs during the database query, logs the error and returns a 500 response with an error message.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for force refresh parameter
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
||||||
|
|
||||||
|
// Try to get data from cache if not forcing refresh
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cachedData = await getCachedCalendarData(session.user.id);
|
||||||
|
if (cachedData) {
|
||||||
|
logger.debug('[CALENDAR] Using cached calendar data', {
|
||||||
|
userId: session.user.id,
|
||||||
|
calendarCount: cachedData.length,
|
||||||
|
});
|
||||||
|
return NextResponse.json(cachedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cache or forcing refresh, fetch from database
|
||||||
|
logger.debug('[CALENDAR] Fetching calendar data from database', {
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const calendars = await prisma.calendar.findMany({
|
||||||
|
where: {
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
orderBy: {
|
||||||
|
start: 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('[CALENDAR] Fetched calendars with events', {
|
||||||
|
userId: session.user.id,
|
||||||
|
count: calendars.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
await cacheCalendarData(session.user.id, calendars);
|
||||||
|
|
||||||
|
return NextResponse.json(calendars);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[CALENDAR] Erreur lors de la récupération des calendriers', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the POST request to create a new calendar.
|
||||||
|
*
|
||||||
|
* @param {NextRequest} req - The incoming request object.
|
||||||
|
* @returns {Promise<NextResponse>} The response object containing the created calendar or an error message.
|
||||||
|
*
|
||||||
|
* @throws {Error} If there is an issue with the request or server.
|
||||||
|
*
|
||||||
|
* The function performs the following steps:
|
||||||
|
* 1. Retrieves the server session using `getServerSession`.
|
||||||
|
* 2. Checks if the user is authenticated by verifying the presence of `session.user.id`.
|
||||||
|
* 3. Parses the request body to extract `name`, `color`, and `description`.
|
||||||
|
* 4. Validates that the `name` field is provided.
|
||||||
|
* 5. Creates a new calendar entry in the database using Prisma.
|
||||||
|
* 6. Returns the created calendar with a 201 status code.
|
||||||
|
* 7. Catches and logs any errors, returning a 500 status code with an error message.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { name, color, description } = await req.json();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Le nom du calendrier est requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await prisma.calendar.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
color: color || "#0082c9",
|
||||||
|
description,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(calendar, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création du calendrier:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/api/courrier/[id]/flag/route.ts
Normal file
70
app/api/courrier/[id]/flag/route.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { toggleEmailFlag } from '@/lib/services/email-service';
|
||||||
|
import { invalidateEmailContentCache, invalidateFolderCache } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await params as per Next.js requirements
|
||||||
|
const params = await context.params;
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing email ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { flagged, folder, accountId } = await request.json();
|
||||||
|
|
||||||
|
if (typeof flagged !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid 'flagged' parameter. Must be a boolean." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFolder = folder || "INBOX";
|
||||||
|
const effectiveAccountId = accountId || 'default';
|
||||||
|
|
||||||
|
// Use the email service to toggle the flag
|
||||||
|
// Note: You'll need to implement this function in email-service.ts
|
||||||
|
const success = await toggleEmailFlag(
|
||||||
|
session.user.id,
|
||||||
|
id,
|
||||||
|
flagged,
|
||||||
|
normalizedFolder,
|
||||||
|
effectiveAccountId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to ${flagged ? 'star' : 'unstar'} email` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for this email
|
||||||
|
await invalidateEmailContentCache(session.user.id, effectiveAccountId, id);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in flag API:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/api/courrier/[id]/mark-read/route.ts
Normal file
97
app/api/courrier/[id]/mark-read/route.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { markEmailReadStatus } from '@/lib/services/email-service';
|
||||||
|
import { invalidateEmailContentCache, invalidateFolderCache } from '@/lib/redis';
|
||||||
|
|
||||||
|
// Global cache reference (will be moved to a proper cache solution in the future)
|
||||||
|
declare global {
|
||||||
|
var emailListCache: { [key: string]: { data: any, timestamp: number } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to invalidate cache for a specific folder
|
||||||
|
const invalidateCache = (userId: string, folder?: string) => {
|
||||||
|
if (!global.emailListCache) return;
|
||||||
|
|
||||||
|
Object.keys(global.emailListCache).forEach(key => {
|
||||||
|
// If folder is provided, only invalidate that folder's cache
|
||||||
|
if (folder) {
|
||||||
|
if (key.includes(`${userId}:${folder}`)) {
|
||||||
|
delete global.emailListCache[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise invalidate all user's caches
|
||||||
|
if (key.startsWith(`${userId}:`)) {
|
||||||
|
delete global.emailListCache[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark email as read
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await params as per Next.js requirements
|
||||||
|
const params = await context.params;
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing email ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isRead, folder, accountId } = await request.json();
|
||||||
|
|
||||||
|
if (typeof isRead !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid 'isRead' parameter. Must be a boolean." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFolder = folder || "INBOX";
|
||||||
|
const effectiveAccountId = accountId || 'default';
|
||||||
|
|
||||||
|
// Use the email service to mark the email
|
||||||
|
const success = await markEmailReadStatus(
|
||||||
|
session.user.id,
|
||||||
|
id,
|
||||||
|
isRead,
|
||||||
|
normalizedFolder,
|
||||||
|
effectiveAccountId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to ${isRead ? 'mark email as read' : 'mark email as unread'}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for this email
|
||||||
|
await invalidateEmailContentCache(session.user.id, effectiveAccountId, id);
|
||||||
|
|
||||||
|
// Also invalidate folder cache to update unread counts
|
||||||
|
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in mark-read API:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/api/courrier/[id]/route.ts
Normal file
136
app/api/courrier/[id]/route.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* NOTE: This endpoint is now mostly for backward compatibility.
|
||||||
|
* The main email list API (/api/courrier) now fetches full email content,
|
||||||
|
* so individual email fetching is typically not needed.
|
||||||
|
* This is kept for cases where individual email access is still required
|
||||||
|
* or when using older client code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getEmailContent, markEmailReadStatus } from '@/lib/services/email-service';
|
||||||
|
import { getCachedEmailContent, invalidateEmailContentCache } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await params as per Next.js requirements
|
||||||
|
const params = await context.params;
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing email ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const folder = searchParams.get("folder") || "INBOX";
|
||||||
|
const accountId = searchParams.get("accountId");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get email from Redis cache first
|
||||||
|
const cachedEmail = await getCachedEmailContent(session.user.id, accountId || 'default', id);
|
||||||
|
if (cachedEmail) {
|
||||||
|
console.log(`Using cached email content for ${session.user.id}:${id}`);
|
||||||
|
return NextResponse.json(cachedEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cache miss for email content ${session.user.id}:${id}, fetching from IMAP`);
|
||||||
|
|
||||||
|
// Use the email service to fetch the email content
|
||||||
|
const email = await getEmailContent(session.user.id, id, folder, accountId || undefined);
|
||||||
|
|
||||||
|
// Return the complete email object
|
||||||
|
return NextResponse.json(email);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error fetching email content:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch email content", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in GET:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a route to mark email as read
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await params as per Next.js requirements
|
||||||
|
const params = await context.params;
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing email ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action } = await request.json();
|
||||||
|
|
||||||
|
if (action !== 'mark-read' && action !== 'mark-unread') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid action. Supported actions: mark-read, mark-unread" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const folder = searchParams.get("folder") || "INBOX";
|
||||||
|
const accountId = searchParams.get("accountId");
|
||||||
|
|
||||||
|
// Use the email service to mark the email
|
||||||
|
const success = await markEmailReadStatus(
|
||||||
|
session.user.id,
|
||||||
|
id,
|
||||||
|
action === 'mark-read',
|
||||||
|
folder
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to ${action === 'mark-read' ? 'mark email as read' : 'mark email as unread'}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for this email
|
||||||
|
await invalidateEmailContentCache(session.user.id, accountId || 'default', id);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in POST:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/courrier/account-details/route.ts
Normal file
64
app/api/courrier/account-details/route.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get accountId from query params
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const accountId = searchParams.get('accountId');
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Account ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account details from database, including connection details
|
||||||
|
const account = await prisma.mailCredentials.findFirst({
|
||||||
|
where: {
|
||||||
|
id: accountId,
|
||||||
|
userId: session.user.id
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
host: true,
|
||||||
|
port: true,
|
||||||
|
secure: true,
|
||||||
|
display_name: true,
|
||||||
|
color: true,
|
||||||
|
// Don't include the password in the response
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Account not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(account);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account details:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to fetch account details',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/api/courrier/account-folders/route.ts
Normal file
170
app/api/courrier/account-folders/route.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getMailboxes } from '@/lib/services/email-service';
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getCachedEmailCredentials } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Verify auth
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const accountId = searchParams.get('accountId');
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If specific accountId is provided, get folders for that account
|
||||||
|
if (accountId) {
|
||||||
|
// Get account from database
|
||||||
|
const account = await prisma.mailCredentials.findFirst({
|
||||||
|
where: {
|
||||||
|
id: accountId,
|
||||||
|
userId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
password: true,
|
||||||
|
host: true,
|
||||||
|
port: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Account not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to IMAP server for this account
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: account.host,
|
||||||
|
port: account.port,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: account.email,
|
||||||
|
pass: account.password || undefined,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Get folders for this account
|
||||||
|
const folders = await getMailboxes(client);
|
||||||
|
|
||||||
|
// Close connection
|
||||||
|
await client.logout();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
accountId,
|
||||||
|
email: account.email,
|
||||||
|
folders
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to connect to IMAP server',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get all accounts for this user
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
password: true,
|
||||||
|
host: true,
|
||||||
|
port: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
accounts: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch folders for each account individually
|
||||||
|
const accountsWithFolders = await Promise.all(accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
// Connect to IMAP server for this specific account
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: account.host,
|
||||||
|
port: account.port,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: account.email,
|
||||||
|
pass: account.password || undefined,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Get folders for this account
|
||||||
|
const folders = await getMailboxes(client);
|
||||||
|
|
||||||
|
// Close connection
|
||||||
|
await client.logout();
|
||||||
|
|
||||||
|
// Add display_name and color from database
|
||||||
|
const metadata = await prisma.$queryRaw`
|
||||||
|
SELECT display_name, color
|
||||||
|
FROM "MailCredentials"
|
||||||
|
WHERE id = ${account.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const displayMetadata = Array.isArray(metadata) && metadata.length > 0 ? metadata[0] : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
display_name: displayMetadata.display_name || account.email,
|
||||||
|
color: displayMetadata.color || "#0082c9",
|
||||||
|
folders
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching folders for account ${account.email}:`, error);
|
||||||
|
// Return fallback folders on error
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
folders: ['INBOX', 'Sent', 'Drafts', 'Trash'] // Fallback folders on error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
accounts: accountsWithFolders
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to get account folders',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/courrier/account-list/route.ts
Normal file
51
app/api/courrier/account-list/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all email accounts for this user
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
host: true,
|
||||||
|
port: true,
|
||||||
|
secure: true,
|
||||||
|
display_name: true,
|
||||||
|
color: true,
|
||||||
|
smtp_host: true,
|
||||||
|
smtp_port: true,
|
||||||
|
smtp_secure: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Never return passwords
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
accounts
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching email accounts:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to fetch email accounts',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
339
app/api/courrier/account/route.ts
Normal file
339
app/api/courrier/account/route.ts
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { saveUserEmailCredentials, testEmailConnection } from '@/lib/services/email-service';
|
||||||
|
import { invalidateFolderCache } from '@/lib/redis';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
// Define EmailCredentials interface inline since we're having import issues
|
||||||
|
interface EmailCredentials {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure?: boolean;
|
||||||
|
smtp_host?: string;
|
||||||
|
smtp_port?: number;
|
||||||
|
smtp_secure?: boolean;
|
||||||
|
display_name?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user exists in the database
|
||||||
|
*/
|
||||||
|
async function userExists(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
return !!user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking if user exists:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure user exists in database, creating if missing
|
||||||
|
* Uses session data from Keycloak to populate user record
|
||||||
|
*/
|
||||||
|
async function ensureUserExists(session: any): Promise<void> {
|
||||||
|
const userId = session.user.id;
|
||||||
|
const userEmail = session.user.email;
|
||||||
|
|
||||||
|
if (!userId || !userEmail) {
|
||||||
|
throw new Error('Missing required user data in session');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
console.log(`User ${userId} already exists in database`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User doesn't exist, create it
|
||||||
|
console.log(`User ${userId} not found in database, creating from session data...`);
|
||||||
|
|
||||||
|
// Generate a temporary random password (not used for auth, Keycloak handles that)
|
||||||
|
const tempPassword = await bcrypt.hash(Math.random().toString(36).slice(-10), 10);
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId, // Use Keycloak user ID
|
||||||
|
email: userEmail,
|
||||||
|
password: tempPassword, // Temporary password (Keycloak handles authentication)
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Successfully created user ${userId} (${userEmail}) in database`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error ensuring user exists:`, error);
|
||||||
|
// If it's a unique constraint error, user might have been created by another request
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
console.log('User may have been created by concurrent request, continuing...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user exists in database (create if missing)
|
||||||
|
// This handles cases where the database was reset but users still exist in Keycloak
|
||||||
|
try {
|
||||||
|
await ensureUserExists(session);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error ensuring user exists:`, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to ensure user exists in database',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json().catch(e => {
|
||||||
|
console.error('Error parsing request body:', e);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the request (but hide password)
|
||||||
|
console.log('Adding account:', {
|
||||||
|
...body,
|
||||||
|
password: body.password ? '***' : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure,
|
||||||
|
smtp_host,
|
||||||
|
smtp_port,
|
||||||
|
smtp_secure,
|
||||||
|
display_name,
|
||||||
|
color
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const missingFields = [];
|
||||||
|
if (!email) missingFields.push('email');
|
||||||
|
if (!password) missingFields.push('password');
|
||||||
|
if (!host) missingFields.push('host');
|
||||||
|
if (port === undefined) missingFields.push('port');
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
console.error(`Missing required fields: ${missingFields.join(', ')}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Required fields missing: ${missingFields.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix common hostname errors - strip http/https prefixes
|
||||||
|
let cleanHost = host;
|
||||||
|
if (cleanHost.startsWith('http://')) {
|
||||||
|
cleanHost = cleanHost.substring(7);
|
||||||
|
} else if (cleanHost.startsWith('https://')) {
|
||||||
|
cleanHost = cleanHost.substring(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create credentials object
|
||||||
|
const credentials: EmailCredentials = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
host: cleanHost,
|
||||||
|
port: typeof port === 'string' ? parseInt(port) : port,
|
||||||
|
secure: secure ?? true,
|
||||||
|
// Optional SMTP settings
|
||||||
|
...(smtp_host && { smtp_host }),
|
||||||
|
...(smtp_port && { smtp_port: typeof smtp_port === 'string' ? parseInt(smtp_port) : smtp_port }),
|
||||||
|
...(smtp_secure !== undefined && { smtp_secure }),
|
||||||
|
// Optional display settings
|
||||||
|
...(display_name && { display_name }),
|
||||||
|
...(color && { color })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test connection before saving
|
||||||
|
console.log(`Testing connection before saving for user ${session.user.id}`);
|
||||||
|
const testResult = await testEmailConnection(credentials);
|
||||||
|
|
||||||
|
if (!testResult.imap) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Connection test failed: ${testResult.error || 'Could not connect to IMAP server'}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials to database and cache
|
||||||
|
console.log(`Saving credentials for user: ${session.user.id}`);
|
||||||
|
await saveUserEmailCredentials(session.user.id, email, credentials);
|
||||||
|
console.log(`Email account successfully added for user ${session.user.id}`);
|
||||||
|
|
||||||
|
// Fetch the created account from the database
|
||||||
|
const createdAccount = await prisma.mailCredentials.findFirst({
|
||||||
|
where: { userId: session.user.id, email },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
display_name: true,
|
||||||
|
color: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate all folder caches for this user/account
|
||||||
|
await invalidateFolderCache(session.user.id, email, '*');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
account: createdAccount,
|
||||||
|
message: 'Email account added successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding email account:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to add email account',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const accountId = searchParams.get('accountId');
|
||||||
|
if (!accountId) {
|
||||||
|
return NextResponse.json({ error: 'Missing accountId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Find the account
|
||||||
|
const account = await prisma.mailCredentials.findFirst({
|
||||||
|
where: { id: accountId, userId: session.user.id },
|
||||||
|
});
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json({ error: 'Account not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
// Delete from database
|
||||||
|
await prisma.mailCredentials.delete({ where: { id: accountId } });
|
||||||
|
// Invalidate cache
|
||||||
|
await invalidateFolderCache(session.user.id, account.email, '*');
|
||||||
|
return NextResponse.json({ success: true, message: 'Account deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting account:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete account', details: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
const { accountId, newPassword, display_name, color } = body;
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Account ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if at least one of the fields is provided
|
||||||
|
if (!newPassword && !display_name && !color) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'At least one field to update is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the account belongs to the user
|
||||||
|
const account = await prisma.mailCredentials.findFirst({
|
||||||
|
where: {
|
||||||
|
id: accountId,
|
||||||
|
userId: session.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Account not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update data object
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
// Add password if provided
|
||||||
|
if (newPassword) {
|
||||||
|
updateData.password = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add display_name if provided
|
||||||
|
if (display_name !== undefined) {
|
||||||
|
updateData.display_name = display_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add color if provided
|
||||||
|
if (color) {
|
||||||
|
updateData.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the account
|
||||||
|
await prisma.mailCredentials.update({
|
||||||
|
where: { id: accountId },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Account updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating account:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to update account',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/courrier/credentials/route.ts
Normal file
54
app/api/courrier/credentials/route.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getUserEmailCredentials } from '@/lib/services/email-service';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Get server session to verify authentication
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!session || !session.user) {
|
||||||
|
console.error("Unauthorized access to mail credentials");
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Unauthorized"
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
if (!userId) {
|
||||||
|
console.error("User ID not found in session");
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "User ID not found"
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch mail credentials for this user using our service
|
||||||
|
const mailCredentials = await getUserEmailCredentials(userId);
|
||||||
|
|
||||||
|
// If no credentials found
|
||||||
|
if (!mailCredentials) {
|
||||||
|
console.warn(`No mail credentials found for user ID: ${userId}`);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Mail credentials not found"
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the credentials (excluding password)
|
||||||
|
console.log(`Successfully retrieved mail credentials for user ID: ${userId}`);
|
||||||
|
return NextResponse.json({
|
||||||
|
credentials: {
|
||||||
|
email: mailCredentials.email,
|
||||||
|
host: mailCredentials.host,
|
||||||
|
port: mailCredentials.port
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error retrieving mail credentials:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Internal Server Error"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/api/courrier/debug-account/route.ts
Normal file
182
app/api/courrier/debug-account/route.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getCachedEmailCredentials, getCachedImapSession } from '@/lib/redis';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getMailboxes } from '@/lib/services/email-service';
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
// Verify auth
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const debugData: any = {
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
redis: {
|
||||||
|
emailCredentials: null,
|
||||||
|
session: null
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
accounts: [],
|
||||||
|
schema: null
|
||||||
|
},
|
||||||
|
imap: {
|
||||||
|
connectionAttempt: false,
|
||||||
|
connected: false,
|
||||||
|
folders: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check Redis cache for credentials
|
||||||
|
try {
|
||||||
|
const credentials = await getCachedEmailCredentials(userId, 'default');
|
||||||
|
if (credentials) {
|
||||||
|
debugData.redis.emailCredentials = {
|
||||||
|
found: true,
|
||||||
|
email: credentials.email,
|
||||||
|
host: credentials.host,
|
||||||
|
port: credentials.port,
|
||||||
|
hasPassword: !!credentials.password,
|
||||||
|
hasSmtp: !!credentials.smtp_host
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
debugData.redis.emailCredentials = { found: false };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugData.redis.emailCredentials = {
|
||||||
|
error: e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis for session data (which contains folders)
|
||||||
|
try {
|
||||||
|
const sessionData = await getCachedImapSession(userId);
|
||||||
|
if (sessionData) {
|
||||||
|
debugData.redis.session = {
|
||||||
|
found: true,
|
||||||
|
lastActive: new Date(sessionData.lastActive).toISOString(),
|
||||||
|
hasFolders: !!sessionData.mailboxes,
|
||||||
|
folderCount: sessionData.mailboxes?.length || 0,
|
||||||
|
folders: sessionData.mailboxes || []
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
debugData.redis.session = { found: false };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugData.redis.session = {
|
||||||
|
error: e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get database schema information to help diagnose issues
|
||||||
|
try {
|
||||||
|
const schemaInfo = await prisma.$queryRaw`
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'MailCredentials'
|
||||||
|
AND table_schema = 'public'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
debugData.database.schema = schemaInfo;
|
||||||
|
} catch (e) {
|
||||||
|
debugData.database.schemaError = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database for accounts
|
||||||
|
try {
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
host: true,
|
||||||
|
port: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also try to get additional fields from raw query
|
||||||
|
const accountsWithMetadata = await Promise.all(accounts.map(async (account) => {
|
||||||
|
try {
|
||||||
|
const rawAccount = await prisma.$queryRaw`
|
||||||
|
SELECT display_name, color, smtp_host, smtp_port, smtp_secure, secure
|
||||||
|
FROM "MailCredentials"
|
||||||
|
WHERE id = ${account.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const metadata = Array.isArray(rawAccount) && rawAccount.length > 0
|
||||||
|
? rawAccount[0]
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
display_name: metadata.display_name,
|
||||||
|
color: metadata.color,
|
||||||
|
smtp_host: metadata.smtp_host,
|
||||||
|
smtp_port: metadata.smtp_port,
|
||||||
|
smtp_secure: metadata.smtp_secure,
|
||||||
|
secure: metadata.secure
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
_queryError: e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
debugData.database.accounts = accountsWithMetadata;
|
||||||
|
debugData.database.accountCount = accounts.length;
|
||||||
|
} catch (e) {
|
||||||
|
debugData.database.error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get IMAP folders for the main account
|
||||||
|
if (debugData.redis.emailCredentials?.found || debugData.database.accountCount > 0) {
|
||||||
|
try {
|
||||||
|
debugData.imap.connectionAttempt = true;
|
||||||
|
|
||||||
|
// Use cached credentials
|
||||||
|
const credentials = await getCachedEmailCredentials(userId, 'default');
|
||||||
|
|
||||||
|
if (credentials && credentials.email && credentials.password) {
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: credentials.host,
|
||||||
|
port: credentials.port,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: credentials.email,
|
||||||
|
pass: credentials.password,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
debugData.imap.connected = true;
|
||||||
|
|
||||||
|
// Get folders
|
||||||
|
const folders = await getMailboxes(client);
|
||||||
|
debugData.imap.folders = folders;
|
||||||
|
|
||||||
|
// Close connection
|
||||||
|
await client.logout();
|
||||||
|
} else {
|
||||||
|
debugData.imap.error = "No valid credentials found";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugData.imap.error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(debugData);
|
||||||
|
}
|
||||||
133
app/api/courrier/delete/route.ts
Normal file
133
app/api/courrier/delete/route.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getImapConnection } from '@/lib/services/email-service';
|
||||||
|
import { invalidateFolderCache } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract request body
|
||||||
|
const body = await request.json();
|
||||||
|
const { emailIds, folder, accountId } = body;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) {
|
||||||
|
console.error('[DELETE API] Missing or invalid emailIds parameter:', emailIds);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing or invalid emailIds parameter" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
console.error('[DELETE API] Missing folder parameter');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing folder parameter" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract account ID from folder name if present and none was explicitly provided
|
||||||
|
const folderAccountId = folder.includes(':') ? folder.split(':')[0] : undefined;
|
||||||
|
|
||||||
|
// Use the most specific account ID available
|
||||||
|
const effectiveAccountId = folderAccountId || accountId || 'default';
|
||||||
|
|
||||||
|
// Normalize folder name by removing account prefix if present
|
||||||
|
const normalizedFolder = folder.includes(':') ? folder.split(':')[1] : folder;
|
||||||
|
|
||||||
|
console.log(`[DELETE API] Deleting ${emailIds.length} emails from folder ${normalizedFolder}, account ${effectiveAccountId}`);
|
||||||
|
|
||||||
|
// Get IMAP connection
|
||||||
|
const client = await getImapConnection(session.user.id, effectiveAccountId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Open the mailbox
|
||||||
|
await client.mailboxOpen(normalizedFolder);
|
||||||
|
|
||||||
|
// Check if we're already in the trash folder
|
||||||
|
const inTrash = normalizedFolder.toLowerCase() === 'trash' ||
|
||||||
|
normalizedFolder.toLowerCase() === 'bin' ||
|
||||||
|
normalizedFolder.toLowerCase() === 'deleted';
|
||||||
|
|
||||||
|
if (inTrash) {
|
||||||
|
// If we're in trash, mark as deleted
|
||||||
|
console.log(`[DELETE API] In trash folder, marking emails as deleted: ${emailIds.join(', ')}`);
|
||||||
|
|
||||||
|
// Mark messages as deleted
|
||||||
|
for (const emailId of emailIds) {
|
||||||
|
await client.messageFlagsAdd(emailId, ['\\Deleted']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If not in trash, move to trash
|
||||||
|
console.log(`[DELETE API] Moving emails to trash: ${emailIds.join(', ')}`);
|
||||||
|
|
||||||
|
// Try to find the trash folder
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
let trashFolder = 'Trash';
|
||||||
|
|
||||||
|
// Look for common trash folder names
|
||||||
|
const trashFolderNames = ['Trash', 'TRASH', 'Bin', 'Deleted', 'Deleted Items'];
|
||||||
|
for (const folder of mailboxes) {
|
||||||
|
if (trashFolderNames.includes(folder.name) ||
|
||||||
|
trashFolderNames.some(name => folder.name.toLowerCase().includes(name.toLowerCase()))) {
|
||||||
|
trashFolder = folder.name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move messages to trash
|
||||||
|
for (const emailId of emailIds) {
|
||||||
|
try {
|
||||||
|
// Convert the emailId to a number if it's a string
|
||||||
|
const uid = typeof emailId === 'string' ? parseInt(emailId, 10) : emailId;
|
||||||
|
|
||||||
|
// Debug logging for troubleshooting
|
||||||
|
console.log(`[DELETE API] Moving email with UID ${uid} to trash folder "${trashFolder}"`);
|
||||||
|
|
||||||
|
// Use the correct syntax for messageMove method
|
||||||
|
await client.messageMove(uid.toString(), trashFolder, { uid: true });
|
||||||
|
|
||||||
|
console.log(`[DELETE API] Successfully moved email ${uid} to trash`);
|
||||||
|
} catch (moveError) {
|
||||||
|
console.error(`[DELETE API] Error moving email ${emailId} to trash:`, moveError);
|
||||||
|
// Continue with other emails even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for source folder
|
||||||
|
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||||
|
|
||||||
|
// Also invalidate trash folder cache
|
||||||
|
await invalidateFolderCache(session.user.id, effectiveAccountId, 'Trash');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `${emailIds.length} email(s) deleted successfully`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// Always close the mailbox
|
||||||
|
await client.mailboxClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing mailbox:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DELETE API] Error processing delete request:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete emails", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/api/courrier/emails/route.ts
Normal file
88
app/api/courrier/emails/route.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getEmails } from '@/lib/services/email-service';
|
||||||
|
import {
|
||||||
|
getCachedEmailList,
|
||||||
|
cacheEmailList,
|
||||||
|
invalidateFolderCache
|
||||||
|
} from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract query parameters
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const perPage = parseInt(searchParams.get("perPage") || "20");
|
||||||
|
const folder = searchParams.get("folder") || "INBOX";
|
||||||
|
const searchQuery = searchParams.get("search") || "";
|
||||||
|
const accountId = searchParams.get("accountId") || "";
|
||||||
|
const checkOnly = searchParams.get("checkOnly") === "true";
|
||||||
|
|
||||||
|
// Log exact parameters received by the API
|
||||||
|
console.log(`[API/emails] Received request with: folder=${folder}, accountId=${accountId}, page=${page}, checkOnly=${checkOnly}`);
|
||||||
|
|
||||||
|
// Parameter normalization
|
||||||
|
// If folder contains an account prefix, extract it but DO NOT use it
|
||||||
|
// Always prioritize the explicit accountId parameter
|
||||||
|
let normalizedFolder = folder;
|
||||||
|
let effectiveAccountId = accountId || 'default';
|
||||||
|
|
||||||
|
if (folder.includes(':')) {
|
||||||
|
const parts = folder.split(':');
|
||||||
|
normalizedFolder = parts[1];
|
||||||
|
|
||||||
|
console.log(`[API/emails] Folder has prefix, normalized to ${normalizedFolder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[API/emails] Using normalized parameters: folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
|
||||||
|
|
||||||
|
// Try to get from Redis cache first, but only if it's not a search query and not checkOnly
|
||||||
|
if (!searchQuery && !checkOnly) {
|
||||||
|
console.log(`[API/emails] Checking Redis cache for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||||
|
const cachedEmails = await getCachedEmailList(
|
||||||
|
session.user.id,
|
||||||
|
effectiveAccountId,
|
||||||
|
normalizedFolder,
|
||||||
|
page,
|
||||||
|
perPage
|
||||||
|
);
|
||||||
|
if (cachedEmails) {
|
||||||
|
console.log(`[API/emails] Using Redis cached emails for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||||
|
return NextResponse.json(cachedEmails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[API/emails] Redis cache miss for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}, fetching emails from IMAP`);
|
||||||
|
|
||||||
|
// Use the email service to fetch emails
|
||||||
|
const emailsResult = await getEmails(
|
||||||
|
session.user.id,
|
||||||
|
normalizedFolder,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
effectiveAccountId,
|
||||||
|
checkOnly
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[API/emails] Successfully fetched ${emailsResult.emails.length} emails from IMAP for account ${effectiveAccountId}`);
|
||||||
|
|
||||||
|
// Return result
|
||||||
|
return NextResponse.json(emailsResult);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[API/emails] Error fetching emails:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch emails", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/api/courrier/fix-folders/route.ts
Normal file
116
app/api/courrier/fix-folders/route.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getMailboxes } from '@/lib/services/email-service';
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import { cacheImapSession, getCachedImapSession } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
// Verify auth
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const results = {
|
||||||
|
success: false,
|
||||||
|
userId,
|
||||||
|
accountsProcessed: 0,
|
||||||
|
foldersFound: 0,
|
||||||
|
accounts: [] as any[]
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all accounts for this user
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
password: true,
|
||||||
|
host: true,
|
||||||
|
port: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'No email accounts found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each account
|
||||||
|
for (const account of accounts) {
|
||||||
|
try {
|
||||||
|
// Connect to IMAP server for this account
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: account.host,
|
||||||
|
port: account.port,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: account.email,
|
||||||
|
pass: account.password || undefined,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Get folders for this account
|
||||||
|
const folders = await getMailboxes(client);
|
||||||
|
|
||||||
|
// Store the results
|
||||||
|
results.accounts.push({
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
folderCount: folders.length,
|
||||||
|
folders
|
||||||
|
});
|
||||||
|
|
||||||
|
results.foldersFound += folders.length;
|
||||||
|
results.accountsProcessed++;
|
||||||
|
|
||||||
|
// Get existing session data
|
||||||
|
const existingSession = await getCachedImapSession(userId);
|
||||||
|
|
||||||
|
// Update the Redis cache with the folders
|
||||||
|
await cacheImapSession(userId, {
|
||||||
|
...(existingSession || { lastActive: Date.now() }),
|
||||||
|
mailboxes: folders,
|
||||||
|
lastVisit: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close connection
|
||||||
|
await client.logout();
|
||||||
|
} catch (error) {
|
||||||
|
results.accounts.push({
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.success = results.accountsProcessed > 0;
|
||||||
|
|
||||||
|
return NextResponse.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to fix folders',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/api/courrier/microsoft/callback/route.ts
Normal file
149
app/api/courrier/microsoft/callback/route.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { exchangeCodeForTokens } from '@/lib/services/microsoft-oauth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { testEmailConnection, saveUserEmailCredentials } from '@/lib/services/email-service';
|
||||||
|
import { invalidateFolderCache } from '@/lib/redis';
|
||||||
|
import { cacheEmailCredentials } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
const { code, state } = body;
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required parameters' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state parameter to prevent CSRF
|
||||||
|
try {
|
||||||
|
const decodedState = JSON.parse(Buffer.from(state, 'base64').toString());
|
||||||
|
|
||||||
|
// Check if state contains valid userId and is not expired (10 minutes)
|
||||||
|
if (decodedState.userId !== session.user.id ||
|
||||||
|
Date.now() - decodedState.timestamp > 10 * 60 * 1000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid or expired state parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid state parameter' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokens = await exchangeCodeForTokens(code);
|
||||||
|
|
||||||
|
// Extract user email from session instead of generating a fake one
|
||||||
|
// Use the logged-in user's email or a properly formatted address
|
||||||
|
let userEmail = '';
|
||||||
|
if (session.user?.email) {
|
||||||
|
// Use the user's actual email if available
|
||||||
|
userEmail = session.user.email;
|
||||||
|
} else {
|
||||||
|
// Fallback to a default format - don't add @microsoft.com
|
||||||
|
userEmail = `unknown-user-${session.user.id}@outlook.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Using email: ${userEmail} for Microsoft account`);
|
||||||
|
|
||||||
|
// Create credentials object for Microsoft account
|
||||||
|
const credentials = {
|
||||||
|
email: userEmail,
|
||||||
|
// Password is empty for OAuth accounts - use a placeholder to meet database schema requirements
|
||||||
|
password: 'microsoft-oauth2-account',
|
||||||
|
// Use Microsoft's IMAP server for Outlook/Office365
|
||||||
|
host: 'outlook.office365.com',
|
||||||
|
port: 993,
|
||||||
|
secure: true,
|
||||||
|
|
||||||
|
// OAuth specific fields
|
||||||
|
useOAuth: true, // Make sure this is explicitly set
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
tokenExpiry: Date.now() + (tokens.expires_in * 1000),
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
display_name: `Microsoft (${userEmail})`,
|
||||||
|
color: '#0078D4', // Microsoft blue
|
||||||
|
|
||||||
|
// SMTP settings for Microsoft
|
||||||
|
smtp_host: 'smtp.office365.com',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_secure: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log Microsoft authentication details
|
||||||
|
console.log(`Microsoft OAuth credentials prepared for ${userEmail}:`, {
|
||||||
|
useOAuth: credentials.useOAuth,
|
||||||
|
host: credentials.host,
|
||||||
|
hasAccessToken: !!credentials.accessToken,
|
||||||
|
hasRefreshToken: !!credentials.refreshToken,
|
||||||
|
tokenExpiry: new Date(credentials.tokenExpiry).toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection before saving
|
||||||
|
console.log(`Testing Microsoft OAuth connection for user ${session.user.id}`);
|
||||||
|
const testResult = await testEmailConnection(credentials);
|
||||||
|
|
||||||
|
if (!testResult.imap) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Connection test failed: ${testResult.error || 'Could not connect to Microsoft IMAP server'}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials to database and cache
|
||||||
|
console.log(`Saving Microsoft account for user: ${session.user.id}`);
|
||||||
|
await saveUserEmailCredentials(session.user.id, userEmail, credentials);
|
||||||
|
|
||||||
|
// Fetch the created account from the database
|
||||||
|
const createdAccount = await prisma.mailCredentials.findFirst({
|
||||||
|
where: { userId: session.user.id, email: userEmail },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
display_name: true,
|
||||||
|
color: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate any existing folder caches
|
||||||
|
await invalidateFolderCache(session.user.id, userEmail, '*');
|
||||||
|
|
||||||
|
// First cache the credentials in Redis to ensure OAuth data is saved
|
||||||
|
await cacheEmailCredentials(session.user.id, userEmail, credentials);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
account: createdAccount,
|
||||||
|
message: 'Microsoft account added successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing Microsoft callback:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to process Microsoft authentication',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/courrier/microsoft/route.ts
Normal file
41
app/api/courrier/microsoft/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getMicrosoftAuthUrl } from '@/lib/services/microsoft-oauth';
|
||||||
|
|
||||||
|
// Endpoint to initiate Microsoft OAuth flow
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a state parameter with the user's ID to prevent CSRF
|
||||||
|
const state = Buffer.from(JSON.stringify({
|
||||||
|
userId: session.user.id,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})).toString('base64');
|
||||||
|
|
||||||
|
// Generate the authorization URL
|
||||||
|
const authUrl = getMicrosoftAuthUrl(state);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
authUrl,
|
||||||
|
state
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initiating Microsoft OAuth flow:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to initiate Microsoft OAuth flow',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/api/courrier/recache/route.ts
Normal file
49
app/api/courrier/recache/route.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth/next';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { forceRecacheUserCredentials } from '@/lib/services/email-service';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get session to ensure user is authenticated
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
// Force recache credentials
|
||||||
|
const success = await forceRecacheUserCredentials(userId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Credentials recached successfully',
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to recache credentials. Check server logs for details.',
|
||||||
|
userId
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Recache API error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/courrier/refresh/route.ts
Normal file
69
app/api/courrier/refresh/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getEmails } from '@/lib/services/email-service';
|
||||||
|
import { invalidateFolderCache } from '@/lib/redis';
|
||||||
|
import { refreshEmailsInBackground } from '@/lib/services/prefetch-service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint to force refresh email data
|
||||||
|
* This is useful when the user wants to manually refresh or
|
||||||
|
* when the app detects that it's been a while since the last refresh
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract folder and account ID from request body
|
||||||
|
const { folder = 'INBOX', accountId } = await request.json();
|
||||||
|
|
||||||
|
// CRITICAL FIX: Proper folder and account ID handling
|
||||||
|
let normalizedFolder: string;
|
||||||
|
let effectiveAccountId: string;
|
||||||
|
|
||||||
|
if (folder.includes(':')) {
|
||||||
|
// Extract parts if folder already has a prefix
|
||||||
|
const parts = folder.split(':');
|
||||||
|
const folderAccountId = parts[0];
|
||||||
|
normalizedFolder = parts[1];
|
||||||
|
|
||||||
|
// If explicit accountId is provided, it takes precedence
|
||||||
|
effectiveAccountId = accountId || folderAccountId;
|
||||||
|
} else {
|
||||||
|
// No prefix in folder name
|
||||||
|
normalizedFolder = folder;
|
||||||
|
effectiveAccountId = accountId || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[API] Refreshing folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
|
||||||
|
|
||||||
|
// First invalidate the cache for this folder with the effective account ID
|
||||||
|
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||||
|
|
||||||
|
// Then trigger a background refresh with explicit account ID
|
||||||
|
refreshEmailsInBackground(session.user.id, normalizedFolder, 1, 20, effectiveAccountId);
|
||||||
|
|
||||||
|
// Also prefetch page 2 if this is the inbox
|
||||||
|
if (normalizedFolder === 'INBOX') {
|
||||||
|
refreshEmailsInBackground(session.user.id, normalizedFolder, 2, 20, effectiveAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Refresh scheduled for folder: ${folder}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling refresh:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to schedule refresh" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/api/courrier/route.ts
Normal file
149
app/api/courrier/route.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getEmails } from '@/lib/services/email-service';
|
||||||
|
import {
|
||||||
|
getCachedEmailList,
|
||||||
|
cacheEmailList,
|
||||||
|
invalidateFolderCache
|
||||||
|
} from '@/lib/redis';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Simple in-memory cache (will be removed in a future update)
|
||||||
|
interface EmailCacheEntry {
|
||||||
|
data: any;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for 1 minute only
|
||||||
|
const CACHE_TTL = 60 * 1000;
|
||||||
|
const emailListCache: Record<string, EmailCacheEntry> = {};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract query parameters
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
|
const perPage = parseInt(searchParams.get("perPage") || "20");
|
||||||
|
const folder = searchParams.get("folder") || "INBOX";
|
||||||
|
const searchQuery = searchParams.get("search") || "";
|
||||||
|
const accountId = searchParams.get("accountId") || "";
|
||||||
|
const checkOnly = searchParams.get("checkOnly") === "true";
|
||||||
|
|
||||||
|
// CRITICAL FIX: Log exact parameters received by the API
|
||||||
|
console.log(`[API] Received request with: folder=${folder}, accountId=${accountId}, page=${page}, checkOnly=${checkOnly}`);
|
||||||
|
|
||||||
|
// CRITICAL FIX: More robust parameter normalization
|
||||||
|
// 1. If folder contains an account prefix, extract it but DO NOT use it
|
||||||
|
// 2. Always prioritize the explicit accountId parameter
|
||||||
|
let normalizedFolder = folder;
|
||||||
|
let effectiveAccountId = accountId || 'default';
|
||||||
|
|
||||||
|
if (folder.includes(':')) {
|
||||||
|
const parts = folder.split(':');
|
||||||
|
const folderAccountId = parts[0];
|
||||||
|
normalizedFolder = parts[1];
|
||||||
|
|
||||||
|
console.log(`[API] Folder has prefix (${folderAccountId}), normalized to ${normalizedFolder}`);
|
||||||
|
// We intentionally DO NOT use folderAccountId here - the explicit accountId parameter takes precedence
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL FIX: Enhanced logging for parameter resolution
|
||||||
|
console.log(`[API] Using normalized parameters: folder=${normalizedFolder}, accountId=${effectiveAccountId}`);
|
||||||
|
|
||||||
|
// Try to get from Redis cache first, but only if it's not a search query and not checkOnly
|
||||||
|
if (!searchQuery && !checkOnly) {
|
||||||
|
// CRITICAL FIX: Use consistent cache key format with the correct account ID
|
||||||
|
console.log(`[API] Checking Redis cache for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||||
|
const cachedEmails = await getCachedEmailList(
|
||||||
|
session.user.id,
|
||||||
|
effectiveAccountId, // Use effective account ID for consistent cache key
|
||||||
|
normalizedFolder, // Use normalized folder name without prefix
|
||||||
|
page,
|
||||||
|
perPage
|
||||||
|
);
|
||||||
|
if (cachedEmails) {
|
||||||
|
console.log(`[API] Using Redis cached emails for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}`);
|
||||||
|
return NextResponse.json(cachedEmails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[API] Redis cache miss for ${session.user.id}:${effectiveAccountId}:${normalizedFolder}:${page}:${perPage}, fetching emails from IMAP`);
|
||||||
|
|
||||||
|
// Use the email service to fetch emails with the normalized folder and effective account ID
|
||||||
|
// CRITICAL FIX: Pass parameters in the correct order and with proper values
|
||||||
|
const emailsResult = await getEmails(
|
||||||
|
session.user.id, // userId
|
||||||
|
normalizedFolder, // folder (without prefix)
|
||||||
|
page, // page
|
||||||
|
perPage, // perPage
|
||||||
|
effectiveAccountId, // accountId
|
||||||
|
checkOnly // checkOnly flag - only check for new emails without loading full content
|
||||||
|
);
|
||||||
|
|
||||||
|
// CRITICAL FIX: Log when emails are returned from IMAP
|
||||||
|
console.log(`[API] Successfully fetched ${emailsResult.emails.length} emails from IMAP for account ${effectiveAccountId}`);
|
||||||
|
|
||||||
|
// The result is already cached in the getEmails function (if not checkOnly)
|
||||||
|
return NextResponse.json(emailsResult);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[API] Error fetching emails:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch emails", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { emailId, folderName, accountId } = await request.json();
|
||||||
|
|
||||||
|
if (!emailId) {
|
||||||
|
return NextResponse.json({ error: 'Missing emailId parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use account ID or default if not provided
|
||||||
|
const effectiveAccountId = accountId || 'default';
|
||||||
|
|
||||||
|
// Normalize folder name by removing account prefix if present
|
||||||
|
const normalizedFolder = folderName && folderName.includes(':')
|
||||||
|
? folderName.split(':')[1]
|
||||||
|
: folderName;
|
||||||
|
|
||||||
|
// Log the cache invalidation operation
|
||||||
|
console.log(`Invalidating cache for user ${session.user.id}, account ${effectiveAccountId}, folder ${normalizedFolder || 'all folders'}`);
|
||||||
|
|
||||||
|
// Invalidate Redis cache for the folder
|
||||||
|
if (normalizedFolder) {
|
||||||
|
await invalidateFolderCache(session.user.id, effectiveAccountId, normalizedFolder);
|
||||||
|
} else {
|
||||||
|
// If no folder specified, invalidate all folders (using a wildcard pattern)
|
||||||
|
const folders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk'];
|
||||||
|
for (const folder of folders) {
|
||||||
|
await invalidateFolderCache(session.user.id, effectiveAccountId, folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in POST handler:', error);
|
||||||
|
return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/courrier/send/route.ts
Normal file
62
app/api/courrier/send/route.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { sendEmail } from '@/lib/services/email-service';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const { to, cc, bcc, subject, body, attachments } = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!to) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Recipient is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use email service to send the email
|
||||||
|
const result = await sendEmail(session.user.id, {
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
attachments
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to send email',
|
||||||
|
details: result.error
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
messageId: result.messageId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to send email',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
283
app/api/courrier/session/route.ts
Normal file
283
app/api/courrier/session/route.ts
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getMailboxes } from '@/lib/services/email-service';
|
||||||
|
import { getRedisClient } from '@/lib/redis';
|
||||||
|
import { getImapConnection } from '@/lib/services/email-service';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
// Define extended MailCredentials type
|
||||||
|
interface MailCredentials {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
secure?: boolean;
|
||||||
|
smtp_host?: string | null;
|
||||||
|
smtp_port?: number | null;
|
||||||
|
smtp_secure?: boolean | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep track of last prefetch time for each user
|
||||||
|
const lastPrefetchMap = new Map<string, number>();
|
||||||
|
const PREFETCH_COOLDOWN_MS = 30000; // 30 seconds cooldown between prefetches
|
||||||
|
|
||||||
|
// Cache TTL for folders in Redis (5 minutes)
|
||||||
|
const FOLDERS_CACHE_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
// Redis key for folders cache
|
||||||
|
const FOLDERS_CACHE_KEY = (userId: string, accountId: string) => `email:folders:${userId}:${accountId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure user exists in database, creating if missing
|
||||||
|
* Uses session data from Keycloak to populate user record
|
||||||
|
*/
|
||||||
|
async function ensureUserExists(session: any): Promise<void> {
|
||||||
|
const userId = session.user.id;
|
||||||
|
const userEmail = session.user.email;
|
||||||
|
|
||||||
|
if (!userId || !userEmail) {
|
||||||
|
throw new Error('Missing required user data in session');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User doesn't exist, create it
|
||||||
|
console.log(`User ${userId} not found in database, creating from session data...`);
|
||||||
|
|
||||||
|
// Generate a temporary random password (not used for auth, Keycloak handles that)
|
||||||
|
const tempPassword = await bcrypt.hash(Math.random().toString(36).slice(-10), 10);
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId, // Use Keycloak user ID
|
||||||
|
email: userEmail,
|
||||||
|
password: tempPassword, // Temporary password (Keycloak handles authentication)
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Successfully created user ${userId} (${userEmail}) in database`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error ensuring user exists:`, error);
|
||||||
|
// If it's a unique constraint error, user might have been created by another request
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
console.log('User may have been created by concurrent request, continuing...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This endpoint is called when the app initializes to check if the user has email credentials
|
||||||
|
* and to start prefetching email data in the background if they do
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Get Redis connection first to ensure it's available
|
||||||
|
const redis = getRedisClient();
|
||||||
|
if (!redis) {
|
||||||
|
logger.error('[COURRIER_SESSION] Redis connection failed');
|
||||||
|
return NextResponse.json({ error: 'Redis connection failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session with detailed logging
|
||||||
|
logger.debug('[COURRIER_SESSION] Attempting to get server session');
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.error('[COURRIER_SESSION] No session found');
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: false,
|
||||||
|
error: 'No session found'
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user) {
|
||||||
|
logger.error('[COURRIER_SESSION] No user in session');
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: false,
|
||||||
|
error: 'No user in session'
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user.id) {
|
||||||
|
logger.error('[COURRIER_SESSION] No user ID in session');
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: false,
|
||||||
|
error: 'No user ID in session'
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[COURRIER_SESSION] Session validated successfully', {
|
||||||
|
userId: session.user.id,
|
||||||
|
hasEmail: !!session.user.email,
|
||||||
|
hasName: !!session.user.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure user exists in database (create if missing)
|
||||||
|
try {
|
||||||
|
await ensureUserExists(session);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[COURRIER_SESSION] Error ensuring user exists', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
hasEmailCredentials: false,
|
||||||
|
error: 'Failed to ensure user exists in database',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user with their accounts
|
||||||
|
logger.debug('[COURRIER_SESSION] Fetching user with ID', {
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: { mailCredentials: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.error('[COURRIER_SESSION] User not found in database after creation attempt');
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
hasEmailCredentials: false,
|
||||||
|
error: 'User not found in database'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all accounts for the user
|
||||||
|
const accounts = (user.mailCredentials || []) as MailCredentials[];
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
logger.debug('[COURRIER_SESSION] No email accounts found for user', {
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
hasEmailCredentials: false,
|
||||||
|
accounts: [],
|
||||||
|
message: 'No email accounts found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[COURRIER_SESSION] Found accounts for user', {
|
||||||
|
userId: session.user.id,
|
||||||
|
count: accounts.length,
|
||||||
|
emails: accounts.map(a => a.email),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch folders for each account
|
||||||
|
const accountsWithFolders = await Promise.all(
|
||||||
|
accounts.map(async (account: MailCredentials) => {
|
||||||
|
const cacheKey = FOLDERS_CACHE_KEY(user.id, account.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get folders from Redis cache first
|
||||||
|
const cachedFolders = await redis.get(cacheKey);
|
||||||
|
if (cachedFolders) {
|
||||||
|
logger.debug('[COURRIER_SESSION] Using cached folders for account', {
|
||||||
|
email: account.email,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
display_name: account.display_name,
|
||||||
|
color: account.color,
|
||||||
|
folders: JSON.parse(cachedFolders)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in cache, fetch from IMAP
|
||||||
|
logger.debug('[COURRIER_SESSION] Fetching folders from IMAP for account', {
|
||||||
|
email: account.email,
|
||||||
|
});
|
||||||
|
const client = await getImapConnection(user.id, account.id);
|
||||||
|
if (!client) {
|
||||||
|
logger.warn('[COURRIER_SESSION] Failed to get IMAP connection for account', {
|
||||||
|
email: account.email,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
display_name: account.display_name,
|
||||||
|
color: account.color,
|
||||||
|
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = await getMailboxes(client);
|
||||||
|
logger.debug('[COURRIER_SESSION] Fetched folders for account', {
|
||||||
|
email: account.email,
|
||||||
|
count: folders.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the folders in Redis
|
||||||
|
await redis.set(
|
||||||
|
cacheKey,
|
||||||
|
JSON.stringify(folders),
|
||||||
|
'EX',
|
||||||
|
FOLDERS_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
display_name: account.display_name,
|
||||||
|
color: account.color,
|
||||||
|
folders
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[COURRIER_SESSION] Error fetching folders for account', {
|
||||||
|
accountId: account.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
email: account.email,
|
||||||
|
display_name: account.display_name,
|
||||||
|
color: account.color,
|
||||||
|
folders: ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
hasEmailCredentials: true,
|
||||||
|
allAccounts: accountsWithFolders
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[COURRIER_SESSION] Error in session route', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
authenticated: false,
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/api/courrier/test-connection/route.ts
Normal file
137
app/api/courrier/test-connection/route.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json().catch(e => {
|
||||||
|
console.error('Error parsing request body:', e);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log request but hide password
|
||||||
|
console.log('Testing connection with:', {
|
||||||
|
...body,
|
||||||
|
password: body.password ? '***' : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const { email, password, host, port, secure = true } = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!email || !password || !host || !port) {
|
||||||
|
const missing = [];
|
||||||
|
if (!email) missing.push('email');
|
||||||
|
if (!password) missing.push('password');
|
||||||
|
if (!host) missing.push('host');
|
||||||
|
if (!port) missing.push('port');
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Missing required fields: ${missing.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix common hostname errors - strip http/https prefixes
|
||||||
|
let cleanHost = host;
|
||||||
|
if (cleanHost.startsWith('http://')) {
|
||||||
|
cleanHost = cleanHost.substring(7);
|
||||||
|
} else if (cleanHost.startsWith('https://')) {
|
||||||
|
cleanHost = cleanHost.substring(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Testing IMAP connection to ${cleanHost}:${port} for ${email}`);
|
||||||
|
|
||||||
|
// Test IMAP connection
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: cleanHost,
|
||||||
|
port: typeof port === 'string' ? parseInt(port) : port,
|
||||||
|
secure: secure === true || secure === 'true',
|
||||||
|
auth: {
|
||||||
|
user: email,
|
||||||
|
pass: password,
|
||||||
|
},
|
||||||
|
logger: false,
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
},
|
||||||
|
// Set timeout to prevent long waits
|
||||||
|
connectionTimeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
console.log(`IMAP connection successful for ${email}`);
|
||||||
|
|
||||||
|
// Try to list mailboxes
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
const folderNames = mailboxes.map(mailbox => mailbox.path);
|
||||||
|
console.log(`Found ${folderNames.length} folders:`, folderNames.slice(0, 5));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.logout();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore logout errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'IMAP connection successful',
|
||||||
|
details: {
|
||||||
|
host: cleanHost,
|
||||||
|
port,
|
||||||
|
folderCount: folderNames.length,
|
||||||
|
sampleFolders: folderNames.slice(0, 5)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('IMAP connection test failed:', error);
|
||||||
|
|
||||||
|
let friendlyMessage = 'Connection failed';
|
||||||
|
let errorDetails = '';
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorDetails = error.message;
|
||||||
|
|
||||||
|
if (error.message.includes('Invalid login') || error.message.includes('authentication failed')) {
|
||||||
|
friendlyMessage = 'Invalid username or password';
|
||||||
|
} else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
|
||||||
|
friendlyMessage = 'Cannot connect to server - check host and port';
|
||||||
|
} else if (error.message.includes('certificate')) {
|
||||||
|
friendlyMessage = 'SSL/TLS certificate issue';
|
||||||
|
} else if (error.message.includes('timeout')) {
|
||||||
|
friendlyMessage = 'Connection timed out';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.logout();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore logout errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: friendlyMessage,
|
||||||
|
details: errorDetails,
|
||||||
|
debug: {
|
||||||
|
providedHost: host,
|
||||||
|
cleanHost,
|
||||||
|
port,
|
||||||
|
secure
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing connection:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to test connection',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
240
app/api/courrier/unread-counts/route.ts
Normal file
240
app/api/courrier/unread-counts/route.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getImapConnection } from '@/lib/services/email-service';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getRedisClient } from '@/lib/redis';
|
||||||
|
|
||||||
|
// Cache TTL for unread counts (increased to 2 minutes for better performance)
|
||||||
|
const UNREAD_COUNTS_CACHE_TTL = 120;
|
||||||
|
// Key for unread counts cache
|
||||||
|
const UNREAD_COUNTS_CACHE_KEY = (userId: string) => `email:unread:${userId}`;
|
||||||
|
// Refresh lock key to prevent parallel refreshes
|
||||||
|
const REFRESH_LOCK_KEY = (userId: string) => `email:unread-refresh:${userId}`;
|
||||||
|
// Lock TTL to prevent stuck locks (30 seconds)
|
||||||
|
const REFRESH_LOCK_TTL = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route for fetching unread counts for email folders
|
||||||
|
* Optimized with proper caching, connection reuse, and background refresh
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const redis = getRedisClient();
|
||||||
|
|
||||||
|
// First try to get from cache
|
||||||
|
const cachedCounts = await redis.get(UNREAD_COUNTS_CACHE_KEY(userId));
|
||||||
|
if (cachedCounts) {
|
||||||
|
// Use cached results if available
|
||||||
|
console.log(`[UNREAD_API] Using cached unread counts for user ${userId}`);
|
||||||
|
|
||||||
|
// If the cache is about to expire, schedule a background refresh
|
||||||
|
const ttl = await redis.ttl(UNREAD_COUNTS_CACHE_KEY(userId));
|
||||||
|
if (ttl < UNREAD_COUNTS_CACHE_TTL / 2) {
|
||||||
|
// Only refresh if not already refreshing (use a lock)
|
||||||
|
const lockAcquired = await redis.set(
|
||||||
|
REFRESH_LOCK_KEY(userId),
|
||||||
|
Date.now().toString(),
|
||||||
|
'EX',
|
||||||
|
REFRESH_LOCK_TTL,
|
||||||
|
'NX' // Set only if key doesn't exist
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lockAcquired) {
|
||||||
|
console.log(`[UNREAD_API] Scheduling background refresh for user ${userId}`);
|
||||||
|
// Use Promise to run in background
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshUnreadCounts(userId, redis)
|
||||||
|
.catch(err => console.error(`[UNREAD_API] Background refresh error: ${err}`))
|
||||||
|
.finally(() => {
|
||||||
|
// Release lock regardless of outcome
|
||||||
|
redis.del(REFRESH_LOCK_KEY(userId)).catch(() => {});
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(JSON.parse(cachedCounts));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Cache miss for user ${userId}, fetching unread counts`);
|
||||||
|
|
||||||
|
// Try to acquire lock to prevent parallel refreshes
|
||||||
|
const lockAcquired = await redis.set(
|
||||||
|
REFRESH_LOCK_KEY(userId),
|
||||||
|
Date.now().toString(),
|
||||||
|
'EX',
|
||||||
|
REFRESH_LOCK_TTL,
|
||||||
|
'NX' // Set only if key doesn't exist
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lockAcquired) {
|
||||||
|
console.log(`[UNREAD_API] Another process is refreshing unread counts for ${userId}`);
|
||||||
|
|
||||||
|
// Return empty counts with short cache time if we can't acquire lock
|
||||||
|
// The next request will likely get cached data
|
||||||
|
return NextResponse.json({ _status: 'pending_refresh' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch new counts
|
||||||
|
const unreadCounts = await fetchUnreadCounts(userId);
|
||||||
|
|
||||||
|
// Save to cache with longer TTL (2 minutes)
|
||||||
|
await redis.set(
|
||||||
|
UNREAD_COUNTS_CACHE_KEY(userId),
|
||||||
|
JSON.stringify(unreadCounts),
|
||||||
|
'EX',
|
||||||
|
UNREAD_COUNTS_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(unreadCounts);
|
||||||
|
} finally {
|
||||||
|
// Always release lock
|
||||||
|
await redis.del(REFRESH_LOCK_KEY(userId));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[UNREAD_API] Error fetching unread counts:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch unread counts", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background refresh function to update cache without blocking the API response
|
||||||
|
*/
|
||||||
|
async function refreshUnreadCounts(userId: string, redis: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`[UNREAD_API] Background refresh started for user ${userId}`);
|
||||||
|
const unreadCounts = await fetchUnreadCounts(userId);
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
await redis.set(
|
||||||
|
UNREAD_COUNTS_CACHE_KEY(userId),
|
||||||
|
JSON.stringify(unreadCounts),
|
||||||
|
'EX',
|
||||||
|
UNREAD_COUNTS_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Background refresh completed for user ${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[UNREAD_API] Background refresh failed for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core function to fetch unread counts from IMAP
|
||||||
|
*/
|
||||||
|
async function fetchUnreadCounts(userId: string): Promise<Record<string, Record<string, number>>> {
|
||||||
|
// Get all accounts from the database directly
|
||||||
|
const accounts = await prisma.mailCredentials.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Found ${accounts.length} accounts for user ${userId}`);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return { default: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping to hold the unread counts
|
||||||
|
const unreadCounts: Record<string, Record<string, number>> = {};
|
||||||
|
|
||||||
|
// For each account, get the unread counts for standard folders
|
||||||
|
for (const account of accounts) {
|
||||||
|
const accountId = account.id;
|
||||||
|
try {
|
||||||
|
// Get IMAP connection for this account
|
||||||
|
console.log(`[UNREAD_API] Processing account ${accountId} (${account.email})`);
|
||||||
|
const client = await getImapConnection(userId, accountId);
|
||||||
|
unreadCounts[accountId] = {};
|
||||||
|
|
||||||
|
// Standard folders to check
|
||||||
|
const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Spam', 'Archive', 'Sent Items', 'Archives', 'Notes', 'Éléments supprimés'];
|
||||||
|
|
||||||
|
// Get mailboxes for this account to check if folders exist
|
||||||
|
const mailboxes = await client.list();
|
||||||
|
const availableFolders = mailboxes.map(mb => mb.path);
|
||||||
|
|
||||||
|
// Check each standard folder if it exists
|
||||||
|
for (const folder of standardFolders) {
|
||||||
|
// Skip if folder doesn't exist in this account
|
||||||
|
if (!availableFolders.includes(folder) &&
|
||||||
|
!availableFolders.some(f => f.toLowerCase() === folder.toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check folder status without opening it (more efficient)
|
||||||
|
const status = await client.status(folder, { unseen: true });
|
||||||
|
|
||||||
|
if (status && typeof status.unseen === 'number') {
|
||||||
|
// Store the unread count
|
||||||
|
unreadCounts[accountId][folder] = status.unseen;
|
||||||
|
|
||||||
|
// Also store with prefixed version for consistency
|
||||||
|
unreadCounts[accountId][`${accountId}:${folder}`] = status.unseen;
|
||||||
|
|
||||||
|
console.log(`[UNREAD_API] Account ${accountId}, folder ${folder}: ${status.unseen} unread`);
|
||||||
|
}
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error(`[UNREAD_API] Error getting unread count for ${accountId}:${folder}:`, folderError);
|
||||||
|
// Continue to next folder even if this one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't close the connection - let the connection pool handle it
|
||||||
|
} catch (accountError) {
|
||||||
|
console.error(`[UNREAD_API] Error processing account ${accountId}:`, accountError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unreadCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get all account IDs for a user
|
||||||
|
*/
|
||||||
|
async function getUserAccountIds(userId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Get credentials for all accounts from the email service
|
||||||
|
// This is a simplified version - you should replace this with your actual logic
|
||||||
|
// to retrieve the user's accounts
|
||||||
|
|
||||||
|
// First try the default account
|
||||||
|
const defaultClient = await getImapConnection(userId, 'default');
|
||||||
|
const accounts = ['default'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get other accounts if they exist
|
||||||
|
// This is just a placeholder - implement your actual account retrieval logic
|
||||||
|
|
||||||
|
// Close the default connection
|
||||||
|
await defaultClient.logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UNREAD_API] Error getting additional accounts:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accounts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UNREAD_API] Error getting account IDs:', error);
|
||||||
|
return ['default']; // Return at least the default account
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/api/debug-email/route.ts
Normal file
89
app/api/debug-email/route.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getImapConnection } from '@/lib/services/email-service';
|
||||||
|
import { simpleParser } from 'mailparser';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// Verify authentication
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const emailId = searchParams.get('id');
|
||||||
|
const folder = searchParams.get('folder') || 'INBOX';
|
||||||
|
|
||||||
|
if (!emailId) {
|
||||||
|
return NextResponse.json({ error: 'Email ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to IMAP
|
||||||
|
const client = await getImapConnection(session.user.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mailboxOpen(folder);
|
||||||
|
|
||||||
|
// Fetch raw email
|
||||||
|
const message = await client.fetchOne(emailId, {
|
||||||
|
source: true,
|
||||||
|
envelope: true,
|
||||||
|
flags: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return NextResponse.json({ error: 'Email not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get raw source content
|
||||||
|
const { source, envelope } = message;
|
||||||
|
const rawSource = source.toString();
|
||||||
|
|
||||||
|
// Parse the email with multiple options to debug
|
||||||
|
const parsedEmail = await simpleParser(rawSource, {
|
||||||
|
skipHtmlToText: true,
|
||||||
|
keepCidLinks: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract all available data
|
||||||
|
return NextResponse.json({
|
||||||
|
id: emailId,
|
||||||
|
folder,
|
||||||
|
date: envelope.date,
|
||||||
|
subject: envelope.subject,
|
||||||
|
from: envelope.from,
|
||||||
|
to: envelope.to,
|
||||||
|
// Raw content (truncated to prevent massive responses)
|
||||||
|
sourcePreview: rawSource.substring(0, 1000) + (rawSource.length > 1000 ? '...' : ''),
|
||||||
|
sourceLength: rawSource.length,
|
||||||
|
// Parsed content
|
||||||
|
htmlPreview: parsedEmail.html ? parsedEmail.html.substring(0, 1000) + (parsedEmail.html.length > 1000 ? '...' : '') : null,
|
||||||
|
htmlLength: parsedEmail.html ? parsedEmail.html.length : 0,
|
||||||
|
textPreview: parsedEmail.text ? parsedEmail.text.substring(0, 1000) + (parsedEmail.text.length > 1000 ? '...' : '') : null,
|
||||||
|
textLength: parsedEmail.text ? parsedEmail.text.length : 0,
|
||||||
|
// Detect if there's HTML and what type of content it contains
|
||||||
|
hasHtml: !!parsedEmail.html,
|
||||||
|
hasStyleTags: typeof parsedEmail.html === 'string' && parsedEmail.html.includes('<style'),
|
||||||
|
hasCss: typeof parsedEmail.html === 'string' && parsedEmail.html.includes('style='),
|
||||||
|
// Headers that might be relevant
|
||||||
|
contentType: parsedEmail.headers.get('content-type'),
|
||||||
|
// Attachment count
|
||||||
|
attachmentCount: parsedEmail.attachments.length
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging out:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in debug-email route:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server error', message: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/debug/create-all-folders/route.ts
Normal file
33
app/api/debug/create-all-folders/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { createUserFolderStructure } from '@/lib/s3';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
console.log(`Manually creating all folders for user: ${userId}`);
|
||||||
|
|
||||||
|
// Create the folder structure for the user
|
||||||
|
const result = await createUserFolderStructure(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'All folders created successfully',
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating folders:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to create folders',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/api/debug/leantime-methods/route.ts
Normal file
114
app/api/debug/leantime-methods/route.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
// GET /api/debug/leantime-methods
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variables
|
||||||
|
if (!process.env.LEANTIME_API_URL || !process.env.LEANTIME_TOKEN) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing Leantime API configuration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods to test
|
||||||
|
const methodsToTest = [
|
||||||
|
// User related methods
|
||||||
|
'leantime.rpc.users.getAll',
|
||||||
|
|
||||||
|
// Notification methods to try
|
||||||
|
'leantime.rpc.notifications.getNotifications',
|
||||||
|
'leantime.rpc.notifications.getAllNotifications',
|
||||||
|
'leantime.rpc.notifications.getMyNotifications',
|
||||||
|
'leantime.rpc.notifications.markNotificationRead',
|
||||||
|
|
||||||
|
// Alternative paths to try
|
||||||
|
'leantime.rpc.Notifications.getNotifications',
|
||||||
|
'leantime.rpc.Notifications.getAllNotifications',
|
||||||
|
'leantime.rpc.Notifications.getMyNotifications',
|
||||||
|
|
||||||
|
// More generic paths
|
||||||
|
'notifications.getNotifications',
|
||||||
|
'notifications.getAll',
|
||||||
|
'notifications.getAllByUser',
|
||||||
|
|
||||||
|
// Alternative namespaces
|
||||||
|
'leantime.domain.notifications.getNotifications',
|
||||||
|
'leantime.domain.notifications.getAll',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Test each method
|
||||||
|
const results = await Promise.all(
|
||||||
|
methodsToTest.map(async (method) => {
|
||||||
|
console.log(`[LEANTIME_DEBUG] Testing method: ${method}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': process.env.LEANTIME_TOKEN || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: method,
|
||||||
|
params: {
|
||||||
|
// Include some common parameters that might be needed
|
||||||
|
userId: 2, // Using user ID 2 since that was found in logs
|
||||||
|
status: 'open',
|
||||||
|
limit: 10
|
||||||
|
},
|
||||||
|
id: 1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
let parsedResponse;
|
||||||
|
try {
|
||||||
|
parsedResponse = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
parsedResponse = { parseError: "Invalid JSON response" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
status: response.status,
|
||||||
|
success: response.ok && !parsedResponse.error,
|
||||||
|
response: parsedResponse
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find successful methods
|
||||||
|
const successfulMethods = results.filter(result => result.success);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
totalMethodsTested: methodsToTest.length,
|
||||||
|
successfulMethods: successfulMethods.length,
|
||||||
|
successfulMethodNames: successfulMethods.map(m => m.method),
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[LEANTIME_DEBUG] Error testing Leantime methods:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Internal server error",
|
||||||
|
message: error.message,
|
||||||
|
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/api/debug/notifications/route.ts
Normal file
83
app/api/debug/notifications/route.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||||
|
|
||||||
|
// GET /api/debug/notifications
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
console.log(`[DEBUG] Testing notifications for user ${userId}`);
|
||||||
|
|
||||||
|
// Get environment variables status
|
||||||
|
const envStatus = {
|
||||||
|
LEANTIME_API_URL: process.env.LEANTIME_API_URL ? 'Set' : 'Not set',
|
||||||
|
LEANTIME_TOKEN: process.env.LEANTIME_TOKEN ? `Set (length: ${process.env.LEANTIME_TOKEN.length})` : 'Not set',
|
||||||
|
LEANTIME_API_KEY: process.env.LEANTIME_API_KEY ? `Set (length: ${process.env.LEANTIME_API_KEY.length})` : 'Not set',
|
||||||
|
KEYCLOAK_BASE_URL: process.env.KEYCLOAK_BASE_URL ? 'Set' : 'Not set',
|
||||||
|
KEYCLOAK_REALM: process.env.KEYCLOAK_REALM || 'Not set',
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_ID: process.env.KEYCLOAK_ADMIN_CLIENT_ID ? 'Set' : 'Not set',
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_SECRET: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET ? 'Set (masked)' : 'Not set',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user information
|
||||||
|
console.log(`[DEBUG] Getting user info for ${userId}`);
|
||||||
|
let userInfo = {
|
||||||
|
id: userId,
|
||||||
|
email: session.user.email || 'Unknown'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test notification service
|
||||||
|
console.log(`[DEBUG] Testing notification service`);
|
||||||
|
const notificationService = NotificationService.getInstance();
|
||||||
|
|
||||||
|
// Get notification count
|
||||||
|
console.log(`[DEBUG] Getting notification count`);
|
||||||
|
const startTimeCount = Date.now();
|
||||||
|
const notificationCount = await notificationService.getNotificationCount(userId);
|
||||||
|
const timeForCount = Date.now() - startTimeCount;
|
||||||
|
|
||||||
|
// Get notifications
|
||||||
|
console.log(`[DEBUG] Getting notifications`);
|
||||||
|
const startTimeNotifications = Date.now();
|
||||||
|
const notifications = await notificationService.getNotifications(userId, 1, 10);
|
||||||
|
const timeForNotifications = Date.now() - startTimeNotifications;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
userInfo,
|
||||||
|
environmentVariables: envStatus,
|
||||||
|
notificationServiceTest: {
|
||||||
|
count: {
|
||||||
|
result: notificationCount,
|
||||||
|
timeMs: timeForCount
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
count: notifications.length,
|
||||||
|
timeMs: timeForNotifications,
|
||||||
|
samples: notifications.slice(0, 3) // Only return first 3 notifications as samples
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[DEBUG] Error in debug notifications API:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Internal server error",
|
||||||
|
message: error.message,
|
||||||
|
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/api/debug/s3/route.ts
Normal file
78
app/api/debug/s3/route.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { ListBucketsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||||
|
import { s3Client, S3_CONFIG } from '@/lib/s3';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const folder = searchParams.get('folder') || 'notes';
|
||||||
|
|
||||||
|
// Debug information about the configuration
|
||||||
|
const config = {
|
||||||
|
endpoint: S3_CONFIG.endpoint,
|
||||||
|
region: S3_CONFIG.region,
|
||||||
|
bucket: S3_CONFIG.bucket,
|
||||||
|
hasAccessKey: !!S3_CONFIG.accessKey,
|
||||||
|
hasSecretKey: !!S3_CONFIG.secretKey
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to list buckets to verify credentials
|
||||||
|
let bucketList = [];
|
||||||
|
try {
|
||||||
|
const bucketsResponse = await s3Client.send(new ListBucketsCommand({}));
|
||||||
|
bucketList = bucketsResponse.Buckets?.map(b => b.Name) || [];
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to list buckets',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
config
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to list objects in the folder
|
||||||
|
const prefix = `user-${userId}/${folder}/`;
|
||||||
|
try {
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: S3_CONFIG.bucket,
|
||||||
|
Prefix: prefix,
|
||||||
|
Delimiter: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
config,
|
||||||
|
buckets: bucketList,
|
||||||
|
prefix,
|
||||||
|
objects: response.Contents?.map(item => ({
|
||||||
|
key: item.Key,
|
||||||
|
size: item.Size,
|
||||||
|
lastModified: item.LastModified
|
||||||
|
})) || [],
|
||||||
|
prefixes: response.CommonPrefixes?.map(p => p.Prefix) || []
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to list objects',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
config,
|
||||||
|
buckets: bucketList,
|
||||||
|
prefix
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/api/emails/route.ts
Normal file
59
app/api/emails/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse, NextRequest } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextcloudUrl = process.env.NEXTCLOUD_URL;
|
||||||
|
if (!nextcloudUrl) {
|
||||||
|
console.error('Missing Nextcloud URL');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Nextcloud configuration is missing' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Nextcloud connectivity
|
||||||
|
const testResponse = await fetch(`${nextcloudUrl}/status.php`);
|
||||||
|
if (!testResponse.ok) {
|
||||||
|
console.error('Nextcloud is not accessible:', await testResponse.text());
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Nextcloud n'est pas accessible. Veuillez contacter votre administrateur.",
|
||||||
|
emails: []
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, return a test response
|
||||||
|
return NextResponse.json({
|
||||||
|
emails: [{
|
||||||
|
id: 'test-1',
|
||||||
|
subject: 'Test Email',
|
||||||
|
sender: {
|
||||||
|
name: 'System',
|
||||||
|
email: 'system@example.com'
|
||||||
|
},
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
isUnread: true
|
||||||
|
}],
|
||||||
|
mailUrl: `${nextcloudUrl}/apps/courrier/box/unified`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "Une erreur est survenue. Veuillez contacter votre administrateur.",
|
||||||
|
emails: []
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/api/events/[id]/route.ts
Normal file
49
app/api/events/[id]/route.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, find the event and its associated calendar
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id: params.id },
|
||||||
|
include: { calendar: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the user owns the calendar
|
||||||
|
if (event.calendar.userId !== session.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Non autorisé" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the event
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: { id: params.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression de l'événement:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur serveur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/api/events/route.ts
Normal file
136
app/api/events/route.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, description, start, end, allDay, location, calendarId } = await req.json();
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!title || !start || !end || !calendarId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Titre, début, fin et calendrier sont requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify calendar ownership
|
||||||
|
const calendar = await prisma.calendar.findFirst({
|
||||||
|
where: {
|
||||||
|
id: calendarId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!calendar) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Calendrier non trouvé ou non autorisé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create event with all required fields
|
||||||
|
const event = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
start: new Date(start),
|
||||||
|
end: new Date(end),
|
||||||
|
isAllDay: allDay || false,
|
||||||
|
location: location || null,
|
||||||
|
calendarId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Created event:", event);
|
||||||
|
return NextResponse.json(event, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création de l'événement:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await req.json();
|
||||||
|
console.log("Received event update data:", data);
|
||||||
|
|
||||||
|
const { id, title, description, start, end, allDay, location, calendarId } = data;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!id || !title || !start || !end || !calendarId) {
|
||||||
|
console.log("Validation failed. Missing fields:", {
|
||||||
|
id: !id,
|
||||||
|
title: !title,
|
||||||
|
start: !start,
|
||||||
|
end: !end,
|
||||||
|
calendarId: !calendarId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ID, titre, début, fin et calendrier sont requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify calendar ownership
|
||||||
|
const calendar = await prisma.calendar.findFirst({
|
||||||
|
where: {
|
||||||
|
id: calendarId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
events: {
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Found calendar:", calendar);
|
||||||
|
|
||||||
|
if (!calendar || calendar.events.length === 0) {
|
||||||
|
console.log("Calendar or event not found:", {
|
||||||
|
calendarFound: !!calendar,
|
||||||
|
eventsFound: calendar?.events.length
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Événement non trouvé ou non autorisé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await prisma.event.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start: new Date(start),
|
||||||
|
end: new Date(end),
|
||||||
|
isAllDay: allDay || false,
|
||||||
|
location,
|
||||||
|
calendarId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Updated event:", event);
|
||||||
|
return NextResponse.json(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la mise à jour de l'événement:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/api/groups/[groupId]/members/route.ts
Normal file
117
app/api/groups/[groupId]/members/route.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(req: Request, props: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get client credentials token
|
||||||
|
const tokenResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
console.error("Failed to get token:", tokenData);
|
||||||
|
return NextResponse.json({ error: "Failed to get token" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get group members
|
||||||
|
const membersResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}/members`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokenData.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!membersResponse.ok) {
|
||||||
|
const errorData = await membersResponse.json();
|
||||||
|
console.error("Failed to get group members:", errorData);
|
||||||
|
return NextResponse.json({ error: "Failed to get group members" }, { status: membersResponse.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await membersResponse.json();
|
||||||
|
return NextResponse.json(members);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in get group members:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request, props: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId } = await req.json();
|
||||||
|
|
||||||
|
// Get client credentials token
|
||||||
|
const tokenResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
console.error("Failed to get token:", tokenData);
|
||||||
|
return NextResponse.json({ error: "Failed to get token" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user to group
|
||||||
|
const addResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${userId}/groups/${params.groupId}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokenData.access_token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!addResponse.ok) {
|
||||||
|
const errorData = await addResponse.json();
|
||||||
|
console.error("Failed to add user to group:", errorData);
|
||||||
|
return NextResponse.json({ error: "Failed to add user to group" }, { status: addResponse.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in add user to group:", error);
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/api/groups/[groupId]/route.ts
Normal file
169
app/api/groups/[groupId]/route.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "../../auth/options";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
async function getAdminToken() {
|
||||||
|
const tokenResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await tokenResponse.json();
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error(data.error_description || 'Failed to get admin token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request, props: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch group');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get Group Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la récupération du groupe" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request, props: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update group');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update Group Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la mise à jour du groupe" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request, props: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
|
||||||
|
{
|
||||||
|
method: "PUT", // Keycloak doesn't support PATCH, so we use PUT
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update group');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update Group Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la mise à jour du groupe" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: Request, props: { params: Promise<{ groupId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${params.groupId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete group');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete Group Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la suppression du groupe" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/api/groups/route.ts
Normal file
174
app/api/groups/route.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
async function getAdminToken() {
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await tokenResponse.json();
|
||||||
|
|
||||||
|
// Log the response for debugging (without exposing the full token!)
|
||||||
|
logger.debug('Token Response', {
|
||||||
|
status: tokenResponse.status,
|
||||||
|
ok: tokenResponse.ok,
|
||||||
|
hasToken: !!data.access_token,
|
||||||
|
expiresIn: data.expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok || !data.access_token) {
|
||||||
|
// Log the error details (without sensitive data)
|
||||||
|
logger.error('Token Error Details', {
|
||||||
|
status: tokenResponse.status,
|
||||||
|
error: data.error || 'Unknown error'
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token Error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ message: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ message: "Erreur d'authentification" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ message: "Échec de la récupération des groupes" }, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await response.json();
|
||||||
|
|
||||||
|
// Return empty array if no groups
|
||||||
|
if (!Array.isArray(groups)) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsWithCounts = await Promise.all(
|
||||||
|
groups.map(async (group: any) => {
|
||||||
|
try {
|
||||||
|
const countResponse = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups/${group.id}/members/count`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
if (countResponse.ok) {
|
||||||
|
count = await countResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
path: group.path,
|
||||||
|
membersCount: count,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
path: group.path,
|
||||||
|
membersCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(groupsWithCounts);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Groups API Error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json({ message: "Une erreur est survenue" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ message: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await req.json();
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Le nom du groupe est requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getAdminToken();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/groups`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Échec de la création du groupe');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name,
|
||||||
|
path: `/${name}`,
|
||||||
|
membersCount: 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Create Group Error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error instanceof Error ? error.message : "Une erreur est survenue" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
app/api/leantime/status-labels/route.ts
Normal file
234
app/api/leantime/status-labels/route.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
interface StatusLabel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
statusType: string;
|
||||||
|
class: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
labels: StatusLabel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for user IDs to avoid repeated lookups
|
||||||
|
const userCache = new Map<string, number>();
|
||||||
|
|
||||||
|
async function getLeantimeUserId(email: string): Promise<number | null> {
|
||||||
|
// Check cache first
|
||||||
|
if (userCache.has(email)) {
|
||||||
|
return userCache.get(email)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching Leantime user with token:', process.env.LEANTIME_TOKEN ? 'Token present' : 'Token missing');
|
||||||
|
|
||||||
|
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': process.env.LEANTIME_TOKEN || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'leantime.rpc.Users.Users.getUserByEmail',
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
email: email
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch user from Leantime:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to fetch user from Leantime: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Leantime user response:', data);
|
||||||
|
|
||||||
|
if (!data.result || data.result === false) {
|
||||||
|
console.log('User not found in Leantime');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the user ID
|
||||||
|
userCache.set(email, data.result.id);
|
||||||
|
// Clear cache after 5 minutes
|
||||||
|
setTimeout(() => userCache.delete(email), 5 * 60 * 1000);
|
||||||
|
return data.result.id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Leantime user ID:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
console.log('Session:', session ? 'Present' : 'Missing');
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized", message: "No session found. Please sign in." },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user?.email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized", message: "No email found in session. Please sign in again." },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('User email:', session.user.email);
|
||||||
|
|
||||||
|
// Get Leantime user ID
|
||||||
|
const leantimeUserId = await getLeantimeUserId(session.user.email);
|
||||||
|
console.log('Leantime user ID:', leantimeUserId);
|
||||||
|
|
||||||
|
if (!leantimeUserId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User not found", message: "Could not find user in Leantime. Please check your email." },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tasks assigned to the user
|
||||||
|
console.log('Fetching tasks for user:', leantimeUserId);
|
||||||
|
const response = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': process.env.LEANTIME_TOKEN || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'leantime.rpc.Tickets.Tickets.getAll',
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
projectId: 0, // 0 means all projects
|
||||||
|
userId: leantimeUserId,
|
||||||
|
status: "all",
|
||||||
|
limit: 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch tasks:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to fetch tasks: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Tasks response:', data);
|
||||||
|
|
||||||
|
if (!data.result) {
|
||||||
|
return NextResponse.json({ projects: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project details to include project names
|
||||||
|
console.log('Fetching projects');
|
||||||
|
const projectsResponse = await fetch('https://agilite.slm-lab.net/api/jsonrpc', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': process.env.LEANTIME_TOKEN || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'leantime.rpc.Projects.getAll',
|
||||||
|
id: 1
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!projectsResponse.ok) {
|
||||||
|
console.error('Failed to fetch projects:', {
|
||||||
|
status: projectsResponse.status,
|
||||||
|
statusText: projectsResponse.statusText
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to fetch projects: ${projectsResponse.status} ${projectsResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsData = await projectsResponse.json();
|
||||||
|
console.log('Projects response:', projectsData);
|
||||||
|
|
||||||
|
// Create a map of projects with their tasks grouped by status
|
||||||
|
const projectMap = new Map<string, Project>();
|
||||||
|
|
||||||
|
data.result.forEach((task: any) => {
|
||||||
|
const project = projectsData.result.find((p: any) => p.id === task.projectId);
|
||||||
|
const projectName = project ? project.name : `Project ${task.projectId}`;
|
||||||
|
const projectId = task.projectId.toString();
|
||||||
|
|
||||||
|
if (!projectMap.has(projectId)) {
|
||||||
|
projectMap.set(projectId, {
|
||||||
|
id: projectId,
|
||||||
|
name: projectName,
|
||||||
|
labels: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentProject = projectMap.get(projectId)!;
|
||||||
|
|
||||||
|
// Check if this status label already exists for this project
|
||||||
|
const existingLabel = currentProject.labels.find(label => label.name === task.status);
|
||||||
|
|
||||||
|
if (!existingLabel) {
|
||||||
|
let statusType;
|
||||||
|
let statusClass;
|
||||||
|
|
||||||
|
// Convert numeric status to string and handle accordingly
|
||||||
|
const statusStr = task.status.toString();
|
||||||
|
switch (statusStr) {
|
||||||
|
case '1':
|
||||||
|
statusType = 'NEW';
|
||||||
|
statusClass = 'bg-blue-100 text-blue-800';
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
statusType = 'INPROGRESS';
|
||||||
|
statusClass = 'bg-yellow-100 text-yellow-800';
|
||||||
|
break;
|
||||||
|
case '3':
|
||||||
|
statusType = 'DONE';
|
||||||
|
statusClass = 'bg-green-100 text-green-800';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusType = 'UNKNOWN';
|
||||||
|
statusClass = 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProject.labels.push({
|
||||||
|
id: `${projectId}-${task.status}`,
|
||||||
|
name: task.status,
|
||||||
|
statusType: statusType,
|
||||||
|
class: statusClass
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert the map to an array and sort projects by name
|
||||||
|
const projects = Array.from(projectMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
console.log('Final projects:', projects);
|
||||||
|
|
||||||
|
return NextResponse.json({ projects });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching status labels:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch status labels", message: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
app/api/leantime/tasks/route.ts
Normal file
242
app/api/leantime/tasks/route.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { getCachedTasksData, cacheTasksData } from "@/lib/redis";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
headline: string;
|
||||||
|
projectName: string;
|
||||||
|
projectId: number;
|
||||||
|
status: number;
|
||||||
|
dateToFinish: string | null;
|
||||||
|
milestone: string | null;
|
||||||
|
details: string | null;
|
||||||
|
createdOn: string;
|
||||||
|
editedOn: string | null;
|
||||||
|
editorId: string;
|
||||||
|
editorFirstname: string;
|
||||||
|
editorLastname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLeantimeUserId(email: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
if (!process.env.LEANTIME_TOKEN) {
|
||||||
|
logger.error('[LEANTIME_TASKS] LEANTIME_TOKEN is not set in environment variables');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[LEANTIME_TASKS] Fetching Leantime users', {
|
||||||
|
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||||
|
apiUrlPresent: !!process.env.LEANTIME_API_URL,
|
||||||
|
tokenLength: process.env.LEANTIME_TOKEN.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': process.env.LEANTIME_TOKEN
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'leantime.rpc.users.getAll',
|
||||||
|
id: 1
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Failed to fetch Leantime users', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Failed to parse Leantime response', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.result || !Array.isArray(data.result)) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Invalid response format from Leantime users API');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = data.result;
|
||||||
|
const user = users.find((u: any) => u.username === email);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
logger.debug('[LEANTIME_TASKS] Found Leantime user', {
|
||||||
|
id: user.id,
|
||||||
|
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('[LEANTIME_TASKS] No Leantime user found for username', {
|
||||||
|
emailHash: Buffer.from(email.toLowerCase()).toString('base64').slice(0, 12),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user ? user.id : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Error fetching Leantime user ID', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for force refresh parameter
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
||||||
|
|
||||||
|
// Try to get data from cache if not forcing refresh
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cachedTasks = await getCachedTasksData(session.user.id);
|
||||||
|
if (cachedTasks) {
|
||||||
|
logger.debug('[LEANTIME_TASKS] Using cached tasks data', {
|
||||||
|
userId: session.user.id,
|
||||||
|
taskCount: Array.isArray(cachedTasks) ? cachedTasks.length : undefined,
|
||||||
|
});
|
||||||
|
return NextResponse.json(cachedTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[LEANTIME_TASKS] Fetching tasks for user', {
|
||||||
|
emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12),
|
||||||
|
});
|
||||||
|
const userId = await getLeantimeUserId(session.user.email);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
logger.error('[LEANTIME_TASKS] User not found in Leantime', {
|
||||||
|
emailHash: Buffer.from(session.user.email.toLowerCase()).toString('base64').slice(0, 12),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: "User not found in Leantime" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[LEANTIME_TASKS] Fetching tasks for Leantime user ID', {
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': process.env.LEANTIME_TOKEN!
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'leantime.rpc.tickets.getAll',
|
||||||
|
params: {
|
||||||
|
userId: userId,
|
||||||
|
status: "all"
|
||||||
|
},
|
||||||
|
id: 1
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
logger.debug('[LEANTIME_TASKS] Tasks API response status', {
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Failed to fetch tasks from Leantime', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
throw new Error('Failed to fetch tasks from Leantime');
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(responseText);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Failed to parse tasks response', {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
throw new Error('Invalid response format from Leantime');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.result || !Array.isArray(data.result)) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Invalid response format from Leantime tasks API');
|
||||||
|
throw new Error('Invalid response format from Leantime');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log only the number of tasks and their IDs
|
||||||
|
logger.debug('[LEANTIME_TASKS] Received tasks summary', {
|
||||||
|
count: data.result.length,
|
||||||
|
idsSample: data.result.slice(0, 20).map((task: any) => task.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = data.result
|
||||||
|
.filter((task: any) => {
|
||||||
|
// Filter out any task (main or subtask) that has status Done (5)
|
||||||
|
if (task.status === 5) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert both to strings for comparison to handle any type mismatches
|
||||||
|
const taskEditorId = String(task.editorId).trim();
|
||||||
|
const currentUserId = String(userId).trim();
|
||||||
|
|
||||||
|
// Only show tasks where the user is the editor
|
||||||
|
const isUserEditor = taskEditorId === currentUserId;
|
||||||
|
return isUserEditor;
|
||||||
|
})
|
||||||
|
.map((task: any) => ({
|
||||||
|
id: task.id.toString(),
|
||||||
|
headline: task.headline,
|
||||||
|
projectName: task.projectName,
|
||||||
|
projectId: task.projectId,
|
||||||
|
status: task.status,
|
||||||
|
dateToFinish: task.dateToFinish || null,
|
||||||
|
milestone: task.type || null,
|
||||||
|
details: task.description || null,
|
||||||
|
createdOn: task.dateCreated,
|
||||||
|
editedOn: task.editedOn || null,
|
||||||
|
editorId: task.editorId,
|
||||||
|
editorFirstname: task.editorFirstname,
|
||||||
|
editorLastname: task.editorLastname,
|
||||||
|
type: task.type || null, // Added type field to identify subtasks
|
||||||
|
dependingTicketId: task.dependingTicketId || null // Added parent task reference
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug('[LEANTIME_TASKS] Filtered tasks for user', {
|
||||||
|
userId,
|
||||||
|
count: tasks.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
await cacheTasksData(session.user.id, tasks);
|
||||||
|
|
||||||
|
return NextResponse.json(tasks);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[LEANTIME_TASKS] Error in tasks route', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch tasks" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { deleteMissionAttachment } from '@/lib/mission-uploads';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.error('Unauthorized access attempt', {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
return { authorized: false, userId: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE endpoint to remove an attachment
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
props: { params: Promise<{ missionId: string, attachmentId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId, attachmentId } = params;
|
||||||
|
if (!missionId || !attachmentId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID and Attachment ID are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mission exists and user has access to it
|
||||||
|
const mission = await prisma.mission.findFirst({
|
||||||
|
where: {
|
||||||
|
id: missionId,
|
||||||
|
OR: [
|
||||||
|
{ creatorId: userId },
|
||||||
|
{ missionUsers: { some: { userId, role: 'gardien-memoire' } } } // Only mission creator or memory guardian can delete
|
||||||
|
]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the attachment to delete
|
||||||
|
const attachment = await prisma.attachment.findUnique({
|
||||||
|
where: {
|
||||||
|
id: attachmentId,
|
||||||
|
missionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file from Minio
|
||||||
|
await deleteMissionAttachment(attachment.filePath);
|
||||||
|
|
||||||
|
// Delete the attachment record from the database
|
||||||
|
await prisma.attachment.delete({
|
||||||
|
where: { id: attachmentId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting attachment', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId,
|
||||||
|
attachmentId: params.attachmentId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { s3Client, S3_CONFIG } from '@/lib/s3';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.error('Unauthorized access attempt', {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
return { authorized: false, userId: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint to download an attachment
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
props: { params: Promise<{ missionId: string, attachmentId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId, attachmentId } = params;
|
||||||
|
if (!missionId || !attachmentId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID and Attachment ID are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mission exists and user has access to it
|
||||||
|
const mission = await prisma.mission.findFirst({
|
||||||
|
where: {
|
||||||
|
id: missionId,
|
||||||
|
OR: [
|
||||||
|
{ creatorId: userId },
|
||||||
|
{ missionUsers: { some: { userId } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the attachment
|
||||||
|
const attachment = await prisma.attachment.findUnique({
|
||||||
|
where: {
|
||||||
|
id: attachmentId,
|
||||||
|
missionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a presigned URL for downloading
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: S3_CONFIG.bucket,
|
||||||
|
Key: attachment.filePath
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a short expiry for security (5 minutes)
|
||||||
|
const url = await getSignedUrl(s3Client, command, { expiresIn: 300 });
|
||||||
|
|
||||||
|
// Redirect the user to the presigned URL for direct download
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error downloading attachment', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId,
|
||||||
|
attachmentId: params.attachmentId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/api/missions/[missionId]/attachments/route.ts
Normal file
81
app/api/missions/[missionId]/attachments/route.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getPublicUrl, S3_CONFIG } from '@/lib/s3';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.error('Unauthorized access attempt', {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
return { authorized: false, userId: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint to list all attachments for a mission
|
||||||
|
export async function GET(request: Request, props: { params: Promise<{ missionId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId } = params;
|
||||||
|
if (!missionId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mission exists and user has access to it
|
||||||
|
const mission = await prisma.mission.findFirst({
|
||||||
|
where: {
|
||||||
|
id: missionId,
|
||||||
|
OR: [
|
||||||
|
{ creatorId: userId },
|
||||||
|
{ missionUsers: { some: { userId } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all attachments for the mission
|
||||||
|
const attachments = await prisma.attachment.findMany({
|
||||||
|
where: { missionId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
filename: true,
|
||||||
|
filePath: true,
|
||||||
|
fileType: true,
|
||||||
|
fileSize: true,
|
||||||
|
createdAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add public URLs to attachments
|
||||||
|
const attachmentsWithUrls = attachments.map(attachment => ({
|
||||||
|
...attachment,
|
||||||
|
publicUrl: `/api/missions/image/${attachment.filePath}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(attachmentsWithUrls);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching mission attachments', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
157
app/api/missions/[missionId]/close/route.ts
Normal file
157
app/api/missions/[missionId]/close/route.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/missions/[missionId]/close
|
||||||
|
*
|
||||||
|
* Closes a mission by calling N8N webhook to close it in external services
|
||||||
|
* and marking it as closed in the database.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
props: { params: Promise<{ missionId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId } = params;
|
||||||
|
if (!missionId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the mission with all details needed for N8N
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: missionId },
|
||||||
|
include: {
|
||||||
|
missionUsers: {
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission (creator or 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already closed
|
||||||
|
if ((mission as any).isClosed) {
|
||||||
|
return NextResponse.json({ error: 'Mission is already closed' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract repo name from giteaRepositoryUrl if present
|
||||||
|
let repoName = '';
|
||||||
|
if (mission.giteaRepositoryUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(mission.giteaRepositoryUrl);
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||||
|
repoName = pathParts[pathParts.length - 1] || '';
|
||||||
|
logger.debug('Extracted repo name from URL', { repoName });
|
||||||
|
} catch (error) {
|
||||||
|
const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/);
|
||||||
|
repoName = match ? match[1] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data for N8N webhook (same format as deletion)
|
||||||
|
const n8nCloseData = {
|
||||||
|
missionId: mission.id,
|
||||||
|
name: mission.name,
|
||||||
|
repoName: repoName,
|
||||||
|
leantimeProjectId: mission.leantimeProjectId || 0,
|
||||||
|
documentationCollectionId: mission.outlineCollectionId || '',
|
||||||
|
rocketchatChannelId: mission.rocketChatChannelId || '',
|
||||||
|
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||||
|
outlineCollectionId: mission.outlineCollectionId,
|
||||||
|
rocketChatChannelId: mission.rocketChatChannelId,
|
||||||
|
penpotProjectId: mission.penpotProjectId,
|
||||||
|
action: 'close' // Indicate this is a close action, not delete
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Calling N8N NeahMissionClose webhook', {
|
||||||
|
missionId: mission.id,
|
||||||
|
missionName: mission.name,
|
||||||
|
hasRepoName: !!repoName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call N8N webhook
|
||||||
|
const webhookUrl = process.env.N8N_CLOSE_MISSION_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/NeahMissionClose';
|
||||||
|
const apiKey = process.env.N8N_API_KEY || '';
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify(n8nCloseData),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('N8N close webhook response', { status: response.status });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('N8N close webhook error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.substring(0, 200)
|
||||||
|
});
|
||||||
|
// Continue with closing even if N8N fails (non-blocking)
|
||||||
|
logger.warn('Continuing with mission close despite N8N error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark mission as closed in database
|
||||||
|
// Using 'as any' until prisma generate is run
|
||||||
|
const updatedMission = await (prisma.mission as any).update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: {
|
||||||
|
isClosed: true,
|
||||||
|
closedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Mission closed successfully', {
|
||||||
|
missionId: updatedMission.id,
|
||||||
|
closedAt: updatedMission.closedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Mission closed successfully',
|
||||||
|
mission: {
|
||||||
|
id: updatedMission.id,
|
||||||
|
name: updatedMission.name,
|
||||||
|
isClosed: updatedMission.isClosed,
|
||||||
|
closedAt: updatedMission.closedAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error closing mission', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to close mission', details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
232
app/api/missions/[missionId]/generate-plan/route.ts
Normal file
232
app/api/missions/[missionId]/generate-plan/route.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/missions/[missionId]/generate-plan
|
||||||
|
*
|
||||||
|
* Generates an action plan by calling N8N webhook which uses an LLM.
|
||||||
|
* Saves the generated plan to the mission.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
props: { params: Promise<{ missionId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId } = params;
|
||||||
|
if (!missionId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the mission
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: missionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission (creator or 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data for N8N webhook - must be nested under "mission" key
|
||||||
|
const webhookData = {
|
||||||
|
mission: {
|
||||||
|
name: mission.name,
|
||||||
|
oddScope: mission.oddScope,
|
||||||
|
niveau: mission.niveau,
|
||||||
|
intention: mission.intention,
|
||||||
|
missionType: mission.missionType,
|
||||||
|
donneurDOrdre: mission.donneurDOrdre,
|
||||||
|
projection: mission.projection,
|
||||||
|
services: mission.services,
|
||||||
|
participation: mission.participation,
|
||||||
|
profils: mission.profils,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Calling N8N GeneratePlan webhook', {
|
||||||
|
missionId,
|
||||||
|
missionName: mission.name,
|
||||||
|
webhookData
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call N8N webhook
|
||||||
|
const webhookUrl = process.env.N8N_GENERATE_PLAN_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/GeneratePlan';
|
||||||
|
const apiKey = process.env.N8N_API_KEY || '';
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify(webhookData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('N8N GeneratePlan webhook error', {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText.substring(0, 200)
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to generate plan: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
const responseText = await response.text();
|
||||||
|
let actionPlan: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = JSON.parse(responseText);
|
||||||
|
|
||||||
|
// N8N might return an array like [{"response":"..."}] when responseData is "allEntries"
|
||||||
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
|
result = result[0];
|
||||||
|
logger.debug('N8N returned array, using first element');
|
||||||
|
}
|
||||||
|
|
||||||
|
// N8N returns { "response": "..." } based on the workflow
|
||||||
|
actionPlan = result.response || result.plan || result.actionPlan || result.content || result.text || responseText;
|
||||||
|
|
||||||
|
logger.debug('Parsed N8N response', {
|
||||||
|
hasResponse: !!result.response,
|
||||||
|
responseLength: actionPlan?.length || 0
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If not JSON, use the raw text
|
||||||
|
actionPlan = responseText;
|
||||||
|
logger.debug('Using raw response text', { length: actionPlan.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Received action plan from N8N', {
|
||||||
|
missionId,
|
||||||
|
planLength: actionPlan.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the action plan to the mission
|
||||||
|
// Note: Using 'as any' until prisma generate is run to update types
|
||||||
|
const updatedMission = await (prisma.mission as any).update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: {
|
||||||
|
actionPlan: actionPlan,
|
||||||
|
actionPlanGeneratedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Action plan saved successfully', {
|
||||||
|
missionId,
|
||||||
|
generatedAt: updatedMission.actionPlanGeneratedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
actionPlan: updatedMission.actionPlan,
|
||||||
|
generatedAt: updatedMission.actionPlanGeneratedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating action plan', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to generate action plan', details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/missions/[missionId]/generate-plan
|
||||||
|
*
|
||||||
|
* Updates the action plan (allows manual editing by creator)
|
||||||
|
*/
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
props: { params: Promise<{ missionId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId } = params;
|
||||||
|
if (!missionId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { actionPlan } = body;
|
||||||
|
|
||||||
|
if (typeof actionPlan !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'actionPlan must be a string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the mission
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: missionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has permission (creator or 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the action plan
|
||||||
|
// Note: Using 'as any' until prisma generate is run to update types
|
||||||
|
const updatedMission = await (prisma.mission as any).update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: {
|
||||||
|
actionPlan: actionPlan
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Action plan updated successfully', {
|
||||||
|
missionId
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
actionPlan: updatedMission.actionPlan
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating action plan', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update action plan', details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
443
app/api/missions/[missionId]/route.ts
Normal file
443
app/api/missions/[missionId]/route.ts
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { deleteMissionLogo, deleteMissionAttachment, getMissionFileUrl } from '@/lib/mission-uploads';
|
||||||
|
import { getPublicUrl, S3_CONFIG } from '@/lib/s3';
|
||||||
|
import { N8nService } from '@/lib/services/n8n-service';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.error('Unauthorized access attempt', {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
return { authorized: false, userId: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint to retrieve a mission by ID
|
||||||
|
export async function GET(request: Request, props: { params: Promise<{ missionId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId } = params;
|
||||||
|
if (!missionId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mission with detailed info
|
||||||
|
const mission = await (prisma as any).mission.findFirst({
|
||||||
|
where: {
|
||||||
|
id: missionId,
|
||||||
|
OR: [
|
||||||
|
{ creatorId: userId },
|
||||||
|
{ missionUsers: { some: { userId } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
missionUsers: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
filename: true,
|
||||||
|
filePath: true,
|
||||||
|
fileType: true,
|
||||||
|
fileSize: true,
|
||||||
|
createdAt: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found or access denied' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public URLs to mission logo and attachments
|
||||||
|
const missionWithUrls = {
|
||||||
|
...mission,
|
||||||
|
logoUrl: mission.logo ? getMissionFileUrl(mission.logo) : null,
|
||||||
|
logo: mission.logo,
|
||||||
|
attachments: mission.attachments.map((attachment: { id: string; filename: string; filePath: string; fileType: string; fileSize: number; createdAt: Date }) => ({
|
||||||
|
...attachment,
|
||||||
|
publicUrl: getMissionFileUrl(attachment.filePath)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Mission data with URLs', {
|
||||||
|
missionId: mission.id,
|
||||||
|
hasLogo: !!mission.logo,
|
||||||
|
attachmentCount: mission.attachments.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(missionWithUrls);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving mission', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT endpoint to update a mission
|
||||||
|
export async function PUT(request: Request, props: { params: Promise<{ missionId: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { missionId } = params;
|
||||||
|
if (!missionId) {
|
||||||
|
return NextResponse.json({ error: 'Mission ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if mission exists and user has access to modify it
|
||||||
|
const existingMission = await (prisma as any).mission.findFirst({
|
||||||
|
where: {
|
||||||
|
id: missionId,
|
||||||
|
OR: [
|
||||||
|
{ creatorId: userId },
|
||||||
|
{ missionUsers: { some: { userId, role: { in: ['gardien-temps', 'gardien-parole'] } } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingMission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found or not authorized to update' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the request body
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
logo,
|
||||||
|
oddScope,
|
||||||
|
niveau,
|
||||||
|
intention,
|
||||||
|
missionType,
|
||||||
|
donneurDOrdre,
|
||||||
|
projection,
|
||||||
|
services,
|
||||||
|
participation,
|
||||||
|
profils,
|
||||||
|
guardians,
|
||||||
|
volunteers
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Process logo URL to get relative path
|
||||||
|
let logoPath = logo;
|
||||||
|
if (logo && typeof logo === 'string') {
|
||||||
|
try {
|
||||||
|
// If it's a full URL, extract the path
|
||||||
|
if (logo.startsWith('http')) {
|
||||||
|
const url = new URL(logo);
|
||||||
|
logoPath = url.pathname.startsWith('/') ? url.pathname.substring(1) : url.pathname;
|
||||||
|
}
|
||||||
|
// If it's already a relative path, ensure it starts with 'missions/'
|
||||||
|
else if (!logo.startsWith('missions/')) {
|
||||||
|
logoPath = `missions/${logo}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing logo URL', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the mission data
|
||||||
|
const updatedMission = await (prisma as any).mission.update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
logo: logoPath,
|
||||||
|
oddScope: oddScope || undefined,
|
||||||
|
niveau,
|
||||||
|
intention,
|
||||||
|
missionType,
|
||||||
|
donneurDOrdre,
|
||||||
|
projection,
|
||||||
|
services: services || undefined,
|
||||||
|
participation,
|
||||||
|
profils: profils || undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update guardians if provided
|
||||||
|
if (guardians) {
|
||||||
|
// Get current guardians
|
||||||
|
const currentGuardians = await (prisma as any).missionUser.findMany({
|
||||||
|
where: {
|
||||||
|
missionId,
|
||||||
|
role: { in: ['gardien-temps', 'gardien-parole', 'gardien-memoire'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete all guardians
|
||||||
|
if (currentGuardians.length > 0) {
|
||||||
|
await (prisma as any).missionUser.deleteMany({
|
||||||
|
where: {
|
||||||
|
missionId,
|
||||||
|
role: { in: ['gardien-temps', 'gardien-parole', 'gardien-memoire'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new guardians
|
||||||
|
const guardianRoles = ['gardien-temps', 'gardien-parole', 'gardien-memoire'];
|
||||||
|
const guardianEntries = Object.entries(guardians)
|
||||||
|
.filter(([role, userId]) => guardianRoles.includes(role) && userId)
|
||||||
|
.map(([role, userId]) => ({
|
||||||
|
role,
|
||||||
|
userId: userId as string,
|
||||||
|
missionId
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (guardianEntries.length > 0) {
|
||||||
|
await (prisma as any).missionUser.createMany({
|
||||||
|
data: guardianEntries
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update volunteers if provided
|
||||||
|
if (volunteers && Array.isArray(volunteers)) {
|
||||||
|
// Get current volunteers
|
||||||
|
const currentVolunteers = await (prisma as any).missionUser.findMany({
|
||||||
|
where: {
|
||||||
|
missionId,
|
||||||
|
role: 'volontaire'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete all volunteers
|
||||||
|
if (currentVolunteers.length > 0) {
|
||||||
|
await (prisma as any).missionUser.deleteMany({
|
||||||
|
where: {
|
||||||
|
missionId,
|
||||||
|
role: 'volontaire'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new volunteers
|
||||||
|
if (volunteers.length > 0) {
|
||||||
|
const volunteerEntries = volunteers.map((userId: string) => ({
|
||||||
|
role: 'volontaire',
|
||||||
|
userId,
|
||||||
|
missionId
|
||||||
|
}));
|
||||||
|
|
||||||
|
await (prisma as any).missionUser.createMany({
|
||||||
|
data: volunteerEntries
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
mission: {
|
||||||
|
id: updatedMission.id,
|
||||||
|
name: updatedMission.name,
|
||||||
|
updatedAt: updatedMission.updatedAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating mission', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE endpoint to remove a mission
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
props: { params: Promise<{ missionId: string }> }
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: params.missionId },
|
||||||
|
include: {
|
||||||
|
missionUsers: {
|
||||||
|
include: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is mission creator or 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
logger.debug('Starting N8N deletion workflow');
|
||||||
|
const n8nService = new N8nService();
|
||||||
|
|
||||||
|
// Extract repo name from giteaRepositoryUrl if present
|
||||||
|
// Format: https://gite.slm-lab.net/alma/repo-name or https://gite.slm-lab.net/api/v1/repos/alma/repo-name
|
||||||
|
let repoName = '';
|
||||||
|
if (mission.giteaRepositoryUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(mission.giteaRepositoryUrl);
|
||||||
|
// Extract repo name from path (last segment)
|
||||||
|
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||||
|
repoName = pathParts[pathParts.length - 1] || '';
|
||||||
|
logger.debug('Extracted repo name from URL', { repoName });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error extracting repo name from URL', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
// If URL parsing fails, try to extract from the string directly
|
||||||
|
const match = mission.giteaRepositoryUrl.match(/\/([^\/]+)\/?$/);
|
||||||
|
repoName = match ? match[1] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data according to N8N workflow expectations
|
||||||
|
// The workflow expects: repoName, leantimeProjectId, documentationCollectionId, rocketchatChannelId
|
||||||
|
const n8nDeletionData = {
|
||||||
|
missionId: mission.id,
|
||||||
|
name: mission.name,
|
||||||
|
repoName: repoName, // N8N expects repoName, not giteaRepositoryUrl
|
||||||
|
leantimeProjectId: mission.leantimeProjectId || 0,
|
||||||
|
documentationCollectionId: mission.outlineCollectionId || '', // N8N expects documentationCollectionId
|
||||||
|
rocketchatChannelId: mission.rocketChatChannelId || '', // N8N expects rocketchatChannelId (lowercase 'c')
|
||||||
|
// Keep original fields for reference
|
||||||
|
giteaRepositoryUrl: mission.giteaRepositoryUrl,
|
||||||
|
outlineCollectionId: mission.outlineCollectionId,
|
||||||
|
rocketChatChannelId: mission.rocketChatChannelId,
|
||||||
|
penpotProjectId: mission.penpotProjectId,
|
||||||
|
config: {
|
||||||
|
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||||
|
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://hub.slm-lab.net'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Sending deletion data to N8N', {
|
||||||
|
missionId: n8nDeletionData.missionId,
|
||||||
|
name: n8nDeletionData.name,
|
||||||
|
hasRepoName: !!n8nDeletionData.repoName
|
||||||
|
});
|
||||||
|
|
||||||
|
const n8nResult = await n8nService.triggerMissionDeletion(n8nDeletionData);
|
||||||
|
logger.debug('N8N deletion workflow result', {
|
||||||
|
success: n8nResult.success,
|
||||||
|
hasError: !!n8nResult.error
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!n8nResult.success) {
|
||||||
|
logger.error('N8N deletion workflow failed, but continuing with mission deletion', {
|
||||||
|
error: 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);
|
||||||
|
logger.debug('Logo deleted successfully from Minio');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting mission logo from Minio', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
// Continue deletion even if logo deletion fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete attachments from Minio
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
logger.debug(`Deleting ${attachments.length} attachment(s) from Minio`);
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
try {
|
||||||
|
await deleteMissionAttachment(attachment.filePath);
|
||||||
|
logger.debug('Attachment deleted successfully', { filename: attachment.filename });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting attachment from Minio', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
filename: attachment.filename
|
||||||
|
});
|
||||||
|
// Continue deletion even if one attachment fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete the mission from database (CASCADE will delete MissionUsers and Attachments)
|
||||||
|
await prisma.mission.delete({
|
||||||
|
where: { id: params.missionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Mission deleted successfully from database', { missionId: params.missionId });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting mission', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
missionId: params.missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete mission' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/api/missions/all/route.ts
Normal file
119
app/api/missions/all/route.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getPublicUrl } from '@/lib/s3';
|
||||||
|
import { S3_CONFIG } from '@/lib/s3';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.error('Unauthorized access attempt', {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
return { authorized: false, userId: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint to list all missions (not filtered by user)
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Number(searchParams.get('limit') || '100'); // Default to 100 for "all"
|
||||||
|
const offset = Number(searchParams.get('offset') || '0');
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
|
||||||
|
// Build query conditions
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
// Add search filter if provided
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ intention: { contains: search, mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all missions with basic info (no user filtering)
|
||||||
|
const missions = await prisma.mission.findMany({
|
||||||
|
where,
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
logo: true,
|
||||||
|
oddScope: true,
|
||||||
|
niveau: true,
|
||||||
|
missionType: true,
|
||||||
|
projection: true,
|
||||||
|
participation: true,
|
||||||
|
services: true,
|
||||||
|
intention: true,
|
||||||
|
createdAt: true,
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
missionUsers: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
role: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const totalCount = await prisma.mission.count({ where });
|
||||||
|
|
||||||
|
// Transform missions to include public URLs (same format as /api/missions)
|
||||||
|
const missionsWithPublicUrls = missions.map(mission => {
|
||||||
|
logger.debug('Processing mission logo', {
|
||||||
|
missionId: mission.id,
|
||||||
|
hasLogo: !!mission.logo
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mission,
|
||||||
|
logoUrl: mission.logo ? `/api/missions/image/${mission.logo}` : null,
|
||||||
|
logo: mission.logo, // Keep original logo path
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
missions: missionsWithPublicUrls,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error listing all missions', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/api/missions/image/[...path]/route.ts
Normal file
94
app/api/missions/image/[...path]/route.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/app/api/auth/options';
|
||||||
|
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { NoSuchKey } from '@aws-sdk/client-s3';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Initialize S3 client
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: 'us-east-1',
|
||||||
|
endpoint: 'https://dome-api.slm-lab.net',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: '4aBT4CMb7JIMMyUtp4Pl',
|
||||||
|
secretAccessKey: 'HGn39XhCIlqOjmDVzRK9MED2Fci2rYvDDgbLFElg'
|
||||||
|
},
|
||||||
|
forcePathStyle: true // Required for MinIO
|
||||||
|
});
|
||||||
|
|
||||||
|
// This endpoint serves mission images from Minio using the server's credentials
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { path: pathSegments } = await params;
|
||||||
|
if (!pathSegments || pathSegments.length === 0) {
|
||||||
|
logger.error('No path segments provided');
|
||||||
|
return new NextResponse('Path is required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the full path from path segments
|
||||||
|
const filePath = pathSegments.join('/');
|
||||||
|
logger.debug('Fetching mission image', {
|
||||||
|
originalPath: filePath,
|
||||||
|
segments: pathSegments
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the missions/ prefix from the URL path since the file is already in the missions bucket
|
||||||
|
const minioPath = filePath.replace(/^missions\//, '');
|
||||||
|
logger.debug('Full Minio path', {
|
||||||
|
minioPath,
|
||||||
|
bucket: 'missions'
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: 'missions',
|
||||||
|
Key: minioPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
if (!response.Body) {
|
||||||
|
logger.error('File not found in Minio', {
|
||||||
|
path: filePath,
|
||||||
|
minioPath,
|
||||||
|
bucket: 'missions'
|
||||||
|
});
|
||||||
|
return new NextResponse('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate content type and cache control
|
||||||
|
const contentType = response.ContentType || 'image/png'; // Default to image/png if not specified
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', contentType);
|
||||||
|
headers.set('Cache-Control', 'public, max-age=31536000');
|
||||||
|
|
||||||
|
logger.debug('Serving image', {
|
||||||
|
path: filePath,
|
||||||
|
minioPath,
|
||||||
|
contentType,
|
||||||
|
contentLength: response.ContentLength
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(response.Body as any, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching file from Minio', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
path: filePath,
|
||||||
|
minioPath,
|
||||||
|
bucket: 'missions',
|
||||||
|
errorType: error instanceof NoSuchKey ? 'NoSuchKey' : 'Unknown'
|
||||||
|
});
|
||||||
|
if (error instanceof NoSuchKey) {
|
||||||
|
return new NextResponse('File not found', { status: 404 });
|
||||||
|
}
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in image serving', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
202
app/api/missions/mission-created/route.ts
Normal file
202
app/api/missions/mission-created/route.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/missions/mission-created
|
||||||
|
*
|
||||||
|
* Endpoint appelé par N8N après la création des intégrations externes.
|
||||||
|
* Reçoit les IDs des intégrations créées et met à jour la mission en base.
|
||||||
|
*
|
||||||
|
* Headers attendus:
|
||||||
|
* - Authorization: Bearer {keycloak_token} (optionnel, vérifié via x-api-key)
|
||||||
|
* - x-api-key: {N8N_API_KEY}
|
||||||
|
*
|
||||||
|
* Body attendu (format N8N):
|
||||||
|
* {
|
||||||
|
* name: string,
|
||||||
|
* creatorId: string,
|
||||||
|
* gitRepoUrl?: string,
|
||||||
|
* leantimeProjectId?: string,
|
||||||
|
* documentationCollectionId?: string,
|
||||||
|
* rocketchatChannelId?: string,
|
||||||
|
* // ... autres champs optionnels
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
logger.debug('Mission Created Webhook Received');
|
||||||
|
|
||||||
|
// Vérifier l'API key
|
||||||
|
const apiKey = request.headers.get('x-api-key');
|
||||||
|
const expectedApiKey = process.env.N8N_API_KEY;
|
||||||
|
|
||||||
|
if (!expectedApiKey) {
|
||||||
|
logger.error('N8N_API_KEY not configured in environment');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server configuration error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey !== expectedApiKey) {
|
||||||
|
logger.error('Invalid API key', {
|
||||||
|
received: apiKey ? 'present' : 'missing',
|
||||||
|
expected: expectedApiKey ? 'configured' : 'missing'
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
logger.debug('Received mission-created data', {
|
||||||
|
hasMissionId: !!body.missionId,
|
||||||
|
hasName: !!body.name,
|
||||||
|
hasCreatorId: !!body.creatorId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation des champs requis
|
||||||
|
// Prefer missionId if provided, otherwise use name + creatorId
|
||||||
|
let mission;
|
||||||
|
|
||||||
|
if (body.missionId) {
|
||||||
|
// ✅ Use missionId if provided (more reliable)
|
||||||
|
logger.debug('Looking up mission by ID', { missionId: body.missionId });
|
||||||
|
mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: body.missionId }
|
||||||
|
});
|
||||||
|
} else if (body.name && body.creatorId) {
|
||||||
|
// Fallback to name + creatorId (for backward compatibility)
|
||||||
|
logger.debug('Looking up mission by name + creatorId', {
|
||||||
|
name: body.name,
|
||||||
|
creatorId: body.creatorId
|
||||||
|
});
|
||||||
|
mission = await prisma.mission.findFirst({
|
||||||
|
where: {
|
||||||
|
name: body.name,
|
||||||
|
creatorId: body.creatorId
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc' // Prendre la plus récente
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Missing required fields', {
|
||||||
|
hasMissionId: !!body.missionId,
|
||||||
|
hasName: !!body.name,
|
||||||
|
hasCreatorId: !!body.creatorId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: missionId OR (name and creatorId)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
logger.error('Mission not found', {
|
||||||
|
missionId: body.missionId,
|
||||||
|
name: body.name,
|
||||||
|
creatorId: body.creatorId
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Mission not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Found mission', {
|
||||||
|
id: mission.id,
|
||||||
|
name: mission.name,
|
||||||
|
hasIntegrations: {
|
||||||
|
gitea: !!mission.giteaRepositoryUrl,
|
||||||
|
leantime: !!mission.leantimeProjectId,
|
||||||
|
outline: !!mission.outlineCollectionId,
|
||||||
|
rocketChat: !!mission.rocketChatChannelId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Préparer les données de mise à jour
|
||||||
|
const updateData: {
|
||||||
|
giteaRepositoryUrl?: string | null;
|
||||||
|
leantimeProjectId?: string | null;
|
||||||
|
outlineCollectionId?: string | null;
|
||||||
|
rocketChatChannelId?: string | null;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Mapper les champs N8N vers notre schéma Prisma
|
||||||
|
if (body.gitRepoUrl !== undefined) {
|
||||||
|
updateData.giteaRepositoryUrl = body.gitRepoUrl || null;
|
||||||
|
logger.debug('Updating giteaRepositoryUrl', { hasUrl: !!body.gitRepoUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.leantimeProjectId !== undefined) {
|
||||||
|
// N8N peut retourner un number, on le convertit en string
|
||||||
|
updateData.leantimeProjectId = body.leantimeProjectId
|
||||||
|
? String(body.leantimeProjectId)
|
||||||
|
: null;
|
||||||
|
logger.debug('Updating leantimeProjectId', { hasId: !!updateData.leantimeProjectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.documentationCollectionId !== undefined) {
|
||||||
|
updateData.outlineCollectionId = body.documentationCollectionId || null;
|
||||||
|
logger.debug('Updating outlineCollectionId', { hasId: !!updateData.outlineCollectionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.rocketchatChannelId !== undefined) {
|
||||||
|
updateData.rocketChatChannelId = body.rocketchatChannelId || null;
|
||||||
|
logger.debug('Updating rocketChatChannelId', { hasId: !!updateData.rocketChatChannelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier qu'il y a au moins un champ à mettre à jour
|
||||||
|
if (Object.keys(updateData).length === 0) {
|
||||||
|
logger.warn('No integration IDs to update');
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Mission found but no integration IDs provided',
|
||||||
|
mission: {
|
||||||
|
id: mission.id,
|
||||||
|
name: mission.name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour la mission
|
||||||
|
const updatedMission = await prisma.mission.update({
|
||||||
|
where: { id: mission.id },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Mission updated successfully', {
|
||||||
|
id: updatedMission.id,
|
||||||
|
name: updatedMission.name,
|
||||||
|
updatedFields: Object.keys(updateData)
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Mission updated successfully',
|
||||||
|
mission: {
|
||||||
|
id: updatedMission.id,
|
||||||
|
name: updatedMission.name,
|
||||||
|
giteaRepositoryUrl: updatedMission.giteaRepositoryUrl,
|
||||||
|
leantimeProjectId: updatedMission.leantimeProjectId,
|
||||||
|
outlineCollectionId: updatedMission.outlineCollectionId,
|
||||||
|
rocketChatChannelId: updatedMission.rocketChatChannelId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in mission-created webhook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Failed to update mission',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
481
app/api/missions/route.ts
Normal file
481
app/api/missions/route.ts
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { N8nService } from '@/lib/services/n8n-service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { s3Client } from '@/lib/s3';
|
||||||
|
import { CopyObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import { uploadMissionLogo, uploadMissionAttachment, getMissionFileUrl } from '@/lib/mission-uploads';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface MissionCreateInput {
|
||||||
|
name: string;
|
||||||
|
oddScope: string[];
|
||||||
|
niveau?: string;
|
||||||
|
intention?: string;
|
||||||
|
missionType?: string;
|
||||||
|
donneurDOrdre?: string;
|
||||||
|
projection?: string;
|
||||||
|
services?: string[];
|
||||||
|
participation?: string;
|
||||||
|
profils?: string[];
|
||||||
|
guardians?: Record<string, string>;
|
||||||
|
volunteers?: string[];
|
||||||
|
creatorId?: string;
|
||||||
|
logo?: {
|
||||||
|
data: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
} | null;
|
||||||
|
attachments?: Array<{
|
||||||
|
data: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
leantimeProjectId?: string | null;
|
||||||
|
outlineCollectionId?: string | null;
|
||||||
|
rocketChatChannelId?: string | null;
|
||||||
|
giteaRepositoryUrl?: string | null;
|
||||||
|
penpotProjectId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissionUserInput {
|
||||||
|
role: string;
|
||||||
|
userId: string;
|
||||||
|
missionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissionResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
oddScope: string[];
|
||||||
|
niveau: string;
|
||||||
|
intention: string;
|
||||||
|
missionType: string;
|
||||||
|
donneurDOrdre: string;
|
||||||
|
projection: string;
|
||||||
|
services: string[];
|
||||||
|
profils: string[];
|
||||||
|
participation: string;
|
||||||
|
creatorId: string;
|
||||||
|
logo: string | null;
|
||||||
|
leantimeProjectId: string | null;
|
||||||
|
outlineCollectionId: string | null;
|
||||||
|
rocketChatChannelId: string | null;
|
||||||
|
giteaRepositoryUrl: string | null;
|
||||||
|
penpotProjectId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
attachments?: Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
filePath: string;
|
||||||
|
fileType: string;
|
||||||
|
fileSize: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
return {
|
||||||
|
authorized: !!session?.user,
|
||||||
|
userId: session?.user?.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint to list missions
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = Number(searchParams.get('limit') || '10');
|
||||||
|
const offset = Number(searchParams.get('offset') || '0');
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
const name = searchParams.get('name');
|
||||||
|
|
||||||
|
const where: Prisma.MissionWhereInput = {};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ intention: { contains: search, mode: 'insensitive' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
where.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missions = await prisma.mission.findMany({
|
||||||
|
where,
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
missionUsers: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
filename: true,
|
||||||
|
filePath: true,
|
||||||
|
fileType: true,
|
||||||
|
fileSize: true,
|
||||||
|
createdAt: true
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = await prisma.mission.count({ where });
|
||||||
|
|
||||||
|
// Transform missions to include public URLs
|
||||||
|
const missionsWithUrls = missions.map(mission => {
|
||||||
|
logger.debug('Processing mission logo:', {
|
||||||
|
missionId: mission.id,
|
||||||
|
hasLogo: !!mission.logo,
|
||||||
|
constructedUrl: mission.logo ? `/api/missions/image/${mission.logo}` : null
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mission,
|
||||||
|
logoUrl: mission.logo ? `/api/missions/image/${mission.logo}` : null,
|
||||||
|
logo: mission.logo,
|
||||||
|
attachments: mission.attachments?.map(attachment => ({
|
||||||
|
...attachment,
|
||||||
|
publicUrl: `/api/missions/image/${attachment.filePath}`
|
||||||
|
})) || []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
missions: missionsWithUrls,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error listing missions', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to verify file exists in Minio
|
||||||
|
async function verifyFileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await s3Client.send(new HeadObjectCommand({
|
||||||
|
Bucket: 'missions',
|
||||||
|
Key: filePath.replace('missions/', '')
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error verifying file:', {
|
||||||
|
filePath,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST endpoint to create a new mission
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let uploadedFiles: { type: 'logo' | 'attachment', path: string }[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug('Mission creation started');
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
logger.debug('Received mission creation request', {
|
||||||
|
hasName: !!body.name,
|
||||||
|
hasOddScope: !!body.oddScope,
|
||||||
|
hasLogo: !!body.logo?.data,
|
||||||
|
attachmentsCount: body.attachments?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple validation
|
||||||
|
if (!body.name || !body.oddScope) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing required fields',
|
||||||
|
missingFields: ['name', 'oddScope'].filter(field => !body[field])
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create mission in database first
|
||||||
|
const missionData = {
|
||||||
|
name: body.name,
|
||||||
|
oddScope: body.oddScope,
|
||||||
|
niveau: body.niveau,
|
||||||
|
intention: body.intention,
|
||||||
|
missionType: body.missionType,
|
||||||
|
donneurDOrdre: body.donneurDOrdre,
|
||||||
|
projection: body.projection,
|
||||||
|
services: body.services,
|
||||||
|
profils: body.profils,
|
||||||
|
participation: body.participation,
|
||||||
|
creatorId: userId,
|
||||||
|
logo: null, // Will update after upload
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug('Creating mission', {
|
||||||
|
name: missionData.name,
|
||||||
|
oddScope: missionData.oddScope,
|
||||||
|
niveau: missionData.niveau,
|
||||||
|
missionType: missionData.missionType
|
||||||
|
});
|
||||||
|
|
||||||
|
const mission = await prisma.mission.create({
|
||||||
|
data: missionData
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Mission created successfully', {
|
||||||
|
missionId: mission.id,
|
||||||
|
name: mission.name
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Create mission users (guardians and volunteers)
|
||||||
|
const missionUsers = [];
|
||||||
|
|
||||||
|
// Add guardians
|
||||||
|
if (body.guardians) {
|
||||||
|
for (const [role, guardianId] of Object.entries(body.guardians)) {
|
||||||
|
if (guardianId) {
|
||||||
|
missionUsers.push({
|
||||||
|
missionId: mission.id,
|
||||||
|
userId: guardianId,
|
||||||
|
role: role
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add volunteers
|
||||||
|
if (body.volunteers && body.volunteers.length > 0) {
|
||||||
|
for (const volunteerId of body.volunteers) {
|
||||||
|
missionUsers.push({
|
||||||
|
missionId: mission.id,
|
||||||
|
userId: volunteerId,
|
||||||
|
role: 'volontaire'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all mission users
|
||||||
|
if (missionUsers.length > 0) {
|
||||||
|
await prisma.missionUser.createMany({
|
||||||
|
data: missionUsers
|
||||||
|
});
|
||||||
|
logger.debug('Mission users created', {
|
||||||
|
count: missionUsers.length,
|
||||||
|
roles: missionUsers.map(u => u.role)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Upload logo to Minio if present
|
||||||
|
let logoPath = null;
|
||||||
|
let logoUrl = null; // Public URL for the logo
|
||||||
|
if (body.logo?.data) {
|
||||||
|
try {
|
||||||
|
// Convert base64 to File object
|
||||||
|
const base64Data = body.logo.data.split(',')[1];
|
||||||
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
const file = new File([buffer], body.logo.name || 'logo.png', { type: body.logo.type || 'image/png' });
|
||||||
|
|
||||||
|
// Upload logo using the correct function
|
||||||
|
const { filePath } = await uploadMissionLogo(userId, mission.id, file);
|
||||||
|
logoPath = filePath;
|
||||||
|
uploadedFiles.push({ type: 'logo', path: filePath });
|
||||||
|
|
||||||
|
// Generate public URL for the logo (using the API endpoint)
|
||||||
|
// Use the helper function to construct the URL
|
||||||
|
const relativeUrl = getMissionFileUrl(filePath);
|
||||||
|
|
||||||
|
// Construct full URL with base domain
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_APP_URL || 'https://hub.slm-lab.net';
|
||||||
|
logoUrl = `${baseUrl}${relativeUrl}`;
|
||||||
|
|
||||||
|
// Update mission with logo path and URL
|
||||||
|
await prisma.mission.update({
|
||||||
|
where: { id: mission.id },
|
||||||
|
data: {
|
||||||
|
logo: filePath,
|
||||||
|
logoUrl: logoUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Logo uploaded successfully', {
|
||||||
|
logoPath,
|
||||||
|
logoUrl
|
||||||
|
});
|
||||||
|
} catch (uploadError) {
|
||||||
|
logger.error('Error uploading logo', {
|
||||||
|
error: uploadError instanceof Error ? uploadError.message : String(uploadError),
|
||||||
|
missionId: mission.id
|
||||||
|
});
|
||||||
|
throw new Error('Failed to upload logo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Handle attachments if present
|
||||||
|
if (body.attachments && body.attachments.length > 0) {
|
||||||
|
try {
|
||||||
|
const attachmentPromises = body.attachments.map(async (attachment: any) => {
|
||||||
|
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' });
|
||||||
|
|
||||||
|
// Upload attachment using the correct function
|
||||||
|
const { filePath, filename, fileType, fileSize } = await uploadMissionAttachment(userId, mission.id, file);
|
||||||
|
uploadedFiles.push({ type: 'attachment', path: filePath });
|
||||||
|
|
||||||
|
// Create attachment record in database with final path
|
||||||
|
return prisma.attachment.create({
|
||||||
|
data: {
|
||||||
|
missionId: mission.id,
|
||||||
|
filename,
|
||||||
|
filePath,
|
||||||
|
fileType,
|
||||||
|
fileSize,
|
||||||
|
uploaderId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(attachmentPromises);
|
||||||
|
logger.debug('Attachments uploaded successfully', {
|
||||||
|
count: body.attachments.length
|
||||||
|
});
|
||||||
|
} catch (attachmentError) {
|
||||||
|
logger.error('Error uploading attachments', {
|
||||||
|
error: attachmentError instanceof Error ? attachmentError.message : String(attachmentError),
|
||||||
|
missionId: mission.id
|
||||||
|
});
|
||||||
|
throw new Error('Failed to upload attachments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Verify all files are in Minio before triggering n8n
|
||||||
|
try {
|
||||||
|
// Verify logo if present
|
||||||
|
if (logoPath) {
|
||||||
|
const logoExists = await verifyFileExists(logoPath);
|
||||||
|
if (!logoExists) {
|
||||||
|
throw new Error('Logo file not found in Minio');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachments if present
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only trigger n8n after verifying all files
|
||||||
|
logger.debug('Starting N8N workflow');
|
||||||
|
const n8nService = new N8nService();
|
||||||
|
|
||||||
|
const n8nData = {
|
||||||
|
...body,
|
||||||
|
missionId: mission.id, // ✅ Send missionId so N8N can return it in /mission-created
|
||||||
|
creatorId: userId,
|
||||||
|
logoPath: logoPath,
|
||||||
|
logoUrl: logoUrl, // ✅ Send logo URL for N8N to use
|
||||||
|
config: {
|
||||||
|
N8N_API_KEY: process.env.N8N_API_KEY,
|
||||||
|
MISSION_API_URL: process.env.NEXT_PUBLIC_API_URL
|
||||||
|
}
|
||||||
|
};
|
||||||
|
logger.debug('Sending to N8N', {
|
||||||
|
missionId: n8nData.missionId,
|
||||||
|
name: n8nData.name,
|
||||||
|
hasLogo: !!n8nData.logoPath
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowResult = await n8nService.triggerMissionCreation(n8nData);
|
||||||
|
logger.debug('N8N workflow result', {
|
||||||
|
success: workflowResult.success,
|
||||||
|
hasError: !!workflowResult.error
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workflowResult.success) {
|
||||||
|
throw new Error(workflowResult.error || 'N8N workflow failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
mission,
|
||||||
|
message: 'Mission created successfully with all integrations'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in final verification or n8n', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in mission creation', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
uploadedFilesCount: uploadedFiles.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup: Delete any uploaded files
|
||||||
|
for (const file of uploadedFiles) {
|
||||||
|
try {
|
||||||
|
await s3Client.send(new DeleteObjectCommand({
|
||||||
|
Bucket: 'missions',
|
||||||
|
Key: file.path.replace('missions/', '')
|
||||||
|
}));
|
||||||
|
logger.debug('Cleaned up file', { path: file.path });
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.error('Error cleaning up file', {
|
||||||
|
path: file.path,
|
||||||
|
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Failed to create mission',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/api/missions/test-n8n-config/route.ts
Normal file
169
app/api/missions/test-n8n-config/route.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/missions/test-n8n-config
|
||||||
|
*
|
||||||
|
* Endpoint de test pour vérifier la configuration N8N
|
||||||
|
* Permet de diagnostiquer les problèmes de connexion entre Next.js et N8N
|
||||||
|
*
|
||||||
|
* Authentification: Requise (session utilisateur)
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les variables d'environnement
|
||||||
|
const n8nApiKey = process.env.N8N_API_KEY;
|
||||||
|
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/mission-created';
|
||||||
|
const n8nRollbackWebhookUrl = process.env.N8N_ROLLBACK_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook/mission-rollback';
|
||||||
|
const missionApiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.slm-lab.net/api';
|
||||||
|
const n8nDeleteWebhookUrl = process.env.N8N_DELETE_WEBHOOK_URL;
|
||||||
|
|
||||||
|
// Construire la réponse
|
||||||
|
const config: {
|
||||||
|
environment: {
|
||||||
|
hasN8NApiKey: boolean;
|
||||||
|
n8nApiKeyLength: number;
|
||||||
|
n8nApiKeyPrefix: string;
|
||||||
|
n8nWebhookUrl: string;
|
||||||
|
n8nRollbackWebhookUrl: string;
|
||||||
|
n8nDeleteWebhookUrl: string;
|
||||||
|
missionApiUrl: string;
|
||||||
|
};
|
||||||
|
urls: {
|
||||||
|
webhookUrl: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
rollbackUrl: string;
|
||||||
|
deleteUrl: string;
|
||||||
|
webhookTest?: {
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
reachable?: boolean;
|
||||||
|
note?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
status: {
|
||||||
|
configured: boolean;
|
||||||
|
missingApiKey: boolean;
|
||||||
|
missingApiUrl: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
};
|
||||||
|
recommendations: string[];
|
||||||
|
} = {
|
||||||
|
// Variables d'environnement
|
||||||
|
environment: {
|
||||||
|
hasN8NApiKey: !!n8nApiKey,
|
||||||
|
n8nApiKeyLength: n8nApiKey?.length || 0,
|
||||||
|
n8nApiKeyPrefix: n8nApiKey ? `${n8nApiKey.substring(0, 4)}...` : 'none',
|
||||||
|
n8nWebhookUrl,
|
||||||
|
n8nRollbackWebhookUrl,
|
||||||
|
n8nDeleteWebhookUrl: n8nDeleteWebhookUrl || 'not configured',
|
||||||
|
missionApiUrl,
|
||||||
|
},
|
||||||
|
|
||||||
|
// URLs construites
|
||||||
|
urls: {
|
||||||
|
webhookUrl: n8nWebhookUrl,
|
||||||
|
callbackUrl: `${missionApiUrl}/api/missions/mission-created`,
|
||||||
|
rollbackUrl: n8nRollbackWebhookUrl,
|
||||||
|
deleteUrl: n8nDeleteWebhookUrl || 'not configured',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Statut de configuration
|
||||||
|
status: {
|
||||||
|
configured: !!n8nApiKey && !!missionApiUrl,
|
||||||
|
missingApiKey: !n8nApiKey,
|
||||||
|
missingApiUrl: !missionApiUrl,
|
||||||
|
ready: !!n8nApiKey && !!missionApiUrl,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Recommandations
|
||||||
|
recommendations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter des recommandations basées sur la configuration
|
||||||
|
if (!n8nApiKey) {
|
||||||
|
config.recommendations.push('❌ N8N_API_KEY n\'est pas défini. Ajoutez-le à vos variables d\'environnement.');
|
||||||
|
} else {
|
||||||
|
config.recommendations.push('✅ N8N_API_KEY est configuré');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!missionApiUrl) {
|
||||||
|
config.recommendations.push('⚠️ NEXT_PUBLIC_API_URL n\'est pas défini. Utilisation de la valeur par défaut.');
|
||||||
|
} else {
|
||||||
|
config.recommendations.push('✅ NEXT_PUBLIC_API_URL est configuré');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n8nApiKey && n8nApiKey.length < 10) {
|
||||||
|
config.recommendations.push('⚠️ N8N_API_KEY semble trop court. Vérifiez qu\'il est correct.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester la connectivité au webhook N8N (optionnel, peut être lent)
|
||||||
|
const testWebhook = request.headers.get('x-test-webhook') === 'true';
|
||||||
|
if (testWebhook) {
|
||||||
|
try {
|
||||||
|
logger.debug('Testing N8N webhook connectivity', { url: n8nWebhookUrl });
|
||||||
|
const testResponse = await fetch(n8nWebhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': n8nApiKey || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ test: true }),
|
||||||
|
signal: AbortSignal.timeout(5000), // 5 secondes timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
config.urls.webhookTest = {
|
||||||
|
status: testResponse.status,
|
||||||
|
statusText: testResponse.statusText,
|
||||||
|
reachable: testResponse.status !== 0,
|
||||||
|
note: testResponse.status === 404
|
||||||
|
? 'Webhook non enregistré (workflow inactif?)'
|
||||||
|
: testResponse.status === 200 || testResponse.status === 400 || testResponse.status === 500
|
||||||
|
? 'Webhook actif (peut échouer avec des données de test)'
|
||||||
|
: 'Réponse inattendue',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
config.urls.webhookTest = {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
reachable: false,
|
||||||
|
note: 'Impossible de joindre le webhook N8N',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.urls.webhookTest = {
|
||||||
|
note: 'Ajoutez le header "x-test-webhook: true" pour tester la connectivité',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in test-n8n-config endpoint', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to check N8N configuration',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
235
app/api/missions/upload/route.ts
Normal file
235
app/api/missions/upload/route.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import {
|
||||||
|
uploadMissionLogo,
|
||||||
|
uploadMissionAttachment,
|
||||||
|
generateMissionLogoUploadUrl,
|
||||||
|
generateMissionAttachmentUploadUrl
|
||||||
|
} from '@/lib/mission-uploads';
|
||||||
|
import { getPublicUrl, S3_CONFIG } from '@/lib/s3';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to check authentication
|
||||||
|
async function checkAuth(request: Request) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.error('Unauthorized access attempt', {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
return { authorized: false, userId: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned URL for direct upload to S3/Minio
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const missionId = searchParams.get('missionId');
|
||||||
|
const type = searchParams.get('type'); // 'logo' or 'attachment'
|
||||||
|
const filename = searchParams.get('filename');
|
||||||
|
|
||||||
|
if (!missionId || !type || !filename) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing required parameters',
|
||||||
|
required: { missionId: true, type: true, filename: true },
|
||||||
|
received: { missionId: !!missionId, type: !!type, filename: !!filename }
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the mission exists and user has access to it
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: missionId },
|
||||||
|
select: { id: true, creatorId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently only allow creator to upload files
|
||||||
|
// You can modify this to include other roles if needed
|
||||||
|
if (mission.creatorId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Not authorized to upload to this mission' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (type === 'logo') {
|
||||||
|
// For logo, we expect filename to contain the file extension (e.g., '.jpg')
|
||||||
|
const fileExtension = filename.substring(filename.lastIndexOf('.'));
|
||||||
|
result = await generateMissionLogoUploadUrl(userId, missionId, fileExtension);
|
||||||
|
} else if (type === 'attachment') {
|
||||||
|
result = await generateMissionAttachmentUploadUrl(userId, missionId, filename);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Invalid upload type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating upload URL', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload (server-side)
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
logger.debug('File upload request received');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { authorized, userId } = await checkAuth(request);
|
||||||
|
if (!authorized || !userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the form data
|
||||||
|
const formData = await request.formData();
|
||||||
|
const missionId = formData.get('missionId') as string;
|
||||||
|
const type = formData.get('type') as string; // 'logo' or 'attachment'
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
|
||||||
|
logger.debug('Form data received', {
|
||||||
|
missionId,
|
||||||
|
type,
|
||||||
|
fileExists: !!file,
|
||||||
|
fileName: file?.name,
|
||||||
|
fileSize: file?.size,
|
||||||
|
fileType: file?.type
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!missionId || !type || !file) {
|
||||||
|
logger.error('Missing required fields', { missionId: !!missionId, type: !!type, file: !!file });
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Missing required fields',
|
||||||
|
required: { missionId: true, type: true, file: true },
|
||||||
|
received: { missionId: !!missionId, type: !!type, file: !!file }
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the mission exists and user has access to it
|
||||||
|
const mission = await prisma.mission.findUnique({
|
||||||
|
where: { id: missionId },
|
||||||
|
select: { id: true, creatorId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mission) {
|
||||||
|
logger.error('Mission not found', { missionId });
|
||||||
|
return NextResponse.json({ error: 'Mission not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently only allow creator to upload files
|
||||||
|
if (mission.creatorId !== userId) {
|
||||||
|
logger.error('User not authorized to upload to this mission', { userId, creatorId: mission.creatorId });
|
||||||
|
return NextResponse.json({ error: 'Not authorized to upload to this mission' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'logo') {
|
||||||
|
logger.debug('Processing logo upload');
|
||||||
|
try {
|
||||||
|
// Upload logo file to Minio
|
||||||
|
const { filePath } = await uploadMissionLogo(userId, missionId, file);
|
||||||
|
logger.debug('Logo uploaded successfully', { filePath });
|
||||||
|
|
||||||
|
// Generate public URL - remove missions/ prefix since it's added by the API
|
||||||
|
const publicUrl = `/api/missions/image/${filePath.replace('missions/', '')}`;
|
||||||
|
|
||||||
|
// Update mission record with logo path
|
||||||
|
await prisma.mission.update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: { logo: filePath }
|
||||||
|
});
|
||||||
|
logger.debug('Mission record updated with logo');
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
publicUrl
|
||||||
|
});
|
||||||
|
} catch (logoError) {
|
||||||
|
logger.error('Error in logo upload process', {
|
||||||
|
error: logoError instanceof Error ? logoError.message : String(logoError),
|
||||||
|
missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Logo upload failed',
|
||||||
|
details: logoError instanceof Error ? logoError.message : String(logoError)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type === 'attachment') {
|
||||||
|
// Upload attachment file to Minio
|
||||||
|
logger.debug('Processing attachment upload');
|
||||||
|
try {
|
||||||
|
const { filename, filePath, fileType, fileSize } = await uploadMissionAttachment(
|
||||||
|
userId,
|
||||||
|
missionId,
|
||||||
|
file
|
||||||
|
);
|
||||||
|
logger.debug('Attachment uploaded successfully', { filePath });
|
||||||
|
|
||||||
|
// Generate public URL
|
||||||
|
const publicUrl = getPublicUrl(filePath, S3_CONFIG.bucket);
|
||||||
|
|
||||||
|
// Create attachment record in database
|
||||||
|
const attachment = await prisma.attachment.create({
|
||||||
|
data: {
|
||||||
|
filename,
|
||||||
|
filePath,
|
||||||
|
fileType,
|
||||||
|
fileSize,
|
||||||
|
missionId,
|
||||||
|
uploaderId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.debug('Attachment record created', { attachmentId: attachment.id });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
attachment: {
|
||||||
|
id: attachment.id,
|
||||||
|
filename: attachment.filename,
|
||||||
|
filePath: attachment.filePath,
|
||||||
|
publicUrl,
|
||||||
|
fileType: attachment.fileType,
|
||||||
|
fileSize: attachment.fileSize,
|
||||||
|
createdAt: attachment.createdAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (attachmentError) {
|
||||||
|
logger.error('Error in attachment upload process', {
|
||||||
|
error: attachmentError instanceof Error ? attachmentError.message : String(attachmentError),
|
||||||
|
missionId
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Attachment upload failed',
|
||||||
|
details: attachmentError instanceof Error ? attachmentError.message : String(attachmentError)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.error('Invalid upload type', { type });
|
||||||
|
return NextResponse.json({ error: 'Invalid upload type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Unhandled error in upload process', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error)
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/api/news/purge-cache/route.ts
Normal file
24
app/api/news/purge-cache/route.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { invalidateNewsCache } from '@/lib/redis';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get limit from query params if available
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = url.searchParams.get('limit');
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
await invalidateNewsCache(limit);
|
||||||
|
return NextResponse.json({ success: true, message: `Cache invalidated for limit=${limit}` });
|
||||||
|
} else {
|
||||||
|
await invalidateNewsCache();
|
||||||
|
return NextResponse.json({ success: true, message: 'All news caches invalidated' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to invalidate news cache:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to invalidate cache', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
app/api/news/route.ts
Normal file
197
app/api/news/route.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { getCachedNewsData, cacheNewsData } from '@/lib/redis';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
// Helper function to clean HTML content
|
||||||
|
function cleanHtmlContent(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||||
|
.replace(/ /g, ' ') // Replace with space
|
||||||
|
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format time
|
||||||
|
function formatDateTime(dateStr: string): { displayDate: string, timestamp: string } {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
|
||||||
|
// Format like "17 avr." to match the Duties widget style
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleString('fr-FR', { month: 'short' })
|
||||||
|
.toLowerCase()
|
||||||
|
.replace('.', ''); // Remove the dot that comes with French locale
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayDate: `${day} ${month}.`, // Add the dot back for consistent styling
|
||||||
|
timestamp: date.toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}).replace(',', ' à')
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { displayDate: 'N/A', timestamp: 'N/A' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to truncate text
|
||||||
|
function truncateText(text: string, maxLength: number): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const cleaned = cleanHtmlContent(text);
|
||||||
|
if (cleaned.length <= maxLength) return cleaned;
|
||||||
|
|
||||||
|
const lastSpace = cleaned.lastIndexOf(' ', maxLength);
|
||||||
|
const truncated = cleaned.substring(0, lastSpace > 0 ? lastSpace : maxLength).trim();
|
||||||
|
return truncated.replace(/[.,!?]$/, '') + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format category
|
||||||
|
function formatCategory(category: string): string | null {
|
||||||
|
if (!category) return null;
|
||||||
|
// Return null for all categories to remove the labels completely
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format source
|
||||||
|
function formatSource(source: string): string {
|
||||||
|
if (!source) return '';
|
||||||
|
const sourceName = source
|
||||||
|
.replace(/^(https?:\/\/)?(www\.)?/i, '')
|
||||||
|
.split('.')[0]
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return sourceName.charAt(0).toUpperCase() + sourceName.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewsItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
displayDate: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Check if we should bypass cache
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
||||||
|
|
||||||
|
// Get limit from query params or default to 100
|
||||||
|
const limit = url.searchParams.get('limit') || '100';
|
||||||
|
|
||||||
|
// Also bypass cache if a non-default limit is explicitly requested
|
||||||
|
const bypassCache = forceRefresh || (url.searchParams.has('limit') && limit !== '100');
|
||||||
|
|
||||||
|
logger.debug('[NEWS] Request received', {
|
||||||
|
limit,
|
||||||
|
forceRefresh,
|
||||||
|
bypassCache,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to get data from cache if not forcing refresh
|
||||||
|
if (!bypassCache) {
|
||||||
|
const cachedNews = await getCachedNewsData(limit);
|
||||||
|
if (cachedNews) {
|
||||||
|
logger.debug('[NEWS] Using cached news data', {
|
||||||
|
limit,
|
||||||
|
count: cachedNews.length,
|
||||||
|
});
|
||||||
|
return NextResponse.json(cachedNews);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = `${env.NEWS_API_URL}/news?limit=${limit}`;
|
||||||
|
logger.debug('[NEWS] Fetching from backend', { apiUrl });
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
// Add timeout to prevent hanging
|
||||||
|
signal: AbortSignal.timeout(10000) // Extended timeout for larger requests
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('[NEWS] Backend API error', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && !contentType.includes('application/json')) {
|
||||||
|
logger.error('[NEWS] Backend returned non-JSON response');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'News API returned invalid response format', status: response.status },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch news', status: response.status },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let articles;
|
||||||
|
try {
|
||||||
|
articles = await response.json();
|
||||||
|
logger.debug('[NEWS] Backend response summary', {
|
||||||
|
limit,
|
||||||
|
count: articles.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (articles.length < parseInt(limit) && articles.length > 0) {
|
||||||
|
logger.debug('[NEWS] Backend returned fewer articles than requested', {
|
||||||
|
requested: limit,
|
||||||
|
actual: articles.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[NEWS] Failed to parse backend response', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to parse news API response', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedNews: NewsItem[] = articles.map((article: any) => ({
|
||||||
|
id: article.id,
|
||||||
|
title: article.title,
|
||||||
|
displayDate: formatDateTime(article.date).displayDate,
|
||||||
|
timestamp: formatDateTime(article.date).timestamp,
|
||||||
|
source: formatSource(article.source),
|
||||||
|
description: truncateText(article.description || '', 200),
|
||||||
|
category: formatCategory(article.category),
|
||||||
|
url: article.url
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug('[NEWS] Returning formatted news', {
|
||||||
|
count: formattedNews.length,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
await cacheNewsData(formattedNews, limit);
|
||||||
|
|
||||||
|
return NextResponse.json(formattedNews);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[NEWS] Route error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch news', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/news/test-backend/route.ts
Normal file
64
app/api/news/test-backend/route.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
|
||||||
|
// Test endpoint to check backend behavior with limits
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Get limit parameter or use default limits for testing
|
||||||
|
const requestedLimit = url.searchParams.get('limit');
|
||||||
|
const limits = requestedLimit ? [requestedLimit] : ['5', '10', '50', '100'];
|
||||||
|
|
||||||
|
const results: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Test each limit
|
||||||
|
for (const limit of limits) {
|
||||||
|
console.log(`Testing backend with limit=${limit}...`);
|
||||||
|
const apiUrl = `${env.NEWS_API_URL}/news?limit=${limit}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
results[limit] = {
|
||||||
|
error: `API returned status ${response.status}`,
|
||||||
|
status: response.status
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = await response.json();
|
||||||
|
results[limit] = {
|
||||||
|
requested: parseInt(limit),
|
||||||
|
received: articles.length,
|
||||||
|
matches: articles.length === parseInt(limit),
|
||||||
|
firstArticleId: articles.length > 0 ? articles[0].id : null,
|
||||||
|
lastArticleId: articles.length > 0 ? articles[articles.length - 1].id : null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
results[limit] = {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
apiUrl: env.NEWS_API_URL,
|
||||||
|
results
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test endpoint error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to test backend', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/api/nextcloud/files/content/route.ts
Normal file
28
app/api/nextcloud/files/content/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
// This file serves as an adapter to redirect requests from the old NextCloud
|
||||||
|
// content endpoint to the new MinIO S3 content endpoint
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to storage handler
|
||||||
|
try {
|
||||||
|
const { GET: storageContentHandler } = await import('@/app/api/storage/files/content/route');
|
||||||
|
return await storageContentHandler(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling storage content handler:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to get file content' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud content adapter:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/api/nextcloud/files/route.ts
Normal file
94
app/api/nextcloud/files/route.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
// This file serves as an adapter to redirect requests from the old NextCloud
|
||||||
|
// endpoints to the new MinIO S3 endpoints
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query parameters - we'll pass the same request to the storage handler
|
||||||
|
try {
|
||||||
|
const { GET: storageFilesHandler } = await import('@/app/api/storage/files/route');
|
||||||
|
return await storageFilesHandler(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling storage files handler:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to list files' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud adapter (GET):', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to storage handler
|
||||||
|
try {
|
||||||
|
const { POST: storageFilesHandler } = await import('@/app/api/storage/files/route');
|
||||||
|
return await storageFilesHandler(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling storage files handler:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud adapter (POST):', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to storage handler
|
||||||
|
try {
|
||||||
|
const { PUT: storageFilesHandler } = await import('@/app/api/storage/files/route');
|
||||||
|
return await storageFilesHandler(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling storage files handler:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud adapter (PUT):', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to storage handler
|
||||||
|
try {
|
||||||
|
const { DELETE: storageFilesHandler } = await import('@/app/api/storage/files/route');
|
||||||
|
return await storageFilesHandler(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling storage files handler:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud adapter (DELETE):', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/api/nextcloud/init/route.ts
Normal file
28
app/api/nextcloud/init/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
// This file serves as an adapter to redirect requests from the old NextCloud
|
||||||
|
// init endpoint to the new MinIO S3 init endpoint
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to storage handler
|
||||||
|
try {
|
||||||
|
const { POST: storageInitHandler } = await import('@/app/api/storage/init/route');
|
||||||
|
return await storageInitHandler(request);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling storage init handler:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to initialize storage' }, { status: 500 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud init adapter:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/api/nextcloud/status/route.ts
Normal file
31
app/api/nextcloud/status/route.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
// This file serves as an adapter to redirect requests from the old NextCloud
|
||||||
|
// status endpoint to the new MinIO S3 status endpoint
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Get session
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backward compatibility, just return the standard folders
|
||||||
|
// This ensures the sidebar always shows something
|
||||||
|
const standardFolders = ['Notes', 'Diary', 'Health', 'Contacts'];
|
||||||
|
|
||||||
|
console.log('NextCloud status adapter returning standard folders:', standardFolders);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'ready',
|
||||||
|
folders: standardFolders
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in NextCloud status adapter:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/api/notifications/[id]/read/route.ts
Normal file
79
app/api/notifications/[id]/read/route.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||||
|
|
||||||
|
// POST /api/notifications/{id}/read
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
console.log('[NOTIFICATION_API] Mark as read endpoint called');
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
console.log('[NOTIFICATION_API] Mark as read - Authentication failed');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await params as per Next.js requirements
|
||||||
|
const params = await context.params;
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) {
|
||||||
|
console.log('[NOTIFICATION_API] Mark as read - Missing notification ID');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing notification ID" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
console.log('[NOTIFICATION_API] Mark as read - Processing', {
|
||||||
|
userId,
|
||||||
|
notificationId: id,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationService = NotificationService.getInstance();
|
||||||
|
const success = await notificationService.markAsRead(userId, id);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
console.log('[NOTIFICATION_API] Mark as read - Failed', {
|
||||||
|
userId,
|
||||||
|
notificationId: id,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to mark notification as read" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[NOTIFICATION_API] Mark as read - Success', {
|
||||||
|
userId,
|
||||||
|
notificationId: id,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.error('[NOTIFICATION_API] Mark as read - Error', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/notifications/count/route.ts
Normal file
33
app/api/notifications/count/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||||
|
|
||||||
|
// GET /api/notifications/count
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const notificationService = NotificationService.getInstance();
|
||||||
|
const counts = await notificationService.getNotificationCount(userId);
|
||||||
|
|
||||||
|
// Add Cache-Control header - rely on server-side cache, minimal client cache
|
||||||
|
const response = NextResponse.json(counts);
|
||||||
|
response.headers.set('Cache-Control', 'private, max-age=0, must-revalidate'); // No client cache, always revalidate
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in notification count API:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/api/notifications/read-all/route.ts
Normal file
62
app/api/notifications/read-all/route.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
import { NotificationService } from '@/lib/services/notifications/notification-service';
|
||||||
|
|
||||||
|
// POST /api/notifications/read-all
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
console.log('[NOTIFICATION_API] Mark all as read endpoint called');
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || !session.user?.id) {
|
||||||
|
console.log('[NOTIFICATION_API] Mark all as read - Authentication failed');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Not authenticated" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
console.log('[NOTIFICATION_API] Mark all as read - Processing', {
|
||||||
|
userId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationService = NotificationService.getInstance();
|
||||||
|
const success = await notificationService.markAllAsRead(userId);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
console.log('[NOTIFICATION_API] Mark all as read - Failed', {
|
||||||
|
userId,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to mark all notifications as read" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[NOTIFICATION_API] Mark all as read - Success', {
|
||||||
|
userId,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.error('[NOTIFICATION_API] Mark all as read - Error', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
duration: `${duration}ms`
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user