n8n int cleaning
This commit is contained in:
parent
1c4166944f
commit
45728a8e0c
611
Missionspre.json
Normal file
611
Missionspre.json
Normal 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": []
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user