Détection des conflits de jours fériers mondiaux et reprogrammation des réunions

Avancé

Ceci est unPersonal Productivityworkflow d'automatisation du domainecontenant 23 nœuds.Utilise principalement des nœuds comme If, Set, Code, Slack, HttpRequest. Détection des conflits de jours fériés et suggestion de nouvelles dates de réunion avec Google Calendar et Slack

Prérequis
  • Token Bot Slack ou URL Webhook
  • Peut nécessiter les informations d'identification d'authentification de l'API cible
Aperçu du workflow
Visualisation des connexions entre les nœuds, avec support du zoom et du déplacement
Exporter le workflow
Copiez la configuration JSON suivante dans n8n pour importer et utiliser ce workflow
{
  "id": "K67zdPMDUtledQFV",
  "meta": {
    "instanceId": "189dde98270e9ce0f006f0e9deb96aa5e627396fc6279cac8902c9b06936984d"
  },
  "name": "Global Holiday Conflict Detector and Meeting Rescheduler",
  "tags": [],
  "nodes": [
    {
      "id": "b487208f-33b3-4527-8e18-bb3c2a8746d7",
      "name": "Vérification Quotidienne",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -752,
        224
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 9
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a8f9264a-5dba-4801-9c1e-e12a59237b8b",
      "name": "Configuration du Workflow",
      "type": "n8n-nodes-base.set",
      "position": [
        -528,
        224
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "id-1",
              "name": "currentYear",
              "type": "number",
              "value": "={{ new Date().getFullYear() }}"
            },
            {
              "id": "id-2",
              "name": "nextWeekStart",
              "type": "string",
              "value": "={{ new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] }}"
            },
            {
              "id": "id-3",
              "name": "nextWeekEnd",
              "type": "string",
              "value": "={{ new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] }}"
            },
            {
              "id": "id-4",
              "name": "slackChannel",
              "type": "string",
              "value": "C09FB9QQQTX"
            },
            {
              "id": "id-5",
              "name": "calendarId",
              "type": "string",
              "value": "c_91f92ee12632d48cc78642add679d75aa8aecb09abea89eadbc97cb17d2de336@group.calendar.google.com"
            },
            {
              "id": "b5660f95-e978-4a1a-9c33-225dfa4e9552",
              "name": "countryCodes",
              "type": "array",
              "value": "[\"US\", \"GB\", \"DE\", \"IN\", \"CN\", \"KR\", \"HK\"]"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "6472f288-c7e2-49da-bd32-61b91d9528b3",
      "name": "Fusionner et Filtrer les Jours Fériés de la Semaine Prochaine",
      "type": "n8n-nodes-base.code",
      "position": [
        144,
        224
      ],
      "parameters": {
        "jsCode": "// Get the next week date range from Workflow Configuration\nconst nextWeekStart = new Date($('Workflow Configuration').first().json.nextWeekStart);\nconst nextWeekEnd = new Date($('Workflow Configuration').first().json.nextWeekEnd);\n\n// Combine all holiday data from the loop\nconst allHolidays = [];\n\n// Process all input items (which are the results from each loop iteration)\nfor (const item of $input.all()) {\n  // The HTTP Request node in the loop outputs the holiday array in item.json\n  if (Array.isArray(item.json)) {\n    for (const holiday of item.json) {\n      allHolidays.push(holiday);\n    }\n  }\n}\n\n// Filter holidays that fall within next week range\nconst nextWeekHolidays = allHolidays.filter(holiday => {\n  const holidayDate = new Date(holiday.date);\n  return holidayDate >= nextWeekStart && holidayDate <= nextWeekEnd;\n});\n\n// Map to desired output format\nconst formattedHolidays = nextWeekHolidays.map(holiday => ({\n  date: holiday.date,\n  name: holiday.name || holiday.localName,\n  country: holiday.countryCode === 'US' ? 'United States' : \n           holiday.countryCode === 'GB' ? 'United Kingdom' : \n           holiday.countryCode === 'DE' ? 'Germany' : \n           holiday.countryCode === 'IN' ? 'India' : \n           holiday.countryCode === 'CN' ? 'China' : \n           holiday.countryCode === 'KR' ? 'South Korea' : \n           holiday.countryCode === 'HK' ? 'Hong Kong' : holiday.countryCode,\n  countryCode: holiday.countryCode\n}));\n\n// Return all filtered holidays as a single item\nreturn [{\n  json: {\n    holidays: formattedHolidays\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "524535d0-6d38-4da8-a5c9-e775f4ff4e50",
      "name": "Obtenir les Événements du Calendrier de la Semaine Prochaine",
      "type": "n8n-nodes-base.googleCalendar",
      "position": [
        -304,
        272
      ],
      "parameters": {
        "options": {},
        "timeMax": "={{ $('Workflow Configuration').first().json.nextWeekEnd }}T23:59:59Z",
        "timeMin": "={{ $('Workflow Configuration').first().json.nextWeekStart }}T00:00:00Z",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Workflow Configuration').first().json.calendarId }}"
        },
        "operation": "getAll",
        "returnAll": true
      },
      "typeVersion": 1.3
    },
    {
      "id": "5ed5e1c2-726a-43bd-8f49-ed40f3eac9fa",
      "name": "Détecter les Conflits avec les Jours Fériés",
      "type": "n8n-nodes-base.code",
      "position": [
        480,
        224
      ],
      "parameters": {
        "jsCode": "// Get the single item containing the list of all holidays from the first input\nconst holidayList = $input.all(0)[0].json.holidays || [];\n\n// Get all items containing calendar events from the second input\nconst calendarEventItems = $input.all(1);\n\n// Create a map of holiday dates for quick lookup\nconst holidayMap = new Map();\nholidayList.forEach(holiday => {\n  const date = holiday.date;\n  if (!holidayMap.has(date)) {\n    holidayMap.set(date, []);\n  }\n  holidayMap.get(date).push({\n    name: holiday.name,\n    country: holiday.countryCode || holiday.country\n  });\n});\n\n// Detect conflicts by iterating through each calendar event item\nconst conflicts = [];\ncalendarEventItems.forEach(eventItem => {\n  const event = eventItem.json;\n  const eventStart = event.start?.dateTime || event.start?.date;\n  if (!eventStart) return; // Skip if no start time\n  \n  const eventDate = eventStart.split('T')[0];\n  \n  // Check if this date has any holidays\n  if (holidayMap.has(eventDate)) {\n    const holidaysOnDate = holidayMap.get(eventDate);\n    const eventTime = eventStart.includes('T') ? eventStart.split('T')[1].substring(0, 5) : 'All day';\n    const attendees = event.attendees ? event.attendees.map(a => a.email) : [];\n    \n    conflicts.push({\n      eventName: event.summary || 'Untitled Event',\n      eventDate: eventDate,\n      eventTime: eventTime,\n      holidayName: holidaysOnDate.map(h => h.name).join(', '),\n      affectedCountries: [...new Set(holidaysOnDate.map(h => h.country))].join(', '),\n      attendees: attendees,\n      eventId: event.id\n    });\n  }\n});\n\n// Return all found conflicts in a SINGLE item to ensure subsequent nodes run only once\nreturn [{\n  json: {\n    conflicts: conflicts,\n    totalConflicts: conflicts.length,\n    checkDate: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "32c956cf-781f-4548-96a9-9d758a7f22a3",
      "name": "Vérifier si des Conflits sont Trouvés",
      "type": "n8n-nodes-base.if",
      "position": [
        752,
        224
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "id-1",
              "operator": {
                "type": "array",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $('Detect Holiday Conflicts').item.json.conflicts }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "fd84ad56-55c4-49c4-9e60-368977208c0c",
      "name": "Générer des Suggestions de Réorganisation",
      "type": "n8n-nodes-base.code",
      "position": [
        1072,
        224
      ],
      "parameters": {
        "jsCode": "const conflicts = $input.first().json.conflicts || [];\n\n// Get the holiday list from the 'Merge and Filter' node which ran before the conflict detection\nconst holidays = $('Merge and Filter Next Week Holidays').first().json.holidays.map(h => h.date);\n\nfunction isHoliday(dateStr) {\n  return holidays.includes(dateStr);\n}\n\nfunction isWeekend(date) {\n  const day = date.getDay();\n  return day === 0 || day === 6; // Sunday or Saturday\n}\n\nfunction findNextAvailableDate(startDate) {\n  let currentDate = new Date(startDate);\n  currentDate.setDate(currentDate.getDate() + 1);\n  \n  for (let i = 0; i < 30; i++) {\n    const dateStr = currentDate.toISOString().split('T')[0];\n    if (!isWeekend(currentDate) && !isHoliday(dateStr)) {\n      const originalTime = startDate.toTimeString().split(' ')[0].substring(0, 5);\n      return { date: dateStr, time: originalTime };\n    }\n    currentDate.setDate(currentDate.getDate() + 1);\n  }\n  \n  return {\n    date: currentDate.toISOString().split('T')[0],\n    time: startDate.toTimeString().split(' ')[0].substring(0, 5)\n  };\n}\n\nconst enhancedConflicts = conflicts.map(conflict => {\n  const eventDateTime = conflict.eventTime === 'All day' ? new Date(conflict.eventDate) : new Date(`${conflict.eventDate}T${conflict.eventTime}`);\n  const suggestion = findNextAvailableDate(eventDateTime);\n  \n  return {\n    ...conflict,\n    suggestedDate: suggestion.date,\n    suggestedTime: suggestion.time,\n    originalDate: conflict.eventDate,\n    originalTime: conflict.eventTime\n  };\n});\n\nreturn [{\n  json: {\n    conflicts: enhancedConflicts,\n    totalConflicts: enhancedConflicts.length\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4bb37c98-7400-4e4c-9519-4cafa5555067",
      "name": "Formater le Digest Slack",
      "type": "n8n-nodes-base.code",
      "position": [
        1376,
        224
      ],
      "parameters": {
        "jsCode": "// Get the conflicts data from the previous node\nconst conflictsData = $input.first().json;\nconst conflicts = conflictsData.conflicts || [];\n\n// Use a single backslash \\n for newlines\nlet slackMessage = ':warning: *Holiday Conflict Alert* :warning:\\n\\n';\n\nif (conflicts.length === 0) {\n  slackMessage += 'No conflicts detected for next week. All clear! :white_check_mark:';\n} else {\n  slackMessage += `Found *${conflicts.length}* meeting(s) scheduled during public holidays next week:\\n\\n`;\n  \n  conflicts.forEach((conflict, index) => {\n    slackMessage += `*${index + 1}. ${conflict.eventName}*\\n`;\n    slackMessage += `:calendar: *Date:* ${conflict.originalDate}\\n`;\n    slackMessage += `:clock3: *Time:* ${conflict.originalTime}\\n`;\n    slackMessage += `:earth_americas: *Affected Countries:* ${conflict.affectedCountries}\\n`;\n    slackMessage += `:pushpin: *Holiday:* ${conflict.holidayName}\\n`;\n    \n    // Add suggestion if it exists\n    if (conflict.suggestedDate) {\n      slackMessage += `:bulb: *Suggestion:* Reschedule to ${conflict.suggestedDate} at ${conflict.suggestedTime}\\n`;\n    }\n    \n    slackMessage += '\\n---\\n\\n';\n  });\n  \n  slackMessage += ':point_right: Please review and reschedule these meetings to accommodate team members in affected regions.';\n}\n\n// Return the formatted message\nreturn [{\n  json: {\n    slackMessage: slackMessage\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "d01a7818-dc28-4490-ad5b-e5963cd22cbd",
      "name": "Publier le Digest Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1680,
        224
      ],
      "webhookId": "5337846c-6b31-45a2-80ac-6960f6350ab1",
      "parameters": {
        "text": "={{ $json.slackMessage }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Workflow Configuration').first().json.slackChannel }}"
        },
        "otherOptions": {},
        "authentication": "oAuth2"
      },
      "typeVersion": 2.3
    },
    {
      "id": "d04fe2ce-a6f3-49d5-a384-39a3a201fa37",
      "name": "Boucler sur les Éléments",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -304,
        80
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "74320878-4302-440c-99ce-5aefefcb1ca2",
      "name": "Récupérer les Jours Fériés Publics",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -80,
        32
      ],
      "parameters": {
        "url": "=https://date.nager.at/api/v3/PublicHolidays/{{ $('Workflow Configuration').first().json.currentYear }}/{{ $json.countryCodes }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "392bf796-1880-4a89-ad6b-7838a7a19351",
      "name": "Note : Vérification Quotidienne",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -816,
        64
      ],
      "parameters": {
        "color": "white",
        "height": 144,
        "content": "**Purpose:** Triggers the workflow once every weekday morning.\n**Key:** Runs at 09:00 server time (adjust in node).\n**Tip:** Change to weekly if you only need Monday runs."
      },
      "typeVersion": 1
    },
    {
      "id": "0541544f-2e8e-41a7-b899-76de44829cca",
      "name": "Note : Configuration du Workflow",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -544,
        -208
      ],
      "parameters": {
        "color": "white",
        "width": 256,
        "height": 208,
        "content": "**Purpose:** Central place to define variables.\n**Fields:** `currentYear`, `nextWeekStart`, `nextWeekEnd`, `countryCodes`, `slackChannel`, `calendarId`.\n**Tip:** Edit country list and calendar/channel IDs here only."
      },
      "typeVersion": 1
    },
    {
      "id": "02931af5-a167-422c-ae32-53412ed0f4f4",
      "name": "Note : Fusionner et Filtrer les Jours Fériés de la Semaine Prochaine",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        80,
        48
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Merges API results and filters to next week only.\n**Output:** `holidays[]` with `date`, `name`, `countryCode`."
      },
      "typeVersion": 1
    },
    {
      "id": "79ac359f-12a3-43ba-bf75-b987f37f482e",
      "name": "Note : Obtenir les Événements du Calendrier de la Semaine Prochaine",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -368,
        480
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Reads all events in next week’s window from Google Calendar.\n**Config:** Uses `calendarId` and `timeMin/Max` from the Set node.\n**Note:** Re-connect your own Google credential in n8n."
      },
      "typeVersion": 1
    },
    {
      "id": "165237d2-da5e-408f-bb46-77be9bf4c38c",
      "name": "Note : Détecter les Conflits avec les Jours Fériés",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        384,
        48
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Compares event dates with holiday dates to find conflicts.\n**Output:** Single item `{ conflicts[], totalConflicts }` so downstream runs once."
      },
      "typeVersion": 1
    },
    {
      "id": "3b55ac06-aa21-44de-a6e0-37051e02cfa1",
      "name": "Note : Vérifier si des Conflits sont Trouvés",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        688,
        48
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Guards the branch; continues only when conflicts exist.\n**Condition:** Array not empty."
      },
      "typeVersion": 1
    },
    {
      "id": "fc01dc46-773b-4881-b646-2bcf21c7bc1f",
      "name": "Note : Générer des Suggestions de Réorganisation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        992,
        48
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Suggests next business day that is not a holiday/weekend.\n**Logic:** Looks ahead up to 30 days, preserving original start time."
      },
      "typeVersion": 1
    },
    {
      "id": "cec6277f-583d-4ba9-8063-2e956395bff5",
      "name": "Note : Formater le Digest Slack",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        48
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Creates a readable Slack message.\n**Includes:** Event, date/time, affected countries, holiday, suggestion."
      },
      "typeVersion": 1
    },
    {
      "id": "6d766a0d-bea6-4bbb-a824-127b53b9dc45",
      "name": "Note : Publier le Digest Slack",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1600,
        48
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Posts the digest to Slack.\n**Config:** Uses `slackChannel` from the Set node.\n**Note:** Re-connect your own Slack OAuth in n8n (left unconfigured)."
      },
      "typeVersion": 1
    },
    {
      "id": "8461d7cc-e39d-4c86-9c6f-96a7aa55ba4f",
      "name": "Note : Boucler sur les Éléments",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -208,
        -160
      ],
      "parameters": {
        "color": "white",
        "content": "**Purpose:** Iterates through each country code.\n**Flow:** Splits the array to call the holiday API per country, then loops back."
      },
      "typeVersion": 1
    },
    {
      "id": "f857f854-7ed9-43c1-af05-7e596d8369dc",
      "name": "Note : Récupérer les Jours Fériés Publics",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        96,
        -192
      ],
      "parameters": {
        "color": "white",
        "width": 288,
        "height": 192,
        "content": "**Purpose:** Calls Nager.Date API for public holidays.\n**URL:** `https://date.nager.at/api/v3/PublicHolidays/{year}/{country}`.\n**Security:** No API key needed. Keep credentials empty."
      },
      "typeVersion": 1
    },
    {
      "id": "48d8def6-1e2e-4e03-b963-a44f9e2c0de1",
      "name": "Aperçu du Modèle",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1632,
        -448
      ],
      "parameters": {
        "color": "yellow",
        "width": 688,
        "height": 752,
        "content": "## What this template does\nDetects global public-holiday conflicts in **next week’s** meetings and posts a Slack digest with suggested reschedule dates.\n\n## Who it’s for\nRemote and distributed teams that want to avoid scheduling meetings on regional holidays.\n\n## How it works\n1) Fetch public holidays for selected country codes.  \n2) Pull next week’s events from Google Calendar.  \n3) Detect date overlaps and generate a reschedule suggestion (next weekday that isn’t a holiday).  \n4) Post a single Slack summary message.\n\n## How to set up\n- Configure **countryCodes**, **calendarId**, and **slackChannel** in the **Workflow Configuration** (Set) node.  \n- Connect your own Google Calendar and Slack credentials in n8n (left as unauthenticated by design).\n\n## Requirements\n- n8n (self-hosted or Cloud)  \n- Slack app with chat:write and channel access  \n- Google Calendar with read access\n\n## Customize\n- Change the time window by editing `nextWeekStart/End` in **Workflow Configuration**.  \n- Adjust the suggestion logic in **Generate Reschedule Suggestions**."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "c01575e0-ea13-45a5-bb0a-c771b4107645",
  "connections": {
    "b487208f-33b3-4527-8e18-bb3c2a8746d7": {
      "main": [
        [
          {
            "node": "a8f9264a-5dba-4801-9c1e-e12a59237b8b",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "d04fe2ce-a6f3-49d5-a384-39a3a201fa37": {
      "main": [
        [
          {
            "node": "6472f288-c7e2-49da-bd32-61b91d9528b3",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "74320878-4302-440c-99ce-5aefefcb1ca2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4bb37c98-7400-4e4c-9519-4cafa5555067": {
      "main": [
        [
          {
            "node": "d01a7818-dc28-4490-ad5b-e5963cd22cbd",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "74320878-4302-440c-99ce-5aefefcb1ca2": {
      "main": [
        [
          {
            "node": "d04fe2ce-a6f3-49d5-a384-39a3a201fa37",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "a8f9264a-5dba-4801-9c1e-e12a59237b8b": {
      "main": [
        [
          {
            "node": "d04fe2ce-a6f3-49d5-a384-39a3a201fa37",
            "type": "main",
            "index": 0
          },
          {
            "node": "524535d0-6d38-4da8-a5c9-e775f4ff4e50",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "32c956cf-781f-4548-96a9-9d758a7f22a3": {
      "main": [
        [
          {
            "node": "fd84ad56-55c4-49c4-9e60-368977208c0c",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5ed5e1c2-726a-43bd-8f49-ed40f3eac9fa": {
      "main": [
        [
          {
            "node": "32c956cf-781f-4548-96a9-9d758a7f22a3",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "fd84ad56-55c4-49c4-9e60-368977208c0c": {
      "main": [
        [
          {
            "node": "4bb37c98-7400-4e4c-9519-4cafa5555067",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "6472f288-c7e2-49da-bd32-61b91d9528b3": {
      "main": [
        [
          {
            "node": "5ed5e1c2-726a-43bd-8f49-ed40f3eac9fa",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}
Foire aux questions

Comment utiliser ce workflow ?

Copiez le code de configuration JSON ci-dessus, créez un nouveau workflow dans votre instance n8n et sélectionnez "Importer depuis le JSON", collez la configuration et modifiez les paramètres d'authentification selon vos besoins.

Dans quelles scénarios ce workflow est-il adapté ?

Avancé - Productivité personnelle

Est-ce payant ?

Ce workflow est entièrement gratuit et peut être utilisé directement. Veuillez noter que les services tiers utilisés dans le workflow (comme l'API OpenAI) peuvent nécessiter un paiement de votre part.

Informations sur le workflow
Niveau de difficulté
Avancé
Nombre de nœuds23
Catégorie1
Types de nœuds9
Description de la difficulté

Adapté aux utilisateurs avancés, avec des workflows complexes contenant 16+ nœuds

Liens externes
Voir sur n8n.io

Partager ce workflow

Catégories

Catégories: 34