n8n int cleaning

This commit is contained in:
alma 2025-05-12 13:28:03 +02:00
parent 1c4166944f
commit 45728a8e0c
9 changed files with 725 additions and 1931 deletions

611
Missionspre.json Normal file
View File

@ -0,0 +1,611 @@
{
"name": "Missions",
"nodes": [
{
"parameters": {
"jsCode": "const missionData = $input.item.json;\nconst sanitizeName = (name) => {\n if (!name || typeof name !== 'string') return 'unnamed-mission';\n const timestamp = new Date().getTime();\n const uniqueSuffix = `-${timestamp}`;\n return name.toLowerCase().replace(/[^\\w\\s-]/g, '').replace(/\\s+/g, '-').trim() + uniqueSuffix;\n};\nconst formatDate = (date) => {\n if (!date) return '';\n const d = new Date(date);\n return d.toISOString().split('T')[0];\n};\nconst missionName = missionData?.missionOriginal?.body?.name || missionData?.body?.name || missionData?.name || 'Unnamed Mission';\n\n// Prepare file data for MinIO\nconst prepareFileData = (file) => {\n if (!file) return null;\n return {\n data: file.data || file,\n name: file.name || 'logo.png',\n type: file.type || 'image/png'\n };\n};\n\nconst output = {\n missionOriginal: missionData,\n missionProcessed: {\n name: missionName,\n sanitizedName: sanitizeName(missionName),\n intention: missionData?.missionOriginal?.body?.intention || missionData?.body?.intention || missionData?.intention || '',\n description: missionData?.missionOriginal?.body?.intention || missionData?.body?.intention || missionData?.intention || 'Mission documentation',\n startDate: formatDate(new Date()),\n endDate: formatDate(new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)),\n missionType: missionData?.missionOriginal?.body?.missionType || missionData?.body?.missionType || missionData?.missionType || 'default',\n guardians: missionData?.missionOriginal?.body?.guardians || missionData?.body?.guardians || missionData?.guardians || {},\n volunteers: missionData?.missionOriginal?.body?.volunteers || missionData?.body?.volunteers || missionData?.volunteers || [],\n profils: missionData?.missionOriginal?.body?.profils || missionData?.body?.profils || missionData?.profils || [],\n services: missionData?.missionOriginal?.body?.services || missionData?.body?.services || missionData?.services || [],\n clientId: (missionData?.missionOriginal?.body?.missionType === 'interne' || missionData?.body?.missionType === 'interne' || missionData?.missionType === 'interne') ? 1 : 2,\n rocketChatUsernames: [],\n logo: prepareFileData(missionData?.logo),\n attachments: Array.isArray(missionData?.attachments) ? missionData.attachments.map(prepareFileData) : []\n },\n config: {\n GITEA_API_URL: \"https://gite.slm-lab.net/api/v1\",\n GITEA_API_TOKEN: \"310645d564cbf752be1fe3b42582a3d5f5d0bddd\",\n GITEA_OWNER: \"alma\",\n LEANTIME_API_URL: \"https://agilite.slm-lab.net\",\n LEANTIME_API_TOKEN: \"lt_lsdShQdoYHaPUWuL07XZR1Rf3GeySsIs_UDlll3VJPk5EwAuILpMC4BwzJ9MZFRrb\",\n ROCKETCHAT_API_URL: \"https://parole.slm-lab.net/\",\n ROCKETCHAT_AUTH_TOKEN: \"w91TYgkH-Z67Oz72usYdkW5TZLLRwnre7qyAhp7aHJB\",\n ROCKETCHAT_USER_ID: \"Tpuww59PJKsrGNQJB\",\n OUTLINE_API_URL: \"https://chapitre.slm-lab.net/api\",\n OUTLINE_API_TOKEN: \"ol_api_tlLlANBfcoJ4l7zA8GOcpduAeL6QyBTcYvEnlN\",\n MISSION_API_URL: \"https://hub.slm-lab.net\",\n N8N_API_KEY: \"LwgeE1ntADD20OuWC88S3pR0EaO7FtO4\",\n KEYCLOAK_BASE_URL: \"https://connect.slm-lab.net\",\n KEYCLOAK_REALM: \"cercle\",\n KEYCLOAK_CLIENT_ID: \"lab\",\n KEYCLOAK_CLIENT_SECRET: \"LwgeE1ntADD20OuWC88S3P0EaO7FtO4\",\n MINIO_API_URL: \"https://minio.slm-lab.net\"\n }\n};\n\nconst guardians = missionData?.missionOriginal?.body?.guardians || missionData?.body?.guardians || missionData?.guardians || {};\nif (guardians) {\n for (const role in guardians) {\n const user = guardians[role];\n if (user) output.missionProcessed.rocketChatUsernames.push(user);\n }\n}\nconst volunteers = missionData?.missionOriginal?.body?.volunteers || missionData?.body?.volunteers || missionData?.volunteers || [];\nif (Array.isArray(volunteers)) {\n output.missionProcessed.rocketChatUsernames.push(...volunteers);\n}\noutput.missionProcessed.rocketChatUsernames = [...new Set(output.missionProcessed.rocketChatUsernames)];\n\nreturn output;"
},
"name": "Process Mission Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [60, 560],
"id": "process-mission-data"
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.GITEA_API_URL + '/user/repos' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "={{ 'Bearer ' + $node['Process Mission Data'].json.config.GITEA_API_TOKEN }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "name",
"value": "={{ $node[\"Process Mission Data\"].json.missionProcessed.name }}"
},
{
"name": "private",
"value": "={{ true }}"
},
{
"name": "auto_init",
"value": "={{ true }}"
}
]
},
"options": {}
},
"name": "Create Git Repository",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
460
],
"id": "71eea3d3-2a75-4a14-9bc6-c23ca49be59a",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.LEANTIME_API_URL + '/api/jsonrpc' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "X-API-Key",
"value": "={{ $node['Process Mission Data'].json.config.LEANTIME_API_TOKEN }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "method",
"value": "leantime.rpc.Projects.Projects.addProject"
},
{
"name": "jsonrpc",
"value": "2.0"
},
{
"name": "id",
"value": "1"
},
{
"name": "params",
"value": "={{ { \n \"values\": {\n \"name\": $node[\"Process Mission Data\"].json.missionProcessed.name,\n \"clientId\": $node[\"Process Mission Data\"].json.missionProcessed.clientId,\n \"details\": $node[\"Process Mission Data\"].json.missionProcessed.intention,\n \"type\": \"project\",\n \"start\": $node[\"Process Mission Data\"].json.missionProcessed.startDate,\n \"end\": $node[\"Process Mission Data\"].json.missionProcessed.endDate,\n \"status\": \"open\",\n \"psettings\": \"restricted\"\n }\n} }}"
}
]
},
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"name": "Create Leantime Project",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
560
],
"id": "0cb15b0b-f718-4455-8e52-6ad8ceb563eb",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.OUTLINE_API_URL + '/collections.create' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Authorization",
"value": "={{ 'Bearer ' + $node['Process Mission Data'].json.config.OUTLINE_API_TOKEN }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "name",
"value": "={{ $node[\"Process Mission Data\"].json.missionProcessed.name }}"
},
{
"name": "description",
"value": "={{ $node[\"Process Mission Data\"].json.missionProcessed.description }}"
},
{
"name": "color",
"value": "#4f46e5"
},
{
"name": "permission",
"value": "read"
},
{
"name": "private",
"value": "={{ true }}"
}
]
},
"options": {}
},
"name": "Create Documentation Collection",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
760
],
"id": "ed034321-54b1-4e59-b204-c93619561fec",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.KEYCLOAK_BASE_URL + '/realms/' + $node['Process Mission Data'].json.config.KEYCLOAK_REALM + '/protocol/openid-connect/token' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/x-www-form-urlencoded"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "grant_type",
"value": "client_credentials"
},
{
"name": "client_id",
"value": "={{ $node['Process Mission Data'].json.config.KEYCLOAK_CLIENT_ID }}"
},
{
"name": "client_secret",
"value": "={{ $node['Process Mission Data'].json.config.KEYCLOAK_CLIENT_SECRET }}"
},
{
"name": "scope",
"value": "openid profile email"
}
]
},
"options": {
"bodyContentType": "form-urlencoded",
"response": {
"response": {
"fullResponse": true
}
},
"timeout": 30000
}
},
"name": "Get Keycloak Token",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
460
],
"id": "get-keycloak-token",
"continueOnFail": false
},
{
"parameters": {
"functionCode": "const input = $input.item.json;\nconsole.log('KEYCLOAK RESPONSE:', JSON.stringify(input, null, 2));\n\n// Extract the access token from the response body\nconst access_token = input.body?.access_token;\nif (!access_token) {\n throw new Error('No access token received from Keycloak');\n}\n\nreturn {\n json: {\n access_token: access_token\n }\n};"
},
"name": "Process Token",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [
580,
460
],
"id": "process-token"
},
{
"parameters": {
"functionCode": "// Get all inputs\nconst inputs = $input.item.json;\n\n// Initialize results object\nconst results = {\n gitRepo: null,\n leantimeProject: null,\n docCollection: null,\n rocketChatChannel: null\n};\n\n// Process each input\nif (Array.isArray(inputs)) {\n // First input is gitRepo\n if (inputs[0] && typeof inputs[0] === 'object') {\n results.gitRepo = inputs[0];\n }\n \n // Second input is leantimeProject\n if (inputs[1] && typeof inputs[1] === 'object') {\n // Extract project ID from Leantime response\n const leantimeResponse = inputs[1];\n if (leantimeResponse && leantimeResponse.result) {\n results.leantimeProject = {\n id: leantimeResponse.result,\n name: leantimeResponse.name || ''\n };\n }\n }\n \n // Third input is docCollection\n if (inputs[2] && typeof inputs[2] === 'object') {\n results.docCollection = inputs[2];\n }\n \n // Fourth input is rocketChatChannel\n if (inputs[3] && typeof inputs[3] === 'object') {\n results.rocketChatChannel = inputs[3];\n }\n}\n\n// If we got a single object, it's the gitRepo\nif (!Array.isArray(inputs) && inputs && typeof inputs === 'object') {\n results.gitRepo = inputs;\n}\n\nconsole.log('Input:', JSON.stringify(inputs, null, 2));\nconsole.log('Combined results:', JSON.stringify(results, null, 2));\nreturn results;"
},
"name": "Combine Results",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [
560,
560
],
"id": "combine-results"
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.MISSION_API_URL + '/api/missions' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $node['Process Mission Data'].json.config.N8N_API_KEY }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "name",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.name }}"
},
{
"name": "niveau",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.niveau || 'default' }}"
},
{
"name": "intention",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.intention }}"
},
{
"name": "description",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.description }}"
},
{
"name": "gitRepoUrl",
"value": "={{ $node['Combine Results'].json.gitRepo?.html_url || '' }}"
},
{
"name": "leantimeProjectId",
"value": "={{ $node['Combine Results'].json.leantimeProject?.id || '' }}"
},
{
"name": "documentationCollectionId",
"value": "={{ $node['Combine Results'].json.docCollection?.id || '' }}"
},
{
"name": "rocketchatChannelId",
"value": "={{ $node['Combine Results'].json.rocketChatChannel?.channel?._id || '' }}"
},
{
"name": "donneurDOrdre",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.donneurDOrdre || 'default' }}"
},
{
"name": "projection",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.projection || 'default' }}"
},
{
"name": "missionType",
"value": "={{ $node['Process Mission Data'].json.missionProcessed.missionType || 'default' }}"
}
]
},
"options": {
"response": {
"response": {
"fullResponse": true
}
},
"timeout": 30000
}
},
"name": "Save Mission To API",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
780,
460
],
"id": "save-mission-to-api"
},
{
"parameters": {
"jsCode": "// Defensive access to all nodes\nconst missionData = $node['Process Mission Data']?.json?.missionProcessed || {};\nconst integrationResults = $node['Combine Results']?.json || {};\nconst saveMissionResult = $node['Save Mission To API']?.json || {};\n\nconst errors = [];\n\nif (saveMissionResult.error) {\n errors.push(`Failed to save mission: ${saveMissionResult.error.message || 'Unknown error'}`);\n}\nif (!integrationResults.gitRepo?.html_url) {\n errors.push('Git repository creation failed');\n}\nif (!integrationResults.leantimeProject?.id) {\n errors.push('Leantime project creation failed');\n}\nif (!integrationResults.rocketChatChannel?.channel?._id) {\n errors.push('RocketChat channel creation failed');\n}\nif (!integrationResults.docCollection?.id) {\n errors.push('Documentation collection creation failed');\n}\n\nconst output = {\n success: errors.length === 0,\n error: errors.length > 0 ? errors.join('; ') : null,\n errors: errors,\n missionData: missionData,\n integrationResults: integrationResults,\n saveMissionResult: saveMissionResult,\n message: errors.length === 0 ?\n 'Mission integration complete: All systems updated successfully' :\n `Mission integration failed: ${errors.join('; ')}`\n};\n\nconsole.log('Process Results output:', JSON.stringify(output, null, 2));\nreturn output;"
},
"name": "Process Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
860,
560
],
"id": "aedea1af-dcfc-4361-bb66-f25dcce10b98"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $node[\"Process Results\"].json }}",
"options": {}
},
"name": "Respond To Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1060,
560
],
"id": "fcecf6cc-52c3-4989-8fad-0cf6e3508601"
},
{
"parameters": {
"httpMethod": "POST",
"path": "mission-created",
"options": {
"responseData": "lastNodeJson"
}
},
"name": "Mission Created Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
-160,
560
],
"webhookId": "mission-created",
"id": "11274a5b-b20f-4180-8611-3690dc9a8722"
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.ROCKETCHAT_API_URL + '/api/v1/channels.create' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "X-Auth-Token",
"value": "={{ $node['Process Mission Data'].json.config.ROCKETCHAT_AUTH_TOKEN }}"
},
{
"name": "X-User-Id",
"value": "={{ $node['Process Mission Data'].json.config.ROCKETCHAT_USER_ID }}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "name",
"value": "={{ $node[\"Process Mission Data\"].json.missionProcessed.name }}"
},
{
"name": "members",
"value": "={{ $node[\"Process Mission Data\"].json.missionProcessed.rocketChatUsernames }}"
},
{
"name": "readOnly",
"value": "false"
}
]
},
"options": {}
},
"name": "Create RocketChat Channel",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
460,
660
],
"id": "90b1482e-6455-4c75-a7ae-297393c7aa63",
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.MINIO_API_URL + '/api/upload/logo' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "multipart/form-data" },
{ "name": "x-api-key", "value": "={{ $node['Process Mission Data'].json.config.N8N_API_KEY }}" }
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{ "name": "missionId", "value": "={{ $node['Process Mission Data'].json.missionProcessed.sanitizedName }}" },
{ "name": "file", "value": "={{ $node['Process Mission Data'].json.missionProcessed.logo }}", "type": "file" }
]
},
"options": {
"response": { "response": { "fullResponse": true } },
"bodyContentType": "multipart-form-data"
}
},
"name": "Upload Mission Logo to MinIO",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [260, 500],
"id": "upload-logo-to-minio",
"continueOnFail": false
},
{
"parameters": {
"method": "POST",
"url": "={{ $node['Process Mission Data'].json.config.MINIO_API_URL + '/api/upload/attachments' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Content-Type", "value": "multipart/form-data" },
{ "name": "x-api-key", "value": "={{ $node['Process Mission Data'].json.config.N8N_API_KEY }}" }
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{ "name": "missionId", "value": "={{ $node['Process Mission Data'].json.missionProcessed.sanitizedName }}" },
{ "name": "files", "value": "={{ $node['Process Mission Data'].json.missionProcessed.attachments }}", "type": "file" }
]
},
"options": {
"response": { "response": { "fullResponse": true } },
"bodyContentType": "multipart-form-data"
}
},
"name": "Upload Mission Attachments to MinIO",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [260, 600],
"id": "upload-attachments-to-minio",
"continueOnFail": true
}
],
"pinData": {},
"connections": {
"Mission Created Webhook": {
"main": [
[
{
"node": "Process Mission Data",
"type": "main",
"index": 0
}
]
]
},
"Process Mission Data": {
"main": [
[
{
"node": "Get Keycloak Token",
"type": "main",
"index": 0
}
]
]
},
"Get Keycloak Token": {
"main": [
[
{
"node": "Process Token",
"type": "main",
"index": 0
}
]
]
},
"Process Token": {
"main": [
[
{
"node": "Create Git Repository",
"type": "main",
"index": 0
},
{
"node": "Create Leantime Project",
"type": "main",
"index": 0
},
{
"node": "Create Documentation Collection",
"type": "main",
"index": 0
},
{
"node": "Create RocketChat Channel",
"type": "main",
"index": 0
}
]
]
},
"Create Git Repository": {
"main": [
[{ "node": "Combine Results", "type": "main", "index": 0 }]
]
},
"Create Leantime Project": {
"main": [
[{ "node": "Combine Results", "type": "main", "index": 1 }]
]
},
"Create Documentation Collection": {
"main": [
[{ "node": "Combine Results", "type": "main", "index": 2 }]
]
},
"Create RocketChat Channel": {
"main": [
[{ "node": "Combine Results", "type": "main", "index": 3 }]
]
},
"Combine Results": {
"main": [
[{ "node": "Save Mission To API", "type": "main", "index": 0 }]
]
},
"Save Mission To API": {
"main": [
[
{
"node": "Process Results",
"type": "main",
"index": 0
}
]
]
},
"Process Results": {
"main": [
[
{
"node": "Respond To Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "45981b34-113c-428c-85fd-fdc5e22afce4",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "575d8de48bd511243817deebddae0cc97d73be64c6c4737e5d4e9caddec881d8"
},
"id": "Mxg5cbQzEUgTEFNd",
"tags": []
}

View File

@ -4,7 +4,7 @@ import { authOptions } from "@/app/api/auth/options";
import { prisma } from '@/lib/prisma';
import { deleteMissionLogo } from '@/lib/mission-uploads';
import { getPublicUrl, S3_CONFIG } from '@/lib/s3';
import { IntegrationService } from '@/lib/services/integration-service';
import { N8nService } from '@/lib/services/n8n-service';
// Helper function to check authentication
async function checkAuth(request: Request) {
@ -203,7 +203,7 @@ export async function PUT(request: Request, props: { params: Promise<{ missionId
}
// Update volunteers if provided
if (volunteers) {
if (volunteers && Array.isArray(volunteers)) {
// Get current volunteers
const currentVolunteers = await (prisma as any).missionUser.findMany({
where: {
@ -254,32 +254,36 @@ export async function PUT(request: Request, props: { params: Promise<{ missionId
}
// DELETE endpoint to remove a mission
export async function DELETE(request: Request, props: { params: Promise<{ missionId: string }> }) {
const params = await props.params;
export async function DELETE(
request: Request,
{ params }: { params: { missionId: string } }
) {
try {
const { authorized, userId } = await checkAuth(request);
if (!authorized || !userId) {
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 });
}
// Check if mission exists and user is the creator
const mission = await (prisma as any).mission.findFirst({
where: {
id: missionId,
creatorId: userId // Only creator can delete a mission
},
const mission = await prisma.mission.findUnique({
where: { id: params.missionId },
include: {
attachments: true
missionUsers: {
include: {
user: true
}
}
}
});
if (!mission) {
return NextResponse.json({ error: 'Mission not found or not authorized to delete' }, { status: 404 });
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 isAdmin = session.user.role === 'ADMIN';
if (!isCreator && !isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Delete logo if exists
@ -292,47 +296,26 @@ export async function DELETE(request: Request, props: { params: Promise<{ missio
}
}
// Clean up external integrations before deleting the mission
// Trigger n8n workflow for rollback
const n8nService = new N8nService();
try {
// Extract integration IDs from the mission
const leantimeProjectId = mission.leantimeProjectId ? parseInt(mission.leantimeProjectId, 10) : undefined;
const outlineCollectionId = mission.outlineCollectionId;
const rocketChatChannelId = mission.rocketChatChannelId;
const giteaRepositoryUrl = mission.giteaRepositoryUrl;
// Only attempt rollback if there are any integrations to clean up
if (leantimeProjectId || outlineCollectionId || rocketChatChannelId || giteaRepositoryUrl) {
console.log(`Rolling back integrations for mission ${missionId}...`);
const integrationService = new IntegrationService();
// Use the public rollbackIntegrations method
await integrationService.rollbackIntegrations(
leantimeProjectId,
outlineCollectionId,
rocketChatChannelId,
giteaRepositoryUrl
);
console.log(`Successfully rolled back integrations for mission ${missionId}`);
} else {
console.log(`No integrations to clean up for mission ${missionId}`);
}
} catch (integrationError) {
console.error('Error cleaning up integrations:', integrationError);
// Continue with mission deletion even if integration cleanup fails
// The admin will need to clean up manually in this case
await n8nService.rollbackMission(mission);
} catch (error) {
console.error('Error during mission rollback:', error);
// Continue with mission deletion even if rollback fails
}
// Delete mission (cascade will handle missionUsers and attachments)
await (prisma as any).mission.delete({
where: { id: missionId }
// Delete the mission
await prisma.mission.delete({
where: { id: params.missionId }
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting mission:', error);
return NextResponse.json({
error: 'Internal server error',
details: error instanceof Error ? error.message : String(error)
}, { status: 500 });
return NextResponse.json(
{ error: 'Failed to delete mission' },
{ status: 500 }
);
}
}

View File

@ -4,7 +4,6 @@ import { authOptions } from "@/app/api/auth/options";
import { prisma } from '@/lib/prisma';
import { getPublicUrl } from '@/lib/s3';
import { S3_CONFIG } from '@/lib/s3';
import { IntegrationService } from '@/lib/services/integration-service';
import { N8nService } from '@/lib/services/n8n-service';
// Helper function to check authentication
@ -239,7 +238,8 @@ export async function POST(request: Request) {
console.log('About to trigger n8n workflow with data:', {
missionId: mission.id,
name: mission.name,
creatorId: auth.userId
creatorId: auth.userId,
fullData: body
});
const n8nService = new N8nService();
@ -251,12 +251,12 @@ export async function POST(request: Request) {
console.log('Received workflow result:', workflowResult);
// Update mission with integration results, even if some failed
// Update mission with integration results
if (workflowResult.results) {
console.log('Processing workflow results:', workflowResult.results);
const updateData: any = {};
// Only update fields that were successfully created
// Update fields based on workflow results
if (workflowResult.results.gitRepo?.html_url) {
updateData.giteaRepositoryUrl = workflowResult.results.gitRepo.html_url;
}
@ -272,13 +272,11 @@ export async function POST(request: Request) {
console.log('Updating mission with integration data:', updateData);
// Only update if we have any successful integrations
if (Object.keys(updateData).length > 0) {
await prisma.mission.update({
where: { id: mission.id },
data: updateData
});
}
// Update mission with integration data
await prisma.mission.update({
where: { id: mission.id },
data: updateData
});
}
return NextResponse.json({

View File

@ -1,329 +0,0 @@
import axios from 'axios';
export class GiteaService {
private apiUrl: string;
private apiToken: string;
private owner: string;
constructor() {
this.apiUrl = process.env.GITEA_API_URL || '';
this.apiToken = process.env.GITEA_API_TOKEN || '';
this.owner = process.env.GITEA_OWNER || '';
console.log('GiteaService initialized with URL:', this.apiUrl);
}
/**
* Get the properly formatted API endpoint
*/
private getApiEndpoint(path: string): string {
const baseUrl = this.apiUrl.endsWith('/')
? this.apiUrl.slice(0, -1)
: this.apiUrl;
const pathWithoutLeadingSlash = path.startsWith('/')
? path.slice(1)
: path;
return `${baseUrl}/api/v1/${pathWithoutLeadingSlash}`;
}
/**
* Create a new repository for a mission
* @param mission The mission data
* @returns Repository info or throws error
*/
async createRepository(mission: any): Promise<any> {
try {
// Create a repo name from the mission name
// Replace spaces with dashes and remove special characters
const repoName = mission.name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
console.log(`Creating repository: ${repoName}`);
// Determine if we're creating under a user or an organization
const endpoint = this.owner.startsWith('@')
? this.getApiEndpoint(`org/${this.owner.substring(1)}/repos`)
: this.getApiEndpoint('user/repos');
// Create the repository with minimal parameters
const response = await axios.post(
endpoint,
{
name: repoName,
private: true,
auto_init: true
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${this.apiToken}`
}
}
);
console.log(`Repository created: ${response.data.full_name}`);
// If the mission has users, add them as collaborators
if (mission.missionUsers && mission.missionUsers.length > 0) {
await this.addCollaborators(response.data.owner.login, repoName, mission.missionUsers);
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
console.error('Gitea API Error:', {
status: error.response.status,
data: error.response.data
});
}
console.error('Error creating Gitea repository:', error);
throw new Error(`Gitea integration failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Add collaborators to a repository
* @param owner Repository owner
* @param repo Repository name
* @param missionUsers The mission users to add as collaborators
*/
async addCollaborators(owner: string, repo: string, missionUsers: any[]): Promise<void> {
console.log(`Adding ${missionUsers.length} collaborators to ${owner}/${repo}`);
// First try to look up users if needed
const usersWithInfo = await this.enrichUsersWithGiteaInfo(missionUsers);
const addCollaboratorPromises = usersWithInfo.map(async (userInfo) => {
try {
// Skip if no username is found
if (!userInfo.giteaUsername) {
console.log(`No Gitea username for user ${userInfo.missionUser.user.email}, skipping`);
return;
}
// Determine permission level based on role
// For Gitea: 'read', 'write', 'admin'
let permission = 'write';
if (userInfo.missionUser.role === 'gardien-temps') {
permission = 'admin';
} else if (userInfo.missionUser.role === 'observateur') {
permission = 'read';
}
console.log(`Adding ${userInfo.giteaUsername} as ${permission}`);
await axios.put(
this.getApiEndpoint(`repos/${owner}/${repo}/collaborators/${userInfo.giteaUsername}`),
{ permission },
{
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${this.apiToken}`
}
}
);
console.log(`Added ${userInfo.giteaUsername} as collaborator`);
} catch (error) {
console.error(`Error adding collaborator ${userInfo.missionUser.user.email}:`, error);
// Continue with other collaborators even if one fails
}
});
// Wait for all collaborator additions to complete
await Promise.all(addCollaboratorPromises);
console.log('Finished adding collaborators');
}
/**
* Try to enrich users with Gitea username information
* @param missionUsers The mission users
* @returns Users with additional Gitea username info if available
*/
private async enrichUsersWithGiteaInfo(missionUsers: any[]): Promise<Array<{
missionUser: any,
giteaUsername: string | null
}>> {
// For each user, try to find their Gitea username
return await Promise.all(missionUsers.map(async (missionUser) => {
let giteaUsername = null;
// Try to search for the user by email directly
try {
if (!missionUser.user.email) {
console.log('User has no email address, skipping');
return { missionUser, giteaUsername };
}
// Look up user by email in Gitea
const searchResponse = await axios.get(
this.getApiEndpoint('users/search'),
{
params: { q: missionUser.user.email },
headers: {
'Authorization': `token ${this.apiToken}`
}
}
);
if (searchResponse.data &&
searchResponse.data.data &&
searchResponse.data.data.length > 0) {
// Look for exact email match first
const exactMatch = searchResponse.data.data.find((user: any) =>
user.email === missionUser.user.email
);
if (exactMatch) {
giteaUsername = exactMatch.username;
console.log(`Found exact match Gitea username "${giteaUsername}" for ${missionUser.user.email}`);
} else {
// If no exact match, use the first result
giteaUsername = searchResponse.data.data[0].username;
console.log(`Found closest match Gitea username "${giteaUsername}" for ${missionUser.user.email}`);
}
} else {
console.log(`No Gitea user found for email ${missionUser.user.email}`);
}
} catch (error) {
console.log(`Error searching for Gitea user with email ${missionUser.user.email}:`, error);
}
return { missionUser, giteaUsername };
}));
}
/**
* Delete a repository
* @param owner Repository owner
* @param repo Repository name
* @returns True if successful, false otherwise
*/
async deleteRepository(owner: string, repo: string): Promise<boolean> {
try {
await axios.delete(
this.getApiEndpoint(`repos/${owner}/${repo}`),
{
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${this.apiToken}`
}
}
);
console.log(`Repository ${owner}/${repo} deleted successfully`);
return true;
} catch (error) {
console.error(`Error deleting repository ${owner}/${repo}:`, error);
return false;
}
}
/**
* Create a new branch in a repository
* @param owner Repository owner
* @param repo Repository name
* @param branchName New branch name
* @param baseBranchName Base branch name (default: 'main')
* @returns True if successful, false otherwise
*/
async createBranch(
owner: string,
repo: string,
branchName: string,
baseBranchName: string = 'main'
): Promise<boolean> {
try {
// First, get the SHA of the base branch
const refResponse = await axios.get(
this.getApiEndpoint(`repos/${owner}/${repo}/git/refs/heads/${baseBranchName}`),
{
headers: {
'Authorization': `token ${this.apiToken}`
}
}
);
const sha = refResponse.data.object.sha;
// Create the new branch
await axios.post(
this.getApiEndpoint(`repos/${owner}/${repo}/git/refs`),
{
ref: `refs/heads/${branchName}`,
sha: sha
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${this.apiToken}`
}
}
);
console.log(`Branch ${branchName} created in ${owner}/${repo}`);
return true;
} catch (error) {
console.error(`Error creating branch ${branchName} in ${owner}/${repo}:`, error);
return false;
}
}
/**
* Create a file in a repository
* @param owner Repository owner
* @param repo Repository name
* @param path File path
* @param content File content (will be base64 encoded)
* @param message Commit message
* @param branch Branch name (default: 'main')
* @returns True if successful, false otherwise
*/
async createFile(
owner: string,
repo: string,
path: string,
content: string,
message: string,
branch: string = 'main'
): Promise<boolean> {
try {
// Convert content to base64
const contentBase64 = Buffer.from(content).toString('base64');
await axios.post(
this.getApiEndpoint(`repos/${owner}/${repo}/contents/${path}`),
{
message,
content: contentBase64,
branch
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `token ${this.apiToken}`
}
}
);
console.log(`File ${path} created in ${owner}/${repo}`);
return true;
} catch (error) {
console.error(`Error creating file ${path} in ${owner}/${repo}:`, error);
return false;
}
}
/**
* Get repository URL
* @param repoName Repository name
* @returns Full repository URL
*/
getRepositoryUrl(repoName: string): string {
const baseUrl = this.apiUrl.replace('/api/v1', '');
return `${baseUrl}/${this.owner}/${repoName}`;
}
}

View File

@ -1,349 +0,0 @@
import { prisma } from '@/lib/prisma';
import { LeantimeService } from './leantime-service';
import { OutlineService } from './outline-service';
import { RocketChatService } from './rocketchat-service';
import { GiteaService } from './gitea-service';
import axios from 'axios';
interface IntegrationResult {
success: boolean;
error?: string;
data?: {
leantimeProjectId?: number;
outlineCollectionId?: string;
rocketChatChannelId?: string;
giteaRepositoryUrl?: string;
penpotProjectId?: string;
};
}
export class IntegrationService {
private leantimeService: LeantimeService;
private outlineService: OutlineService;
private rocketChatService: RocketChatService;
private giteaService: GiteaService;
constructor() {
this.leantimeService = new LeantimeService();
this.outlineService = new OutlineService();
this.rocketChatService = new RocketChatService();
this.giteaService = new GiteaService();
}
/**
* Set up all integrations for a mission
* @param missionId The mission ID
* @returns Integration result
*/
async setupIntegrationsForMission(missionId: string): Promise<IntegrationResult> {
try {
// Get complete mission data with users
const mission = await prisma.mission.findUnique({
where: { id: missionId },
include: {
missionUsers: {
include: {
user: true
}
},
attachments: true
}
});
if (!mission) {
throw new Error(`Mission not found: ${missionId}`);
}
// These fields will store the IDs of created resources
let leantimeProjectId: number | undefined;
let outlineCollectionId: string | undefined;
let rocketChatChannelId: string | undefined;
let giteaRepositoryUrl: string | undefined;
// Track which integrations succeeded and which failed
const integrationStatus = {
leantime: { success: false, id: undefined as number | undefined, error: undefined as string | undefined },
outline: { success: false, id: undefined as string | undefined, error: undefined as string | undefined },
rocketchat: { success: false, id: undefined as string | undefined, error: undefined as string | undefined },
gitea: { success: false, url: undefined as string | undefined, error: undefined as string | undefined }
};
// A flag to determine if we should consider this a success or failure overall
let criticalFailure = false;
try {
// Step 1: Create Leantime project (Consider this a critical integration)
try {
console.log('Starting Leantime project creation...');
leantimeProjectId = await this.leantimeService.createProject(mission);
console.log(`Leantime project created with ID: ${leantimeProjectId}`);
integrationStatus.leantime.success = true;
integrationStatus.leantime.id = leantimeProjectId;
} catch (leantimeError) {
console.error('Error creating Leantime project:', leantimeError);
integrationStatus.leantime.success = false;
integrationStatus.leantime.error = leantimeError instanceof Error ? leantimeError.message : String(leantimeError);
criticalFailure = true;
throw leantimeError; // Leantime is critical, so we rethrow
}
// Add a delay to avoid rate limits (extended to 3 seconds)
console.log('Waiting 3 seconds before proceeding to Outline integration...');
await new Promise(resolve => setTimeout(resolve, 3000));
// Step 2: Create Outline collection (Consider this non-critical)
try {
console.log('Starting Outline collection creation...');
outlineCollectionId = await this.outlineService.createCollection(mission);
console.log(`Outline collection created with ID: ${outlineCollectionId}`);
integrationStatus.outline.success = true;
integrationStatus.outline.id = outlineCollectionId;
} catch (outlineError) {
console.error('Error creating Outline collection:', outlineError);
integrationStatus.outline.success = false;
integrationStatus.outline.error = outlineError instanceof Error ? outlineError.message : String(outlineError);
// Check if it's an authentication error (401)
if (axios.isAxiosError(outlineError) && outlineError.response?.status === 401) {
console.log('⚠️ Outline authentication error. Please check your API credentials.');
} else if (axios.isAxiosError(outlineError) && outlineError.response?.status === 429) {
console.log('⚠️ Outline rate limiting error (429). The integration will be skipped for now.');
}
// Don't set criticalFailure - Outline is non-critical
}
// Add a delay to avoid rate limits (extended to 3 seconds)
console.log('Waiting 3 seconds before proceeding to Rocket.Chat integration...');
await new Promise(resolve => setTimeout(resolve, 3000));
// Step 3: Create Rocket.Chat channel (Consider this non-critical)
try {
console.log('Starting Rocket.Chat channel creation...');
rocketChatChannelId = await this.rocketChatService.createChannel(mission);
console.log(`Rocket.Chat channel created with ID: ${rocketChatChannelId}`);
integrationStatus.rocketchat.success = true;
integrationStatus.rocketchat.id = rocketChatChannelId;
} catch (rocketChatError) {
console.error('Error creating Rocket.Chat channel:', rocketChatError);
integrationStatus.rocketchat.success = false;
integrationStatus.rocketchat.error = rocketChatError instanceof Error ? rocketChatError.message : String(rocketChatError);
// Check for rate limiting
if (axios.isAxiosError(rocketChatError) && rocketChatError.response?.status === 429) {
console.log('⚠️ Rocket.Chat rate limiting error (429). The integration will be skipped for now.');
}
// Don't set criticalFailure - Rocket.Chat is non-critical
}
// Add a delay to avoid rate limits (extended to 3 seconds)
console.log('Waiting 3 seconds before proceeding to Gitea integration...');
await new Promise(resolve => setTimeout(resolve, 3000));
// Step 4: Create Gitea repository (Consider this non-critical)
// Only create if Gite or Calcul services are selected
if (mission.services &&
(mission.services.includes('Gite') || mission.services.includes('Calcul'))) {
try {
console.log('Starting Gitea repository creation...');
const repoData = await this.giteaService.createRepository(mission);
if (repoData && repoData.html_url) {
giteaRepositoryUrl = repoData.html_url;
} else {
giteaRepositoryUrl = this.giteaService.getRepositoryUrl(
mission.name
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
);
}
console.log(`Gitea repository created at: ${giteaRepositoryUrl}`);
integrationStatus.gitea.success = true;
integrationStatus.gitea.url = giteaRepositoryUrl;
} catch (giteaError) {
console.error('Error creating Gitea repository:', giteaError);
integrationStatus.gitea.success = false;
integrationStatus.gitea.error = giteaError instanceof Error ? giteaError.message : String(giteaError);
// Don't set criticalFailure - Gitea is non-critical
}
} else {
console.log('Skipping Gitea repository creation - neither Gite nor Calcul services selected');
integrationStatus.gitea.success = false;
integrationStatus.gitea.error = 'Skipped - service not selected';
}
// Update the mission with the integration IDs (only for successful integrations)
await prisma.mission.update({
where: { id: missionId },
data: {
leantimeProjectId: integrationStatus.leantime.success ? leantimeProjectId?.toString() : undefined,
outlineCollectionId: integrationStatus.outline.success ? outlineCollectionId : undefined,
rocketChatChannelId: integrationStatus.rocketchat.success ? rocketChatChannelId : undefined,
giteaRepositoryUrl: integrationStatus.gitea.success ? giteaRepositoryUrl : undefined
}
});
// Output a summary of integration results
console.log('Integration results:', JSON.stringify(integrationStatus, null, 2));
// If we get here without a critical failure, we consider it a success
// even if some non-critical integrations failed
return {
success: !criticalFailure,
data: {
leantimeProjectId,
outlineCollectionId,
rocketChatChannelId,
giteaRepositoryUrl
}
};
} catch (error) {
console.error('Error setting up integrations:', error);
// Rollback any created resources
await this.rollbackIntegrations(leantimeProjectId, outlineCollectionId, rocketChatChannelId, giteaRepositoryUrl);
// Delete the mission itself since a critical integration failed
try {
await prisma.mission.delete({
where: { id: missionId }
});
console.log(`Mission ${missionId} deleted due to critical integration failure.`);
} catch (deleteError) {
console.error('Error deleting mission after integration failure:', deleteError);
}
return {
success: false,
error: `Integration failed: ${error instanceof Error ? error.message : String(error)}`
};
}
} catch (error) {
console.error('Error in integration setup:', error);
return {
success: false,
error: `Integration setup failed: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Rollback integrations if any step fails
* @param leantimeProjectId The Leantime project ID to delete
* @param outlineCollectionId The Outline collection ID to delete
* @param rocketChatChannelId The Rocket.Chat channel ID to delete
* @param giteaRepositoryUrl The Gitea repository URL to extract owner/repo from for deletion
*/
public async rollbackIntegrations(
leantimeProjectId?: number,
outlineCollectionId?: string,
rocketChatChannelId?: string,
giteaRepositoryUrl?: string
): Promise<void> {
console.log('⚠️ Rolling back integrations due to an error...');
// Track what we've successfully rolled back
const rollbackStatuses = {
leantime: false,
outline: false,
rocketchat: false,
gitea: false
};
try {
// Attempt to delete Leantime project
if (leantimeProjectId) {
try {
console.log(`Attempting to delete Leantime project: ${leantimeProjectId}`);
const leantimeSuccess = await this.leantimeService.deleteProject(leantimeProjectId);
rollbackStatuses.leantime = leantimeSuccess;
console.log(`Leantime project deletion ${leantimeSuccess ? 'successful' : 'failed'}: ${leantimeProjectId}`);
// Add a longer delay to avoid rate limiting (3 seconds)
console.log('Waiting 3 seconds before next rollback operation...');
await new Promise(resolve => setTimeout(resolve, 3000));
} catch (leantimeError) {
console.error('Error during Leantime rollback:', leantimeError);
console.log(`⚠️ Note: Leantime project ${leantimeProjectId} may need to be deleted manually`);
}
}
// Attempt to delete Outline collection
if (outlineCollectionId) {
try {
console.log(`Attempting to delete Outline collection: ${outlineCollectionId}`);
const outlineSuccess = await this.outlineService.deleteCollection(outlineCollectionId);
rollbackStatuses.outline = outlineSuccess;
console.log(`Outline collection deletion ${outlineSuccess ? 'successful' : 'failed'}: ${outlineCollectionId}`);
// Add a longer delay to avoid rate limiting (3 seconds)
console.log('Waiting 3 seconds before next rollback operation...');
await new Promise(resolve => setTimeout(resolve, 3000));
} catch (outlineError) {
console.error('Error during Outline rollback:', outlineError);
console.log(`⚠️ Note: Outline collection ${outlineCollectionId} may need to be deleted manually`);
}
}
// Attempt to delete Rocket.Chat channel
if (rocketChatChannelId) {
try {
console.log(`Attempting to delete Rocket.Chat channel: ${rocketChatChannelId}`);
const rocketChatSuccess = await this.rocketChatService.deleteChannel(rocketChatChannelId);
rollbackStatuses.rocketchat = rocketChatSuccess;
console.log(`Rocket.Chat channel deletion ${rocketChatSuccess ? 'successful' : 'failed'}: ${rocketChatChannelId}`);
// Add a longer delay to avoid rate limiting (3 seconds)
console.log('Waiting 3 seconds before next rollback operation...');
await new Promise(resolve => setTimeout(resolve, 3000));
} catch (rocketChatError) {
console.error('Error during Rocket.Chat rollback:', rocketChatError);
console.log(`⚠️ Note: Rocket.Chat channel ${rocketChatChannelId} may need to be deleted manually`);
}
}
// Attempt to delete Gitea repository
if (giteaRepositoryUrl) {
try {
// Extract owner and repo from URL
const urlParts = giteaRepositoryUrl.split('/');
// Make sure we have enough parts in the URL before accessing array elements
if (urlParts.length >= 2) {
const owner = urlParts[urlParts.length - 2];
const repo = urlParts[urlParts.length - 1];
console.log(`Attempting to delete Gitea repository: ${owner}/${repo}`);
const giteaSuccess = await this.giteaService.deleteRepository(owner, repo);
rollbackStatuses.gitea = giteaSuccess;
console.log(`Gitea repository deletion ${giteaSuccess ? 'successful' : 'failed'}: ${owner}/${repo}`);
} else {
console.log(`Invalid Gitea repository URL format: ${giteaRepositoryUrl}`);
}
} catch (giteaError) {
console.error('Error during Gitea rollback:', giteaError);
console.log(`⚠️ Note: Gitea repository at ${giteaRepositoryUrl} may need to be deleted manually`);
}
} else {
console.log('No Gitea repository was created, skipping rollback');
// Mark as successful since there's nothing to delete
rollbackStatuses.gitea = true;
}
// Provide a summary of rollback operations
console.log('Rollback summary:', JSON.stringify(rollbackStatuses));
// If any rollbacks failed, provide a note
if (!rollbackStatuses.leantime || !rollbackStatuses.outline || !rollbackStatuses.rocketchat || !rollbackStatuses.gitea) {
console.log('⚠️ Some resources may need to be deleted manually.');
}
} catch (error) {
console.error('Error during rollback:', error);
console.log('⚠️ Resources may need to be deleted manually:');
if (leantimeProjectId) console.log(`- Leantime project: ${leantimeProjectId}`);
if (outlineCollectionId) console.log(`- Outline collection: ${outlineCollectionId}`);
if (rocketChatChannelId) console.log(`- Rocket.Chat channel: ${rocketChatChannelId}`);
if (giteaRepositoryUrl) console.log(`- Gitea repository: ${giteaRepositoryUrl}`);
}
}
}

View File

@ -1,824 +0,0 @@
import axios from 'axios';
export class LeantimeService {
private apiUrl: string;
private apiToken: string;
private apiUserId: string | null;
constructor() {
this.apiUrl = process.env.LEANTIME_API_URL || '';
this.apiToken = process.env.LEANTIME_TOKEN || '';
this.apiUserId = null; // Will be fetched when needed
console.log('LeantimeService initialized with URL:', this.apiUrl);
}
/**
* Get the properly formatted API endpoint
* @returns The formatted API endpoint
*/
private getApiEndpoint(): string {
return this.apiUrl.endsWith('/api/jsonrpc')
? this.apiUrl
: this.apiUrl.endsWith('/')
? `${this.apiUrl}api/jsonrpc`
: `${this.apiUrl}/api/jsonrpc`;
}
/**
* Create a new project in Leantime
* @param mission The mission data
* @returns Project ID or throws error
*/
async createProject(mission: any): Promise<number> {
try {
// Determine client ID based on mission type
const clientId = mission.niveau.toLowerCase() === 'a' ?
await this.getClientIdByName('Enkun') :
await this.getClientIdByName('ONG');
if (!clientId) {
throw new Error(`Leantime client not found for mission type ${mission.niveau}`);
}
// Generate dates for the project (today and one year from now)
const today = new Date();
const endDate = new Date();
endDate.setFullYear(today.getFullYear() + 1);
const formattedStartDate = today.toISOString().split('T')[0];
const formattedEndDate = endDate.toISOString().split('T')[0];
console.log(`Creating project with dates: start=${formattedStartDate}, end=${formattedEndDate}`);
// Create project values object
const projectData = {
name: mission.name,
clientId: clientId,
details: mission.intention || '',
type: 'project',
start: formattedStartDate,
end: formattedEndDate,
status: 'open',
psettings: 'restricted'
};
console.log('Creating project with data:', JSON.stringify(projectData, null, 2));
// Use only the method that we know works
const payload = {
method: 'leantime.rpc.Projects.Projects.addProject',
jsonrpc: '2.0',
id: 1,
params: {
values: projectData
}
};
console.log('Project creation payload:', JSON.stringify(payload, null, 2));
const response = await axios.post(
this.getApiEndpoint(),
payload,
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
console.log('Project creation response status:', response.status);
console.log('Project creation response data:', JSON.stringify(response.data, null, 2));
if (!response.data || !response.data.result) {
throw new Error(`Failed to create Leantime project: ${JSON.stringify(response.data)}`);
}
const projectId = response.data.result[0]; // We need the first element in the array
console.log(`Created Leantime project with ID: ${projectId}`);
// If the mission has a logo, set it as project avatar
if (mission.logo) {
await this.setProjectAvatar(projectId, mission.logo);
}
// Check if mission has users to assign
if (mission.missionUsers && mission.missionUsers.length > 0) {
await this.assignUsersToProject(projectId, mission.missionUsers);
} else {
// No users to assign
const projectUrl = this.getProjectUrl(projectId);
console.log(` No users to assign. Project created at: ${projectUrl}`);
}
return projectId;
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
console.error('Axios Error Details:', {
status: error.response.status,
data: error.response.data,
headers: error.response.headers
});
}
console.error('Error creating Leantime project:', error);
throw new Error(`Leantime integration failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Set project avatar using mission logo
* @param projectId The Leantime project ID
* @param logoPath The mission logo URL
*/
async setProjectAvatar(projectId: number, logoPath: string): Promise<void> {
try {
// Get the logo file from the storage
const logoResponse = await fetch(`/api/missions/image/${logoPath}`);
if (!logoResponse.ok) {
throw new Error(`Failed to fetch logo file: ${logoResponse.statusText}`);
}
const formData = new FormData();
const logoBlob = await logoResponse.blob();
// Add the file to form data
formData.append('file', logoBlob, 'logo.png');
formData.append('project', JSON.stringify({ id: projectId }));
// Get base URL by removing any API path
const baseUrl = this.apiUrl
.replace('/api/jsonrpc', '')
.replace('/api/jsonrpc.php', '');
// Construct proper avatar endpoint
const avatarEndpoint = baseUrl.endsWith('/')
? `${baseUrl}api/v1/projects.setProjectAvatar`
: `${baseUrl}/api/v1/projects.setProjectAvatar`;
console.log('Using avatar endpoint:', avatarEndpoint);
// Upload the avatar
const response = await axios.post(
avatarEndpoint,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
'X-API-Key': this.apiToken
}
}
);
if (!response.data || !response.data.success) {
throw new Error(`Failed to set project avatar: ${JSON.stringify(response.data)}`);
}
console.log(`Set avatar for Leantime project ${projectId}`);
} catch (error) {
console.error('Error setting project avatar:', error);
// Don't fail the entire process if avatar upload fails
}
}
/**
* Get the API user's ID for use in project assignments
* This is useful when all other methods of user assignment fail
*/
private async getApiUserId(): Promise<string | null> {
if (this.apiUserId) {
return this.apiUserId;
}
try {
const response = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Auth.getCurrentUser',
jsonrpc: '2.0',
id: 1
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (response.data && response.data.result && response.data.result.id) {
this.apiUserId = response.data.result.id;
console.log(`Found API user ID: ${this.apiUserId}`);
return this.apiUserId;
}
return null;
} catch (error) {
console.error('Error getting API user ID:', error);
return null;
}
}
/**
* Assign mission users to the Leantime project
* @param projectId The Leantime project ID
* @param missionUsers The mission users with roles
*/
async assignUsersToProject(projectId: number, missionUsers: any[]): Promise<void> {
const projectUrl = this.getProjectUrl(projectId);
console.log(`⚠️ For best results, please assign users manually at: ${projectUrl}`);
console.log(' Automatic user assignment is currently disabled due to API limitations.');
// No automatic user assignment - this was causing too many issues
return;
}
/**
* Verify if a user is assigned to a project
* @param userId The Leantime user ID
* @param projectId The Leantime project ID
* @returns True if the user is assigned to the project, false otherwise
*/
private async verifyUserAssignedToProject(userId: string, projectId: number): Promise<boolean> {
try {
console.log(`Verifying if user ${userId} is assigned to project ${projectId}...`);
const response = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Projects.Projects.getProjectIdAssignedToUser',
jsonrpc: '2.0',
id: 1,
params: {
userId: userId
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
// Check if the response contains project IDs
if (response.data && response.data.result && Array.isArray(response.data.result)) {
// Convert project IDs to strings for comparison (API might return strings or numbers)
const assignedProjects = response.data.result.map((id: number | string) => String(id));
const projectIdStr = String(projectId);
// Check if the user is assigned to our project
const isAssigned = assignedProjects.includes(projectIdStr);
console.log(`User ${userId} ${isAssigned ? 'is' : 'is not'} assigned to project ${projectId}`);
return isAssigned;
}
console.log(`User ${userId} is not assigned to any projects`);
return false;
} catch (error) {
console.error(`Error verifying user assignment for user ${userId}:`, error);
return false;
}
}
/**
* Assign a user to a project with the specified role
* @param projectId The Leantime project ID
* @param userId The Leantime user ID
* @param role The role to assign
* @returns true if assignment succeeded, false otherwise
*/
async assignUserToProject(projectId: number, userId: string, role: string): Promise<boolean> {
try {
console.log(`Assigning user ${userId} to project ${projectId} with role ${role}`);
// Wait before making the API call to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
// Use only the method that we know works best
const response = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Projects.addProjectUser',
jsonrpc: '2.0',
id: 1,
params: {
projectId: projectId,
userId: userId,
role: role
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (response.data && response.data.result) {
console.log(`✅ Assigned user ${userId} to project ${projectId} with role ${role}`);
return true;
}
console.warn(`⚠️ Could not assign user ${userId} to project ${projectId}.`);
return false;
} catch (error) {
console.error(`Error assigning user ${userId} to project ${projectId}:`, error);
console.warn(`⚠️ User assignment failed. The project ${projectId} was created successfully, but users will need to be added manually.`);
return false;
}
}
/**
* Special method to assign the API user to a project
* This uses different approaches to try to ensure success
* @returns true if assignment succeeded, false otherwise
*/
private async assignApiUserToProject(projectId: number, apiUserId: string): Promise<boolean> {
console.log(`Assigning API user ${apiUserId} to project ${projectId} as admin`);
// First, check if the API user is already assigned
const alreadyAssigned = await this.verifyUserAssignedToProject(apiUserId, projectId);
if (alreadyAssigned) {
console.log(`✅ API user ${apiUserId} is already assigned to project ${projectId}`);
return true;
}
// Wait before making the API call to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 2000));
// Use only the method that we know works best
try {
const response = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Projects.addProjectUser',
jsonrpc: '2.0',
id: 1,
params: {
projectId: projectId,
userId: apiUserId,
role: 'admin' // API user gets admin to ensure full control
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (response.data && response.data.result) {
console.log(`✅ API user successfully assigned to project ${projectId}`);
return true;
}
} catch (error) {
console.error('Error assigning API user to project:', error);
}
console.warn(`⚠️ Could not assign API user to project ${projectId}. The project may appear with "New API Access" as the only team member.`);
return false;
}
/**
* Get a client ID by name
* @param clientName The client name to search for
* @returns The client ID or null if not found
*/
async getClientIdByName(clientName: string): Promise<number | null> {
try {
// Log the API URL to debug
console.log('Leantime API URL:', this.apiUrl);
const apiEndpoint = this.getApiEndpoint();
console.log('Using endpoint:', apiEndpoint);
const response = await axios.post(
apiEndpoint,
{
method: 'leantime.rpc.Clients.Clients.getAll',
jsonrpc: '2.0',
id: 1,
params: {}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (!response.data || !response.data.result) {
throw new Error(`Failed to get clients: ${JSON.stringify(response.data)}`);
}
const clients = response.data.result;
const client = clients.find((c: any) =>
c.name.toLowerCase() === clientName.toLowerCase()
);
if (client) {
return parseInt(client.id);
} else {
console.log(`Client "${clientName}" not found. Creating it...`);
// Add delay before creating client to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1000));
return await this.createClient(clientName);
}
} catch (error) {
console.error('Error getting client by name:', error);
// Check if this is a rate limiting error
if (axios.isAxiosError(error) && error.response?.status === 429) {
console.log('Rate limiting detected (429). Waiting before retry...');
// Wait 2 seconds before next API call to respect rate limits
await new Promise(resolve => setTimeout(resolve, 2000));
}
// Try to create the client if we couldn't find it
try {
console.log(`Attempting to create client "${clientName}" after error...`);
return await this.createClient(clientName);
} catch (createError) {
console.error('Error creating client:', createError);
return null;
}
}
}
/**
* Create a new client in Leantime
* @param clientName The name of the client to create
* @returns The ID of the created client or null if failed
*/
async createClient(clientName: string): Promise<number | null> {
try {
// Wait before making the API call to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1000));
// Use only the method that works consistently
const response = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Clients.Clients.addClient',
jsonrpc: '2.0',
id: 1,
params: {
values: {
name: clientName,
street: '',
zip: '',
city: '',
state: '',
country: '',
phone: '',
internet: '',
email: ''
}
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (!response.data || !response.data.result) {
throw new Error(`Failed to create client: ${JSON.stringify(response.data)}`);
}
const clientId = parseInt(response.data.result);
console.log(`Created client "${clientName}" with ID: ${clientId}`);
return clientId;
} catch (error) {
// Check if this is a rate limiting error
if (axios.isAxiosError(error) && error.response?.status === 429) {
console.log('Rate limiting detected (429). Waiting and retrying...');
// Wait 3 seconds and then retry
await new Promise(resolve => setTimeout(resolve, 3000));
try {
// Retry with the same method after waiting
const retryResponse = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Clients.Clients.addClient',
jsonrpc: '2.0',
id: 1,
params: {
values: {
name: clientName,
street: '',
zip: '',
city: '',
state: '',
country: '',
phone: '',
internet: '',
email: ''
}
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (retryResponse.data && retryResponse.data.result) {
const retryClientId = parseInt(retryResponse.data.result);
console.log(`Created client "${clientName}" with ID: ${retryClientId} (retry)`);
return retryClientId;
}
} catch (retryError) {
console.error('Error on retry:', retryError);
}
}
console.error(`Error creating client "${clientName}":`, error);
return null;
}
}
/**
* Get a user ID by email
* @param email The user email to search for
* @returns The user ID or null if not found
*/
async getUserByEmail(email: string): Promise<string | null> {
try {
console.log(`Looking up user with email: ${email}`);
// Try getUserByEmail first (direct lookup method)
try {
const directResponse = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Users.Users.getUserByEmail',
jsonrpc: '2.0',
id: 1,
params: {
email: email
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (directResponse.data &&
directResponse.data.result &&
directResponse.data.result !== false &&
directResponse.data.result.id) {
console.log(`Found user by direct email lookup: ${directResponse.data.result.id}`);
return directResponse.data.result.id;
}
} catch (directError) {
console.log('Direct email lookup failed, trying alternate method...');
// Check if the error is rate limiting (429)
if (axios.isAxiosError(directError) && directError.response?.status === 429) {
console.log('Rate limiting detected (429). Waiting before retry...');
// Wait 1 second before next API call to respect rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Fall back to get all users and filter
try {
const response = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Users.Users.getAll',
jsonrpc: '2.0',
id: 1
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
if (!response.data || !response.data.result) {
throw new Error(`Failed to get users: ${JSON.stringify(response.data)}`);
}
const users = response.data.result;
// First try exact email match
let user = users.find((u: any) => u.email === email || u.username === email);
// If that fails, try case-insensitive match
if (!user) {
const lowerEmail = email.toLowerCase();
user = users.find((u: any) =>
(u.email && u.email.toLowerCase() === lowerEmail) ||
(u.username && u.username.toLowerCase() === lowerEmail)
);
}
if (user) {
console.log(`Found user with email ${email}: ID ${user.id}`);
return user.id;
}
} catch (fetchError) {
// Check if this is a rate limiting error
if (axios.isAxiosError(fetchError) && fetchError.response?.status === 429) {
console.log('Rate limiting detected (429). Consider user assignment through manual UI.');
} else {
console.error('Error fetching users:', fetchError);
}
}
// If user is still not found or error occurred
console.log(`No user found with email ${email}`);
return null;
} catch (error) {
console.error('Error getting user by email:', error);
return null;
}
}
/**
* Delete a project from Leantime
* @param projectId The Leantime project ID to delete
* @returns True if successful, false otherwise
*/
async deleteProject(projectId: number): Promise<boolean> {
try {
console.log(`Attempting to delete Leantime project ${projectId} using REST API approach`);
// Get base URL by removing any API path
const baseUrl = this.apiUrl
.replace('/api/jsonrpc', '')
.replace('/api/jsonrpc.php', '');
// Construct REST API URL for project deletion
// Attempt different patterns that might work:
// 1. First, try the standard REST API approach
const restApiUrl = baseUrl.endsWith('/')
? `${baseUrl}api/v1/projects/${projectId}`
: `${baseUrl}/api/v1/projects/${projectId}`;
console.log(`Trying REST API deletion via DELETE to: ${restApiUrl}`);
try {
const restResponse = await axios.delete(
restApiUrl,
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
console.log(`REST API Delete response:`, JSON.stringify(restResponse.data, null, 2));
if (restResponse.status >= 200 && restResponse.status < 300) {
console.log(`Successfully deleted project ${projectId} via REST API`);
return true;
}
} catch (restError) {
console.error('REST API error:', restError);
if (axios.isAxiosError(restError)) {
console.error('REST API error details:', {
status: restError.response?.status,
data: restError.response?.data,
message: restError.message
});
}
}
// 2. Try the web interface approach (simulate a form submission)
console.log(`Trying web interface simulation for project deletion...`);
// Assuming a standard web interface form submission
const formUrl = baseUrl.endsWith('/')
? `${baseUrl}projects/deleteProject/${projectId}`
: `${baseUrl}/projects/deleteProject/${projectId}`;
console.log(`Simulating form submission to: ${formUrl}`);
try {
// Some systems require a POST to the delete endpoint
const formResponse = await axios.post(
formUrl,
{}, // Empty payload or { confirm: true } depending on the system
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
console.log(`Form submission response:`, JSON.stringify(formResponse.data, null, 2));
if (formResponse.status >= 200 && formResponse.status < 300) {
console.log(`Successfully deleted project ${projectId} via web interface simulation`);
return true;
}
} catch (formError) {
console.error('Form submission error:', formError);
if (axios.isAxiosError(formError)) {
console.error('Form submission error details:', {
status: formError.response?.status,
data: formError.response?.data,
message: formError.message
});
}
}
// 3. Last resort: Try to mark the project as archived/inactive if deletion isn't supported
console.log(`Attempting to mark project as archived/inactive as a fallback...`);
try {
// Use the JSON-RPC API to update the project status
const updateResponse = await axios.post(
this.getApiEndpoint(),
{
method: 'leantime.rpc.Projects.Projects.updateProject',
jsonrpc: '2.0',
id: 1,
params: {
id: projectId,
values: {
status: 'archived' // Try 'archived', 'deleted', or 'inactive'
}
}
},
{
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiToken
}
}
);
console.log(`Project status update response:`, JSON.stringify(updateResponse.data, null, 2));
if (updateResponse.data && updateResponse.data.result) {
console.log(`Successfully marked project ${projectId} as archived`);
return true;
}
} catch (updateError) {
console.error('Project status update error:', updateError);
if (axios.isAxiosError(updateError)) {
console.error('Update error details:', {
status: updateError.response?.status,
data: updateError.response?.data,
message: updateError.message
});
}
}
// If we get here, all methods failed
console.error(`Failed to delete or archive Leantime project ${projectId} with all methods.`);
console.log(`Please manually delete/archive project ${projectId} from Leantime.`);
// Get the project URL for manual deletion
const projectUrl = this.getProjectUrl(projectId);
console.log(`Project URL for manual cleanup: ${projectUrl}`);
return false;
} catch (error) {
// Log detailed error information
console.error(`Error in deleteProject for Leantime project ${projectId}:`, error);
if (axios.isAxiosError(error)) {
console.error('Error details:', {
status: error.response?.status,
data: error.response?.data,
message: error.message
});
}
return false;
}
}
/**
* Helper method to get the project URL
*/
private getProjectUrl(projectId: number): string {
const baseUrl = this.apiUrl
.replace('/api/jsonrpc', '')
.replace('/api/jsonrpc.php', '');
return baseUrl.endsWith('/')
? `${baseUrl}projects/showProject/${projectId}`
: `${baseUrl}/projects/showProject/${projectId}`;
}
}

View File

@ -2,10 +2,15 @@ import axios from 'axios';
export class N8nService {
private webhookUrl: string;
private rollbackWebhookUrl: string;
constructor() {
this.webhookUrl = process.env.N8N_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook-test/mission-created';
console.log('N8nService initialized with webhook URL:', this.webhookUrl);
this.rollbackWebhookUrl = process.env.N8N_ROLLBACK_WEBHOOK_URL || 'https://brain.slm-lab.net/webhook-test/mission-rollback';
console.log('N8nService initialized with webhook URLs:', {
create: this.webhookUrl,
rollback: this.rollbackWebhookUrl
});
}
/**
@ -70,4 +75,67 @@ export class N8nService {
throw new Error(`Failed to trigger mission creation workflow: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Trigger the mission rollback workflow in n8n
* @param mission The mission to rollback
* @returns The workflow execution result
*/
async rollbackMission(mission: any): Promise<any> {
try {
console.log('Triggering n8n workflow for mission rollback:', {
id: mission.id,
name: mission.name,
webhookUrl: this.rollbackWebhookUrl,
fullData: JSON.stringify(mission, null, 2)
});
const response = await axios.post(this.rollbackWebhookUrl, mission, {
headers: {
'Content-Type': 'application/json'
},
timeout: 30000 // 30 second timeout
});
console.log('n8n rollback workflow response:', {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
});
// Handle string response
if (typeof response.data === 'string') {
console.log('Received string response from n8n rollback, treating as success');
return {
success: true,
results: {
message: response.data
}
};
}
if (response.data.errors && response.data.errors.length > 0) {
console.warn('Rollback workflow completed with partial success:', response.data.errors);
return response.data;
}
if (!response.data.success) {
console.error('Rollback workflow execution failed:', {
message: response.data.message,
data: response.data
});
throw new Error(`Rollback workflow execution failed: ${response.data.message || 'Unknown error'}`);
}
return response.data;
} catch (error) {
console.error('Error triggering n8n rollback workflow:', {
error: error instanceof Error ? error.message : String(error),
webhookUrl: this.rollbackWebhookUrl,
errorDetails: error instanceof Error ? error.stack : undefined
});
throw new Error(`Failed to trigger mission rollback workflow: ${error instanceof Error ? error.message : String(error)}`);
}
}
}

View File

@ -1,143 +0,0 @@
import axios from 'axios';
export class OutlineService {
private apiUrl: string;
private apiToken: string;
constructor() {
this.apiUrl = process.env.OUTLINE_API_URL || 'https://chapitre.slm-lab.net/api';
this.apiToken = process.env.OUTLINE_API_KEY || '';
console.log('OutlineService initialized with URL:', this.apiUrl);
console.log('Creating Outline collection with token length:', this.apiToken.length);
}
async createCollection(mission: any): Promise<string> {
if (!this.apiToken) {
throw new Error('Outline API token is not configured');
}
console.log('Mission data received:', JSON.stringify({
id: mission.id,
label: mission.label,
name: mission.name,
description: mission.description
}, null, 2));
// Determine the best name to use for the collection
// Prioritize mission.name, then mission.label, then default
const collectionName = mission.name || mission.label || `Mission ${mission.id}`;
console.log(`Using collection name: "${collectionName}"`);
try {
// Create a collection in Outline based on the mission
// Note: According to error message, valid permission values are 'read', 'read_write', 'admin'
// We'll use private: true to make it private
const payload = {
name: collectionName,
description: mission.description || 'Mission documentation',
color: '#4f46e5', // Indigo color as default
permission: "read", // Use read-only permission
private: true, // Make it private
sharing: false // Disable sharing
};
console.log('Sending to Outline API:', JSON.stringify(payload, null, 2));
const response = await axios.post(
`${this.apiUrl}/collections.create`,
payload,
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiToken}`
}
}
);
console.log('Outline API response:', JSON.stringify(response.data, null, 2));
// Create a default document in the collection
if (response.data && (response.data.data?.id || response.data.id)) {
const collectionId = response.data.data?.id || response.data.id;
try {
// Try to create a welcome document in the collection
await this.createWelcomeDocument(collectionId, collectionName);
} catch (docError) {
console.warn('Failed to create welcome document, but collection was created:', docError);
}
return collectionId;
} else {
throw new Error('Failed to get collection ID from Outline API response');
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Full error response:', JSON.stringify(error.response?.data, null, 2));
if (error.response?.status === 401) {
console.error('Outline authentication error:', error.response.data);
throw new Error('Outline API authentication failed - check your token');
} else if (error.response?.status === 429) {
console.error('Outline rate limit error:', error.response.data);
throw new Error('Outline API rate limit exceeded - try again later');
} else {
console.error('Outline API error:', error.response?.data || error.message);
throw new Error(`Outline API error: ${error.response?.status} - ${error.message}`);
}
}
throw error;
}
}
// Helper method to create a welcome document in a collection
private async createWelcomeDocument(collectionId: string, collectionName: string): Promise<void> {
try {
const response = await axios.post(
`${this.apiUrl}/documents.create`,
{
title: `Welcome to ${collectionName}`,
text: `# Welcome to ${collectionName}\n\nThis is your new private collection in Outline. Here you can store and share documents related to this mission.\n\n## Getting Started\n\n- Use the + button to create new documents\n- Organize documents with headers and lists\n- Share this collection only with team members involved in this mission`,
collectionId: collectionId,
publish: true
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiToken}`
}
}
);
console.log('Created welcome document with ID:', response.data?.data?.id || response.data?.id);
} catch (error) {
console.error('Failed to create welcome document:', error);
}
}
async deleteCollection(collectionId: string): Promise<boolean> {
if (!this.apiToken) {
throw new Error('Outline API token is not configured');
}
try {
// Delete the collection in Outline
await axios.post(
`${this.apiUrl}/collections.delete`,
{
id: collectionId
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiToken}`
}
}
);
return true;
} catch (error) {
console.error('Error deleting Outline collection:', error);
return false;
}
}
}

View File

@ -1,221 +0,0 @@
import axios from 'axios';
export class RocketChatService {
private apiUrl: string;
private authToken: string;
private userId: string;
constructor() {
// Extract the base URL from the iframe URL
const baseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0] || '';
this.apiUrl = baseUrl;
this.authToken = process.env.ROCKET_CHAT_TOKEN || '';
this.userId = process.env.ROCKET_CHAT_USER_ID || '';
}
/**
* Create a new channel in Rocket.Chat
* @param mission The mission data
* @returns Channel ID or throws error
*/
async createChannel(mission: any): Promise<string> {
try {
// First, get all mission users that need to be added to the channel
const missionUserEmails = mission.missionUsers.map((mu: any) => mu.user.email);
const rocketChatUsernames: string[] = [];
for (const email of missionUserEmails) {
const user = await this.getUserByEmail(email);
if (user) {
rocketChatUsernames.push(user.username);
} else {
console.warn(`User not found in Rocket.Chat: ${email}`);
}
}
// Sanitize the channel name to comply with Rocket.Chat restrictions
const channelName = this.sanitizeChannelName(mission.name);
// Create the channel
const response = await axios.post(
`${this.apiUrl}/api/v1/channels.create`,
{
name: channelName,
members: rocketChatUsernames,
readOnly: false
},
{
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': this.authToken,
'X-User-Id': this.userId
}
}
);
if (!response.data || !response.data.success) {
throw new Error(`Failed to create Rocket.Chat channel: ${JSON.stringify(response.data)}`);
}
const channelId = response.data.channel._id;
console.log(`Created Rocket.Chat channel with ID: ${channelId}`);
// Make "Gardien de la Parole" users channel admins
await this.setChannelAdmins(channelId, mission.missionUsers);
return channelId;
} catch (error) {
console.error('Error creating Rocket.Chat channel:', error);
throw new Error(`Rocket.Chat integration failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Make "Gardien de la Parole" users channel admins
* @param channelId The Rocket.Chat channel ID
* @param missionUsers The mission users with roles
*/
async setChannelAdmins(channelId: string, missionUsers: any[]): Promise<void> {
try {
// Find "Gardien de la Parole" users
const gardienParoleUsers = missionUsers.filter((mu: any) => mu.role === 'gardien-parole');
for (const gardienUser of gardienParoleUsers) {
const user = await this.getUserByEmail(gardienUser.user.email);
if (user) {
await this.makeUserChannelOwner(channelId, user._id);
}
}
} catch (error) {
console.error('Error setting channel admins:', error);
// Don't fail if setting admins fails
}
}
/**
* Make a user a channel owner
* @param channelId The Rocket.Chat channel ID
* @param userId The Rocket.Chat user ID
*/
async makeUserChannelOwner(channelId: string, userId: string): Promise<void> {
try {
const response = await axios.post(
`${this.apiUrl}/api/v1/channels.addOwner`,
{
roomId: channelId,
userId: userId
},
{
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': this.authToken,
'X-User-Id': this.userId
}
}
);
if (!response.data || !response.data.success) {
throw new Error(`Failed to make user channel owner: ${JSON.stringify(response.data)}`);
}
console.log(`Made user ${userId} an owner of channel ${channelId}`);
} catch (error) {
console.error(`Error making user ${userId} channel owner:`, error);
// Don't fail if individual user assignment fails
}
}
/**
* Get a user by email
* @param email The user email to search for
* @returns The user object or null if not found
*/
async getUserByEmail(email: string): Promise<any | null> {
try {
// First try exact email match
const response = await axios.get(
`${this.apiUrl}/api/v1/users.list`,
{
headers: {
'X-Auth-Token': this.authToken,
'X-User-Id': this.userId
}
}
);
if (!response.data || !response.data.success) {
throw new Error(`Failed to get users: ${JSON.stringify(response.data)}`);
}
// First check if user exists by email
const userByEmail = response.data.users.find((user: any) =>
user.emails?.some((e: any) => e.address === email)
);
if (userByEmail) {
return userByEmail;
}
// If not found by email, try by username (username might be the part before @)
const username = email.split('@')[0];
const userByUsername = response.data.users.find((user: any) => user.username === username);
return userByUsername || null;
} catch (error) {
console.error('Error getting user by email:', error);
return null;
}
}
/**
* Sanitize a channel name to comply with Rocket.Chat restrictions
* @param name The original name
* @returns The sanitized name
*/
private sanitizeChannelName(name: string): string {
// Replace spaces and invalid characters with hyphens
// Channel names must match regex [0-9a-zA-Z-_.]+
let sanitized = name.replace(/[^0-9a-zA-Z-_.]/g, '-');
// Ensure no sequential hyphens
sanitized = sanitized.replace(/-+/g, '-');
// Trim hyphens from beginning and end
sanitized = sanitized.replace(/^-+|-+$/g, '');
// Ensure name is not empty after sanitization
if (!sanitized) {
sanitized = 'mission-channel';
}
return sanitized.toLowerCase();
}
/**
* Delete a channel from Rocket.Chat
* @param channelId The Rocket.Chat channel ID to delete
* @returns True if successful, false otherwise
*/
async deleteChannel(channelId: string): Promise<boolean> {
try {
const response = await axios.post(
`${this.apiUrl}/api/v1/channels.delete`,
{
roomId: channelId
},
{
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': this.authToken,
'X-User-Id': this.userId
}
}
);
return response.data && response.data.success === true;
} catch (error) {
console.error(`Error deleting Rocket.Chat channel ${channelId}:`, error);
return false;
}
}
}