├── .gitattributes ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── host.json ├── readme-images ├── set-fa-emails.PNG ├── send-error-email.PNG ├── set-pilot-emails.PNG ├── flight-admin-list.PNG ├── send-success-email.PNG ├── initialize-fa-emails.PNG ├── item-created-trigger.PNG ├── item-deleted-trigger.PNG ├── item-modified-trigger.PNG ├── graph-explorer-request.PNG ├── initialize-pilot-emails.PNG ├── parallel-email-actions.PNG ├── archive-flight-team-action.PNG ├── create-flight-team-action.PNG └── notify-flight-team-action.PNG ├── Models ├── ListChangedRequest.cs ├── ListSubscription.cs └── FlightTeam.cs ├── local.settings.json.example ├── LICENSE ├── CreateFlightTeam.csproj ├── ConnectGraphNotifications.cs ├── Graph ├── GraphResources.cs ├── BlobTokenCache.cs ├── AuthProvider.cs └── GraphService.cs ├── README-localized ├── README-zh-cn.md ├── README-ja-jp.md ├── README-fr-fr.md ├── README-pt-br.md ├── README-ru-ru.md └── README-es-es.md ├── README.md ├── .gitignore ├── SETUP.md ├── Database └── DatabaseHelper.cs ├── ListChanged.cs └── Provisioning └── TeamProvisioning.cs /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set behaviour for all files, in case developers don't have core.autocrlf set. 2 | * text=auto -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-vscode.csharp" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensions": { 4 | "queues": { 5 | "maxDequeueCount": 1 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /readme-images/set-fa-emails.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/set-fa-emails.PNG -------------------------------------------------------------------------------- /readme-images/send-error-email.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/send-error-email.PNG -------------------------------------------------------------------------------- /readme-images/set-pilot-emails.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/set-pilot-emails.PNG -------------------------------------------------------------------------------- /readme-images/flight-admin-list.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/flight-admin-list.PNG -------------------------------------------------------------------------------- /readme-images/send-success-email.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/send-success-email.PNG -------------------------------------------------------------------------------- /readme-images/initialize-fa-emails.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/initialize-fa-emails.PNG -------------------------------------------------------------------------------- /readme-images/item-created-trigger.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/item-created-trigger.PNG -------------------------------------------------------------------------------- /readme-images/item-deleted-trigger.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/item-deleted-trigger.PNG -------------------------------------------------------------------------------- /readme-images/item-modified-trigger.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/item-modified-trigger.PNG -------------------------------------------------------------------------------- /readme-images/graph-explorer-request.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/graph-explorer-request.PNG -------------------------------------------------------------------------------- /readme-images/initialize-pilot-emails.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/initialize-pilot-emails.PNG -------------------------------------------------------------------------------- /readme-images/parallel-email-actions.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/parallel-email-actions.PNG -------------------------------------------------------------------------------- /readme-images/archive-flight-team-action.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/archive-flight-team-action.PNG -------------------------------------------------------------------------------- /readme-images/create-flight-team-action.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/create-flight-team-action.PNG -------------------------------------------------------------------------------- /readme-images/notify-flight-team-action.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoftgraph/contoso-airlines-azure-functions-sample/main/readme-images/notify-flight-team-action.PNG -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to C# Functions", 6 | "type": "coreclr", 7 | "request": "attach", 8 | "processId": "${command:azureFunctions.pickProcess}" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.projectRuntime": "~2", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.templateFilter": "Verified", 5 | "azureFunctions.deploySubpath": "bin/Release/netcoreapp2.1/publish", 6 | "azureFunctions.preDeployTask": "publish", 7 | "debug.internalConsoleOptions": "neverOpen", 8 | "cSpell.words": [ 9 | "async", 10 | "document", 11 | "msgraph", 12 | "notif", 13 | "notifs", 14 | "opencode", 15 | "upsert" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /Models/ListChangedRequest.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Serialization; 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | namespace CreateFlightTeam.Models 7 | { 8 | [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] 9 | public class ChangeNotification 10 | { 11 | public string SubscriptionId { get; set; } 12 | public DateTime SubscriptionExpirationDateTime { get; set; } 13 | public string ClientState { get; set; } 14 | public string ChangeType { get; set; } 15 | public string Resource { get; set; } 16 | } 17 | 18 | public class ListChangedRequest 19 | { 20 | [JsonProperty("value")] 21 | public List Changes { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /local.settings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "AzureWebJobsDashboard": "UseDevelopmentStorage=true", 6 | "FUNCTIONS_WORKER_RUNTIME": "dotnet", 7 | "TenantId": "YOUR TENANT ID", 8 | "TenantName": "YOUR TENANT DOMAIN NAME", 9 | "AppId": "YOUR PROVISIONING APP ID", 10 | "RedirectUri": "https://flights.contoso.com", 11 | "AppSecret": "YOUR PROVISIONING APP SECRET", 12 | "NotificationAppId": "YOUR NOTIFICATION APP ID", 13 | "NotificationAppSecret": "YOUR NOTIFICATION APP SECRET", 14 | "TeamAppToInstall": "1542629c-01b3-4a6d-8f76-1938b779e48d", 15 | "WebPartId": "WEB-PART-ID", 16 | "FlightAdminSite": "FlightTeamAdmin", 17 | "FlightList": "Flights", 18 | "NotificationHostName": "msgraph.notifs.demo.windows.com", 19 | "NgrokProxy": "YOUR NGROK HTTPS URL", 20 | "DatabaseUri": "https://localhost:8081", 21 | "DatabaseKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" 22 | } 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Microsoft Graph 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Models/ListSubscription.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Serialization; 3 | using System; 4 | 5 | namespace CreateFlightTeam.Models 6 | { 7 | public class ListSubscription 8 | { 9 | [JsonProperty(PropertyName = "id")] 10 | public string Id { get; set; } 11 | [JsonProperty(PropertyName = "subscriptionId")] 12 | public string SubscriptionId { get; set; } 13 | [JsonProperty(PropertyName = "expiration")] 14 | public DateTime Expiration { get; set; } 15 | [JsonProperty(PropertyName = "clientState")] 16 | public string ClientState { get; set; } 17 | [JsonProperty(PropertyName = "resource")] 18 | public string Resource { get; set; } 19 | [JsonProperty(PropertyName = "deltaLink")] 20 | public string DeltaLink { get; set; } 21 | 22 | public bool IsExpired() 23 | { 24 | return Expiration <= DateTime.UtcNow; 25 | } 26 | 27 | public bool IsExpiredOrCloseToExpired() 28 | { 29 | // If expiration is not more than 12 hours away 30 | return Expiration.AddHours(-12) <= DateTime.UtcNow; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CreateFlightTeam.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp2.1 4 | v2 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | PreserveNewest 18 | 19 | 20 | PreserveNewest 21 | Never 22 | 23 | 24 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "clean", 6 | "command": "dotnet clean", 7 | "type": "shell", 8 | "problemMatcher": "$msCompile" 9 | }, 10 | { 11 | "label": "build", 12 | "command": "dotnet build", 13 | "type": "shell", 14 | "dependsOn": "clean", 15 | "group": { 16 | "kind": "build", 17 | "isDefault": true 18 | }, 19 | "problemMatcher": "$msCompile" 20 | }, 21 | { 22 | "label": "clean release", 23 | "command": "dotnet clean --configuration Release", 24 | "type": "shell", 25 | "problemMatcher": "$msCompile" 26 | }, 27 | { 28 | "label": "publish", 29 | "command": "dotnet publish --configuration Release", 30 | "type": "shell", 31 | "dependsOn": "clean release", 32 | "problemMatcher": "$msCompile" 33 | }, 34 | { 35 | "type": "func", 36 | "dependsOn": "build", 37 | "options": { 38 | "cwd": "${workspaceFolder}/bin/Debug/netcoreapp2.1" 39 | }, 40 | "command": "host start", 41 | "isBackground": true, 42 | "problemMatcher": "$func-watch" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /ConnectGraphNotifications.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Azure.WebJobs; 4 | using Microsoft.Azure.WebJobs.Extensions.Http; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Identity.Client; 7 | using System.Collections.Generic; 8 | using System.Net.Http.Headers; 9 | using System.Threading.Tasks; 10 | 11 | namespace CreateFlightTeam 12 | { 13 | [StorageAccount("AzureWebJobsStorage")] 14 | public static class ConnectGraphNotifications 15 | { 16 | [FunctionName("ConnectGraphNotifications")] 17 | public static async Task Run( 18 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 19 | ILogger log) 20 | { 21 | // POST comes from mobile app and should have a bearer token 22 | var authHeader = req.Headers["Authorization"]; 23 | 24 | try 25 | { 26 | await Graph.AuthProvider.GetTokenOnBehalfOfAsync(authHeader, log); 27 | // Return 202 28 | return new AcceptedResult(); 29 | } 30 | catch (MsalException ex) 31 | { 32 | log.LogError(ex.Message); 33 | return new BadRequestResult(); 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /Graph/GraphResources.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE.txt in the project root for license information.v 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Serialization; 4 | using System; 5 | using System.Collections.Generic; 6 | 7 | namespace CreateFlightTeam.Graph 8 | { 9 | [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] 10 | public class SharePointWebPart 11 | { 12 | public const string ListWebPart = "f92bf067-bc19-489e-a556-7fe95f508720"; 13 | 14 | public string Type { get; set; } 15 | public WebPartData Data { get; set; } 16 | } 17 | 18 | [JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] 19 | public class WebPartData 20 | { 21 | public string DataVersion { get; set; } 22 | public object Properties { get; set; } 23 | } 24 | 25 | public class LookupField 26 | { 27 | [JsonProperty(PropertyName = "LookupValue")] 28 | public string DisplayName { get; set; } 29 | public string Email { get; set; } 30 | } 31 | 32 | public class ListFields 33 | { 34 | [JsonProperty(PropertyName = "Description")] 35 | public string Description { get; set; } 36 | 37 | [JsonProperty(PropertyName = "FlightNumber")] 38 | public float FlightNumber { get; set; } 39 | 40 | public List Pilots { get; set; } 41 | 42 | [JsonProperty(PropertyName = "FlightAttendants")] 43 | public List FlightAttendants { get; set; } 44 | 45 | [JsonProperty(PropertyName = "CateringLiaison")] 46 | public string CateringLiaison { get; set; } 47 | 48 | [JsonProperty(PropertyName = "DepartureTime")] 49 | public DateTime DepartureTime { get; set; } 50 | 51 | [JsonProperty(PropertyName = "DepartureGate")] 52 | public string DepartureGate { get; set; } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README-localized/README-zh-cn.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-sp 5 | - office-teams 6 | - office-planner 7 | - ms-graph 8 | languages: 9 | - csharp 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Azure AD 15 | services: 16 | - SharePoint 17 | - Microsoft Teams 18 | - Planner 19 | createdDate: 09/12/2018 0:00:00 PM 20 | --- 21 | # Contoso Airlines 航班团队预配示例 22 | 23 | 此示例应用实现旨在通过 Graph Webhook 调用的 Azure 函数,用于在有新航班添加到 SharePoint 母版列表时预配 Microsoft 团队。此示例使用 Microsoft Graph 执行以下预配任务: 24 | 25 | - 为航班团队创建统一的[组](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta),并为该组初始化一个[团队](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta)。 26 | - 在团队中创建[频道](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta)。 27 | - 向团队[安装应用程序](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta)。 28 | - 为团队创建自定义 SharePoint 页面和自定义 [SharePoint 列表](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta)。 29 | - 向规划器计划和 SharePoint 页面的团队“常规”频道添加[选项卡](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta)。 30 | - 在更新航班时[发送 Graph 通知](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta)。 31 | - 在删除航班时[将团队存档](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta)。 32 | 33 | ## 先决条件 34 | 35 | - 装有 **Azure Functions** 扩展的 Visual Studio Code。 36 | - Office 365 租户 37 | - Azure 订阅 - 如果希望发布函数。可在 Visual Studio 代码中本地运行此操作,但需要满足更多要求。 38 | 39 | ### 本地运行的先决条件 40 | 41 | - ngrok 42 | - Azure Cosmos DB 模拟器 43 | - Azure 存储模拟器 44 | 45 | ## 设置 46 | 47 | 若要设置示例,请参阅[进行端到端演示设置](SETUP.md) 48 | 49 | 此项目遵循 [Microsoft 开放源代码行为准则](https://opensource.microsoft.com/codeofconduct/)。 50 | 有关详细信息,请参阅[行为准则常见问题解答](https://opensource.microsoft.com/codeofconduct/faq/)。 51 | 如有其他任何问题或意见,也可联系 [opencode@microsoft.com](mailto:opencode@microsoft.com)。 -------------------------------------------------------------------------------- /README-localized/README-ja-jp.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-sp 5 | - office-teams 6 | - office-planner 7 | - ms-graph 8 | languages: 9 | - csharp 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Azure AD 15 | services: 16 | - SharePoint 17 | - Microsoft Teams 18 | - Planner 19 | createdDate: 09/12/2018 0:00:00 PM 20 | --- 21 | # Contoso 航空フライト チームのプロビジョニング サンプル 22 | 23 | このサンプル アプリは、SharePoint のマスター リストに新しいフライトが追加されると、Microsoft Teams チームをプロビジョニングするために Graph webhook 経由で呼び出されるように作られた Azure 関数を実装します。このサンプルでは、Microsoft Graph を使用して次のプロビジョニング タスクを実行します。 24 | 25 | - 統合された[グループ](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta)をフライト チーム用に作成し、そのグループについて [Teams](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta) を初期化します。 26 | - チームに[チャネル](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta) を作成します。 27 | - チームに[アプリをインストール](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta)します。 28 | - チームにカスタム SharePoint ページとカスタム [SharePoint](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta) リストを作成します。 29 | - プランナーの計画と SharePoint ページ用の[タブ](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta)をチームの \[一般] チャネルに追加します。 30 | - フライトが更新されたときに、[Graph の通知を送信](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta)します。 31 | - フライトが削除されたときに、[チームをアーカイブ](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta)します。 32 | 33 | ## 前提条件 34 | 35 | - **Azure Functions** 拡張機能がインストールされている Visual Studio Code。 36 | - Office 365 テナント 37 | - 関数を発行する場合は、Azure のサブスクリプション。このアプリは Visual Studio Code でローカル実行できますが、その場合は追加の要件があります。 38 | 39 | ### ローカルで実行するための前提条件 40 | 41 | - ngrok 42 | - Azure Cosmos DB Emulator 43 | - Azure Storage Emulator 44 | 45 | ## セットアップ 46 | 47 | サンプルをセットアップするには、「[Set up for end-to-end demo (エンド ツー エンド デモ用のセットアップ)](SETUP.md)」を参照してください。 48 | 49 | このプロジェクトでは、[Microsoft Open Source Code of Conduct (Microsoft オープン ソース倫理規定)](https://opensource.microsoft.com/codeofconduct/) 50 | が採用されています。詳細については、「[Code of Conduct の FAQ (倫理規定の FAQ)](https://opensource.microsoft.com/codeofconduct/faq/)」 51 | を参照してください。また、その他の質問やコメントがあれば、[opencode@microsoft.com](mailto:opencode@microsoft.com) までお問い合わせください。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contoso Airlines Flight Team Provisioning Sample 2 | 3 | ## IMPORTANT 4 | 5 | **This sample has been archived and is no longer being maintained. For a more current sample using Microsoft Graph from Azure Functions, please see https://github.com/microsoftgraph/msgraph-training-azurefunction-csharp.** 6 | 7 | This sample app implements Azure functions designed to be invoked via a Graph webhook to provision a Microsoft Team when a new flight is added to a master list in SharePoint. The sample uses Microsoft Graph to do the following provisioning tasks: 8 | 9 | - Creates a unified [group](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta) for the flight team, and initializes a [Team](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta) for the group. 10 | - Creates [channels](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta) in the team. 11 | - [Installs an app](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta) to the team. 12 | - Creates a custom SharePoint page and custom [SharePoint list](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta) for the team. 13 | - Adds a [tab](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta) to the team's General channel for the planner plan and SharePoint page. 14 | - [Sends a Graph notification](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta) when the flight is updated. 15 | - [Archives the team](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta) when the flight is deleted. 16 | 17 | ## Prerequisites 18 | 19 | - Visual Studio Code with **Azure Functions** extension installed. 20 | - Office 365 tenant 21 | - Azure subscription if you want to publish the functions. You can run this locally in Visual Studio Code but will need further requirements. 22 | 23 | ### Prerequisites to run locally 24 | 25 | - ngrok 26 | - Azure Cosmos DB Emulator 27 | - Azure Storage Emulator 28 | 29 | ## Setup 30 | 31 | To setup the sample, see [Set up for end-to-end demo](SETUP.md) 32 | 33 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 34 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 35 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 36 | -------------------------------------------------------------------------------- /README-localized/README-fr-fr.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-sp 5 | - office-teams 6 | - office-planner 7 | - ms-graph 8 | languages: 9 | - csharp 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Azure AD 15 | services: 16 | - SharePoint 17 | - Microsoft Teams 18 | - Planner 19 | createdDate: 09/12/2018 0:00:00 PM 20 | --- 21 | # Exemple d’approvisionnement pour le personnel de bord Contoso Airlines 22 | 23 | Cet exemple d’application implémente les fonctions Azure conçues pour être appelées par un webhook pour approvisionner une équipe Microsoft lorsqu’un nouveau vol est ajouté à une liste principale dans SharePoint. L’exemple utilise Microsoft Graph pour effectuer les tâches d'approvisionnement suivantes : 24 | 25 | - Crée un [groupe](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta) unifié pour l’équipe de vol et Initialise une [Équipe](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta) pour le groupe. 26 | - Crée des [canaux](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta) dans l’équipe. 27 | - [Installe une application](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta) dans l'équipe. 28 | - Crée une page SharePoint sur mesure et personnalisée la [Liste SharePoint](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta) pour l’équipe. 29 | - Ajoute un [onglet](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta) au canal général de l’équipe pour le plan du planificateur et la page SharePoint. 30 | - [Envoie une notification Graph](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta) le vol est mis à jour. 31 | - [Archive l’équipe](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta) lorsque le vol supprimé. 32 | 33 | ## Conditions préalables 34 | 35 | - Extension Visual Studio Code installée avec **Azure Functions**. 36 | - Client Office 365 37 | - Abonnement Azure si vous souhaitez publier les fonctions. Vous pouvez l’exécuter localement dans Visual Studio Code, mais d'autres conditions sont requises. 38 | 39 | ### Conditions préalables à l’exécution locale 40 | 41 | - ngrok 42 | - Émulateur DB Azure Cosmos DB 43 | - Émulateur de stockage Azure 44 | 45 | ## Configuration 46 | 47 | Pour configurer l’exemple, voir [Configurer pour une démonstration de bout en bout](SETUP.md) 48 | 49 | Ce projet a adopté le [Code de conduite Microsoft Open Source](https://opensource.microsoft.com/codeofconduct/). 50 | Pour en savoir plus, consultez la [FAQ relative au Code de conduite](https://opensource.microsoft.com/codeofconduct/faq/) 51 | ou contactez [opencode@microsoft.com](mailto:opencode@microsoft.com) pour toute question ou tout commentaire. -------------------------------------------------------------------------------- /README-localized/README-pt-br.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-sp 5 | - office-teams 6 | - office-planner 7 | - ms-graph 8 | languages: 9 | - csharp 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Azure AD 15 | services: 16 | - SharePoint 17 | - Microsoft Teams 18 | - Planner 19 | createdDate: 09/12/2018 0:00:00 PM 20 | --- 21 | # Exemplo de provisionamento da equipe de bordo da Contoso Airlines 22 | 23 | Este exemplo de aplicativo implementa funções do Azure desenvolvidas para serem chamadas por meio de um webhook do Graph para provisionar um Microsoft Team quando um novo voo é adicionado a uma lista mestre no SharePoint. O exemplo usa o Microsoft Graph para executar as seguintes tarefas de provisionamento: 24 | 25 | - Criar um [grupo](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta) unificado para a equipe de voo e inicializar um [Team](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta) para o grupo. 26 | - Criar [canais](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta) na equipe. 27 | - [Instalar um aplicativo](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta) para a equipe. 28 | - Criar uma página do SharePoint personalizada e uma [lista do SharePoint](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta) personalizada para a equipe. 29 | - Adicionar uma [guia](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta) ao canal "Geral" da equipe para o plano do Planner e a página do SharePoint. 30 | - [Enviar uma notificação do Graph](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta) quando o voo for atualizado. 31 | - [Arquivar a equipe](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta) quando o voo for excluído. 32 | 33 | ## Pré-requisitos 34 | 35 | - Código do Visual Studio com a extensão **Azure Functions** instalada. 36 | - Locatário do Office 365 37 | - Assinatura do Azure para publicar as funções. Você pode executar isso localmente no Código do Visual Studio, mas precisará atender a mais requisitos. 38 | 39 | ### Pré-requisitos para executar localmente 40 | 41 | - ngrok 42 | - Emulador de banco de dados do Azure Cosmos 43 | - Emulador de armazenamento do Azure 44 | 45 | ## Configuração 46 | 47 | Para configurar ao exemplo, confira [Configurar para demonstração de ponta a ponta](SETUP.md) 48 | 49 | Este projeto adotou o [Código de Conduta de Código Aberto da Microsoft](https://opensource.microsoft.com/codeofconduct/). 50 | Para saber mais, confira as [Perguntas frequentes sobre o Código de Conduta](https://opensource.microsoft.com/codeofconduct/faq/) 51 | ou entre em contato pelo [opencode@microsoft.com](mailto:opencode@microsoft.com) se tiver outras dúvidas ou comentários. -------------------------------------------------------------------------------- /README-localized/README-ru-ru.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-sp 5 | - office-teams 6 | - office-planner 7 | - ms-graph 8 | languages: 9 | - csharp 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Azure AD 15 | services: 16 | - SharePoint 17 | - Microsoft Teams 18 | - Planner 19 | createdDate: 09/12/2018 0:00:00 PM 20 | --- 21 | # Пример подготовки летного состава для авиакомпании Contoso 22 | 23 | В этом примере приложения реализованы функции Azure, рассчитанные на вызов через веб-перехватчик Graph для подготовки команды Microsoft Teams при добавлении нового рейса в главный список в SharePoint. В этом примере приложение Microsoft Graph используется для выполнения задач подготовки, перечисленных ниже. 24 | 25 | - Создание единой[группы](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta) для летного экипажа и инициализация [команды](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta) для этой группы. 26 | - Создание [каналов](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta) в команде. 27 | - [Установка приложения](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta) для команды. 28 | - Создание настраиваемой страницы SharePoint и настраиваемого [списка SharePoint](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta) для команды. 29 | - Добавление [вкладки](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta) для плана Планировщика и страницы SharePoint в "Общий" канал. 30 | - [Отправка уведомления Graph](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta) при обновлении информации о рейсе. 31 | - [Архивирование команды](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta) при удалении информации о рейсе. 32 | 33 | ## Необходимые компоненты 34 | 35 | - Visual Studio Code с установленным расширением **Функции Azure**. 36 | - Клиент Office 365 37 | - Подписка на Azure, если нужна публикация функций. Это можно сделать локально в приложении Visual Studio Code, но для этого потребуется выполнить дополнительные требования. 38 | 39 | ### Предварительные условия для локального выполнения 40 | 41 | - ngrok 42 | - Эмулятор Azure Cosmos DB 43 | - Эмулятор службы хранилища Azure 44 | 45 | ## Настройка 46 | 47 | Информацию о настройке примера приложения см. в статье [Настройка полной демонстрации](SETUP.md) 48 | 49 | Этот проект соответствует [Правилам поведения разработчиков открытого кода Майкрософт](https://opensource.microsoft.com/codeofconduct/). 50 | Дополнительные сведения см. в разделе [часто задаваемых вопросов о правилах поведения](https://opensource.microsoft.com/codeofconduct/faq/). 51 | Если у вас возникли вопросы или замечания, напишите нам по адресу [opencode@microsoft.com](mailto:opencode@microsoft.com). -------------------------------------------------------------------------------- /README-localized/README-es-es.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_type: sample 3 | products: 4 | - office-sp 5 | - office-teams 6 | - office-planner 7 | - ms-graph 8 | languages: 9 | - csharp 10 | extensions: 11 | contentType: samples 12 | technologies: 13 | - Microsoft Graph 14 | - Azure AD 15 | services: 16 | - SharePoint 17 | - Microsoft Teams 18 | - Planner 19 | createdDate: 09/12/2018 0:00:00 PM 20 | --- 21 | # Ejemplo de aprovisionamiento de Contoso Airlines Flight Team 22 | 23 | Esta aplicación de ejemplo implementa las funciones de Azure diseñadas para invocarse a través de un webhook de Graph con el fin de aprovisionar un Equipo de Microsoft cuando se agrega un nuevo piloto a una lista maestra en SharePoint. El ejemplo usa Microsoft Graph para realizar las siguientes tareas de aprovisionamiento: 24 | 25 | - Crea un [grupo](https://docs.microsoft.com/graph/api/resources/groups-overview?view=graph-rest-beta) unificado para el equipo de vuelo y, a continuación, inicializa un [Equipo](https://docs.microsoft.com/graph/api/resources/teams-api-overview?view=graph-rest-beta) para el grupo. 26 | - Crea [canales](https://docs.microsoft.com/graph/api/resources/channel?view=graph-rest-beta) en el equipo. 27 | - [Instala una aplicación](https://docs.microsoft.com/graph/api/resources/teamsapp?view=graph-rest-beta) en un equipo. 28 | - Crea una página de SharePoint personalizada y una](https://docs.microsoft.com/graph/api/resources/list?view=graph-rest-beta)lista de SharePoint[ personalizada para el equipo. 29 | - Agrega una [pestaña](https://docs.microsoft.com/graph/api/resources/teamstab?view=graph-rest-beta) al canal General del Equipo para el plan de Planner y la página de SharePoint. 30 | - [Envía una notificación de Graph](https://docs.microsoft.com/graph/api/resources/projectrome-notification?view=graph-rest-beta) cuando se actualiza el vuelo. 31 | - [Archiva el equipo](https://docs.microsoft.com/graph/api/team-archive?view=graph-rest-beta) cuando se elimina el vuelo. 32 | 33 | ## Requisitos previos 34 | 35 | - Extensión de Visual Studio Code con **funciones de Azure** instalada. 36 | - Inquilino de Office 365 37 | - Suscripción de Azure si desea publicar las funciones. Puede ejecutarlo de forma local en Visual Studio Code, pero necesitará requisitos adicionales. 38 | 39 | ### Requisitos previos para ejecutar localmente 40 | 41 | - ngrok 42 | - Azure Cosmos DB Emulator 43 | - Azure Storage Emulator 44 | 45 | ## Instalación 46 | 47 | Para configurar el ejemplo, consulte [Configurar la demostración de un extremo a otro](SETUP.md) 48 | 49 | Este proyecto ha adoptado el [Código de conducta de código abierto de Microsoft](https://opensource.microsoft.com/codeofconduct/). 50 | Para obtener más información, vea [Preguntas frecuentes sobre el código de conducta](https://opensource.microsoft.com/codeofconduct/faq/) 51 | o póngase en contacto con [opencode@microsoft.com](mailto:opencode@microsoft.com) si tiene otras preguntas o comentarios. -------------------------------------------------------------------------------- /Models/FlightTeam.cs: -------------------------------------------------------------------------------- 1 | using CreateFlightTeam.Graph; 2 | using Microsoft.Graph; 3 | using Newtonsoft.Json; 4 | using Newtonsoft.Json.Serialization; 5 | using System; 6 | using System.Collections.Generic; 7 | 8 | namespace CreateFlightTeam.Models 9 | { 10 | public class FlightTeam 11 | { 12 | [JsonProperty(PropertyName = "id")] 13 | public string Id { get; set; } 14 | [JsonProperty(PropertyName = "sharePointListItemId")] 15 | public string SharePointListItemId { get; set; } 16 | [JsonProperty(PropertyName = "teamId")] 17 | public string TeamId { get; set; } 18 | [JsonProperty(PropertyName = "description")] 19 | public string Description { get; set; } 20 | [JsonProperty(PropertyName = "flightNumber")] 21 | public int FlightNumber { get; set; } 22 | [JsonProperty(PropertyName = "departureGate")] 23 | public string DepartureGate { get; set; } 24 | [JsonProperty(PropertyName = "departureTime")] 25 | public DateTime DepartureTime { get; set; } 26 | [JsonProperty(PropertyName = "admin")] 27 | public string Admin { get; set; } 28 | [JsonProperty(PropertyName = "pilots")] 29 | public List Pilots { get; set; } 30 | [JsonProperty(PropertyName = "flightAttendants")] 31 | public List FlightAttendants { get; set; } 32 | [JsonProperty(PropertyName = "cateringLiaison")] 33 | public string CateringLiaison { get; set; } 34 | 35 | public FlightTeam() { } 36 | public static FlightTeam FromListItem(string itemId, ListItem listItem) 37 | { 38 | var jsonFields = JsonConvert.SerializeObject(listItem.Fields.AdditionalData); 39 | var fields = JsonConvert.DeserializeObject(jsonFields); 40 | 41 | if (string.IsNullOrEmpty(fields.Description) || 42 | string.IsNullOrEmpty(fields.DepartureGate) || 43 | fields.FlightNumber <= 0 || 44 | fields.DepartureTime == DateTime.MinValue) 45 | { 46 | return null; 47 | } 48 | 49 | var team = new FlightTeam(); 50 | 51 | team.SharePointListItemId = itemId; 52 | team.Description = fields.Description; 53 | team.FlightNumber = (int)fields.FlightNumber; 54 | team.DepartureGate = fields.DepartureGate; 55 | team.DepartureTime = fields.DepartureTime; 56 | team.Admin = listItem.CreatedBy.User.Id; 57 | team.CateringLiaison = fields.CateringLiaison; 58 | 59 | team.Pilots = new List(); 60 | if (fields.Pilots != null) { 61 | foreach (var value in fields.Pilots) 62 | { 63 | team.Pilots.Add(value.Email); 64 | } 65 | } 66 | 67 | team.FlightAttendants = new List(); 68 | if (fields.FlightAttendants != null) { 69 | foreach (var value in fields.FlightAttendants) 70 | { 71 | team.FlightAttendants.Add(value.Email); 72 | } 73 | } 74 | 75 | return team; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Graph/BlobTokenCache.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Identity.Client; 2 | using Microsoft.WindowsAzure.Storage; 3 | using Microsoft.WindowsAzure.Storage.Blob; 4 | using Newtonsoft.Json; 5 | using System; 6 | using System.Text; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | namespace CreateFlightTeam.Graph 11 | { 12 | public static class BlobTokenCache 13 | { 14 | private static readonly string connectionString = Environment.GetEnvironmentVariable("AzureWebJobsStorage"); 15 | private static string tokenBlobETag = string.Empty; 16 | 17 | private static TokenCache cache = new TokenCache(); 18 | 19 | public static TokenCache GetMsalCacheInstance() 20 | { 21 | cache.SetBeforeAccess(BeforeAccessNotification); 22 | cache.SetAfterAccess(AfterAccessNotification); 23 | Load().Wait(); 24 | return cache; 25 | } 26 | 27 | private static async Task GetTokenStorageBlob() 28 | { 29 | try 30 | { 31 | var storageAccount = CloudStorageAccount.Parse(connectionString); 32 | var client = storageAccount.CreateCloudBlobClient(); 33 | var container = client.GetContainerReference("tokencache"); 34 | 35 | await container.CreateIfNotExistsAsync(); 36 | 37 | return container.GetBlockBlobReference("MsalUserTokenCache"); 38 | } 39 | catch 40 | { 41 | return null; 42 | } 43 | } 44 | private static async Task Load() 45 | { 46 | var tokenBlob = await GetTokenStorageBlob(); 47 | 48 | if (await tokenBlob.ExistsAsync()) 49 | { 50 | await tokenBlob.FetchAttributesAsync(); 51 | 52 | // Check if we need to reload from the blob 53 | if (tokenBlob.Properties.ETag.CompareTo(tokenBlobETag) != 0) 54 | { 55 | var blobCacheBytes = new byte[tokenBlob.Properties.Length]; 56 | await tokenBlob.DownloadToByteArrayAsync(blobCacheBytes, 0); 57 | 58 | cache.Deserialize(blobCacheBytes); 59 | tokenBlobETag = tokenBlob.Properties.ETag; 60 | } 61 | } 62 | } 63 | 64 | private static async Task Persist() 65 | { 66 | // Reflect changes in the persistent store 67 | var cacheBytes = cache.Serialize(); 68 | var tokenBlob = await GetTokenStorageBlob(); 69 | 70 | await tokenBlob.UploadFromByteArrayAsync(cacheBytes, 0, cacheBytes.Length); 71 | } 72 | 73 | // Triggered right before MSAL needs to access the cache. 74 | // Reload the cache from the persistent store in case it changed since the last access. 75 | private static void BeforeAccessNotification(TokenCacheNotificationArgs args) 76 | { 77 | Load().Wait(); 78 | } 79 | 80 | // Triggered right after MSAL accessed the cache. 81 | private static void AfterAccessNotification(TokenCacheNotificationArgs args) 82 | { 83 | // if the access operation resulted in a cache update 84 | if (args.HasStateChanged) 85 | { 86 | Persist().Wait(); 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /Graph/AuthProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE.txt in the project root for license information. 2 | using CreateFlightTeam.DocumentDB; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Identity.Client; 5 | using Microsoft.WindowsAzure.Storage; 6 | using Microsoft.WindowsAzure.Storage.Blob; 7 | using Newtonsoft.Json; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Net.Http; 12 | using System.Net.Http.Headers; 13 | using System.Threading.Tasks; 14 | 15 | namespace CreateFlightTeam.Graph 16 | { 17 | public static class AuthProvider 18 | { 19 | private static readonly string appId = Environment.GetEnvironmentVariable("AppId"); 20 | private static readonly string tid = Environment.GetEnvironmentVariable("TenantId"); 21 | private static readonly string authority = $"https://login.microsoftonline.com/{tid}"; 22 | private static readonly ClientCredential clientCreds = new ClientCredential( 23 | Environment.GetEnvironmentVariable("AppSecret")); 24 | private static readonly string redirectUri = Environment.GetEnvironmentVariable("RedirectUri"); 25 | private static readonly string[] scopes = { "https://graph.microsoft.com/.default" }; 26 | private static readonly string notifAppId = Environment.GetEnvironmentVariable("NotificationAppId"); 27 | private static readonly ClientCredential notifClientCreds = new ClientCredential( 28 | Environment.GetEnvironmentVariable("NotificationAppSecret")); 29 | private static readonly string[] notifScopes = { "Notifications.ReadWrite.CreatedByApp" }; 30 | 31 | private static ILogger logger; 32 | 33 | public static ILogger AzureLogger 34 | { 35 | get { return logger; } 36 | set { logger = value; } 37 | } 38 | 39 | public static async Task GetTokenOnBehalfOfAsync(string authHeader, ILogger log) 40 | { 41 | logger = log; 42 | if (string.IsNullOrEmpty(authHeader)) 43 | { 44 | throw new MsalException("missing_auth", "Authorization header is not present on request."); 45 | } 46 | 47 | // Parse the auth header 48 | var parsedHeader = AuthenticationHeaderValue.Parse(authHeader); 49 | 50 | if (parsedHeader.Scheme.ToLower() != "bearer") 51 | { 52 | throw new MsalException("invalid_scheme", "Authorization header is missing the 'bearer' scheme."); 53 | } 54 | 55 | var confidentialClient = new ConfidentialClientApplication(notifAppId, 56 | authority, redirectUri, notifClientCreds, BlobTokenCache.GetMsalCacheInstance(), null); 57 | 58 | //Logger.LogCallback = AuthLog; 59 | //Logger.Level = Microsoft.Identity.Client.LogLevel.Verbose; 60 | //Logger.PiiLoggingEnabled = true; 61 | var userAssertion = new UserAssertion(parsedHeader.Parameter); 62 | 63 | try 64 | { 65 | var result = await confidentialClient.AcquireTokenOnBehalfOfAsync(notifScopes, userAssertion); 66 | } 67 | catch (Exception ex) 68 | { 69 | logger.LogError($"Error getting OBO token: {ex.Message}"); 70 | throw ex; 71 | } 72 | } 73 | 74 | public static async Task GetUserToken(string userId) 75 | { 76 | var confidentialClient = new ConfidentialClientApplication(notifAppId, 77 | authority, redirectUri, notifClientCreds, BlobTokenCache.GetMsalCacheInstance(), null); 78 | 79 | //Logger.LogCallback = AuthLog; 80 | //Logger.Level = Microsoft.Identity.Client.LogLevel.Verbose; 81 | //Logger.PiiLoggingEnabled = true; 82 | 83 | var account = await confidentialClient.GetAccountAsync($"{userId}.{tid}"); 84 | 85 | if (account == null) 86 | { 87 | return string.Empty; 88 | } 89 | 90 | try 91 | { 92 | var result = await confidentialClient.AcquireTokenSilentAsync(notifScopes, account); 93 | return result.AccessToken; 94 | } 95 | catch (MsalException) 96 | { 97 | return string.Empty; 98 | } 99 | } 100 | 101 | private static void AuthLog(Microsoft.Identity.Client.LogLevel level, string message, bool containsPII) 102 | { 103 | logger.LogInformation($"MSAL: {message}"); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | local.settings.json 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | # DNX 47 | project.lock.json 48 | project.fragment.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # NCrunch 117 | _NCrunch_* 118 | .*crunch*.local.xml 119 | nCrunchTemp_* 120 | 121 | # MightyMoose 122 | *.mm.* 123 | AutoTest.Net/ 124 | 125 | # Web workbench (sass) 126 | .sass-cache/ 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.[Pp]ublish.xml 146 | *.azurePubxml 147 | # TODO: Comment the next line if you want to checkin your web deploy settings 148 | # but database connection strings (with potential passwords) will be unencrypted 149 | #*.pubxml 150 | *.publishproj 151 | 152 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 153 | # checkin your Azure Web App publish settings, but sensitive information contained 154 | # in these scripts will be unencrypted 155 | PublishScripts/ 156 | 157 | # NuGet Packages 158 | *.nupkg 159 | # The packages folder can be ignored because of Package Restore 160 | **/packages/* 161 | # except build/, which is used as an MSBuild target. 162 | !**/packages/build/ 163 | # Uncomment if necessary however generally it will be regenerated when needed 164 | #!**/packages/repositories.config 165 | # NuGet v3's project.json files produces more ignoreable files 166 | *.nuget.props 167 | *.nuget.targets 168 | 169 | # Microsoft Azure Build Output 170 | csx/ 171 | *.build.csdef 172 | 173 | # Microsoft Azure Emulator 174 | ecf/ 175 | rcf/ 176 | 177 | # Windows Store app package directories and files 178 | AppPackages/ 179 | BundleArtifacts/ 180 | Package.StoreAssociation.xml 181 | _pkginfo.txt 182 | 183 | # Visual Studio cache files 184 | # files ending in .cache can be ignored 185 | *.[Cc]ache 186 | # but keep track of directories ending in .cache 187 | !*.[Cc]ache/ 188 | 189 | # Others 190 | ClientBin/ 191 | ~$* 192 | *~ 193 | *.dbmdl 194 | *.dbproj.schemaview 195 | *.jfm 196 | *.pfx 197 | *.publishsettings 198 | node_modules/ 199 | orleans.codegen.cs 200 | 201 | # Since there are multiple workflows, uncomment next line to ignore bower_components 202 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 203 | #bower_components/ 204 | 205 | # RIA/Silverlight projects 206 | Generated_Code/ 207 | 208 | # Backup & report files from converting an old project file 209 | # to a newer Visual Studio version. Backup files are not needed, 210 | # because we have git ;-) 211 | _UpgradeReport_Files/ 212 | Backup*/ 213 | UpgradeLog*.XML 214 | UpgradeLog*.htm 215 | 216 | # SQL Server files 217 | *.mdf 218 | *.ldf 219 | 220 | # Business Intelligence projects 221 | *.rdl.data 222 | *.bim.layout 223 | *.bim_*.settings 224 | 225 | # Microsoft Fakes 226 | FakesAssemblies/ 227 | 228 | # GhostDoc plugin setting file 229 | *.GhostDoc.xml 230 | 231 | # Node.js Tools for Visual Studio 232 | .ntvs_analysis.dat 233 | 234 | # Visual Studio 6 build log 235 | *.plg 236 | 237 | # Visual Studio 6 workspace options file 238 | *.opt 239 | 240 | # Visual Studio LightSwitch build output 241 | **/*.HTMLClient/GeneratedArtifacts 242 | **/*.DesktopClient/GeneratedArtifacts 243 | **/*.DesktopClient/ModelManifest.xml 244 | **/*.Server/GeneratedArtifacts 245 | **/*.Server/ModelManifest.xml 246 | _Pvt_Extensions 247 | 248 | # Paket dependency manager 249 | .paket/paket.exe 250 | paket-files/ 251 | 252 | # FAKE - F# Make 253 | .fake/ 254 | 255 | # JetBrains Rider 256 | .idea/ 257 | *.sln.iml 258 | 259 | # CodeRush 260 | .cr/ 261 | 262 | # Python Tools for Visual Studio (PTVS) 263 | __pycache__/ 264 | *.pyc 265 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | # Deploy solution in an Office 365 tenant 2 | 3 | WORK IN PROGRESS 4 | 5 | ## Demo users 6 | 7 | - **Flight administrator**: An admin user that you'll use to demo creating flights in the SharePoint list. 8 | - **Flight attendant**: A non-admin user that you'll use to demo the mobile app and the self-serve aspect of the web app. 9 | 10 | ## Create groups 11 | 12 | Create the following universal groups and add folks to them. 13 | 14 | - **Flight Admins**: Add the **Flight administrator** account. 15 | - **Flight Attendants**: Add the **Flight attendant** account + 4-5 others 16 | 17 | ## Create flight team admin site 18 | 19 | 1. Create a team site in SharePoint named `FlightAdmin`. (**Note**: If you use a different site name, be sure to update the `FlightAdminSite` value in **local.settings.json**.) 20 | 1. Create a new document library in the site named `Flights`. (**Note**: If you use a different document library name, be sure to update the `FlightList` value in **local.settings.json**.) 21 | 1. Add the following columns to the document library. 22 | 1. **Single line of text** 23 | 24 | | Field | Value | 25 | |-----------------------------------------------|--------------| 26 | | Name | Description | 27 | | Require that this column contains information | Yes | 28 | 29 | 1. **Number** 30 | 31 | | Field | Value | 32 | |-----------------------------------------------|---------------| 33 | | Name | Flight Number | 34 | | Number of decimal places | 0 | 35 | | Require that this column contains information | Yes | 36 | | Enforce unique values | Yes | 37 | 38 | 1. **Person** 39 | 40 | | Field | Value | 41 | |-----------------------------------------------|--------| 42 | | Name | Pilots | 43 | | Allow multiple selections | Yes | 44 | | Require that this column contains information | Yes | 45 | 46 | 1. **Person** 47 | 48 | | Field | Value | 49 | |-----------------------------------------------|-------------------| 50 | | Name | Flight Attendants | 51 | | Allow multiple selections | Yes | 52 | | Require that this column contains information | No | 53 | 54 | 1. **Single line of text** 55 | 56 | | Field | Value | 57 | |-----------------------------------------------|------------------| 58 | | Name | Catering Liaison | 59 | | Require that this column contains information | No | 60 | 61 | 1. **Date** 62 | 63 | | Field | Value | 64 | |-----------------------------------------------|----------------| 65 | | Name | Departure Time | 66 | | Include Time | Yes | 67 | | Require that this column contains information | Yes | 68 | 69 | 1. **Single line of text** 70 | 71 | | Field | Value | 72 | |-----------------------------------------------|----------------| 73 | | Name | Departure Gate | 74 | | Require that this column contains information | Yes | 75 | 76 | 1. Select the **New** dropdown, then select **Edit New menu**. Disable all items except **Word document**, then select **Save**. 77 | 1. Select the gear icon in the upper right, then select **Library settings**. 78 | 1. Select **Indexed columns**, then select **Create a new index**. 79 | 1. Set **Primary column for this index** to **Departure Time** then select **Create**. 80 | 81 | ## App registration 82 | 83 | Register an app **Flight Team Provisioning Function**. 84 | 85 | - Accounts in this organizational directory only 86 | - Redirect URI: Web, https://flights.contoso.com 87 | - Add application permissions for Graph: 88 | - **Calendars.ReadWrite** 89 | - **Files.ReadWrite.All** 90 | - **Group.ReadWrite.All** 91 | - **Sites.Manage.All** 92 | - **Sites.ReadWrite.All** 93 | - **User.Invite.All** 94 | - **User.Read.All** 95 | - After adding the permissions, use the **Grant admin consent for Contoso** button 96 | - Create a secret 97 | - Set `TenantId`, `TenantName`, `AppId`, and `AppSecret` 98 | 99 | ## OPTIONAL: Configuring Graph notifications 100 | 101 | This section deals with the configuration needed to enable the [Graph notifications](https://docs.microsoft.com/graph/api/resources/notifications-api-overview?view=graph-rest-beta) feature of this sample. If you do not do these steps, the sample will still work, it just will not send these notifications. 102 | 103 | You'll need a few things for this to work: 104 | 105 | - An application to receive the notifications. This sample was written to work with the [Contoso Airlines iOS app](https://github.com/microsoftgraph/contoso-airlines-ios-swift-sample), but you could also write your own. For the sample iOS app, you need: 106 | - A MacOS device with XCode installed. 107 | - An Apple developer account 108 | - You must [register your app in the Windows Dev Center for cross-device experiences](https://docs.microsoft.com/windows/project-rome/notifications/how-to-guide-for-ios#register-your-app-in-microsoft-windows-dev-center-for-cross-device-experiences). 109 | 110 | ### App registration for notification service 111 | 112 | Register a separate app in the Azure portal for the notification service named **Flight Team Notification Service**. 113 | 114 | - Accounts in this organizational directory only 115 | - Redirect URI: Web, https://flights.contoso.com 116 | - Add delegated permissions for Graph: 117 | - **Notifications.ReadWrite.CreatedByApp** 118 | - **User.Read** 119 | - **offline_access** 120 | - After adding the permissions, use the **Grant admin consent for Contoso** button 121 | - On **Expose an API** tab, add a scope named **Notifications.Send** that admins and users can consent. Accept the application ID URI that is generated for you. 122 | - Add an **Authorized client application** using the application ID for your receiving app (for example, the iOS sample above) 123 | - Create a secret 124 | - Set the values for `NotificationAppId` and `NotificationAppSecret` in **local.settings.json**. 125 | - Add the application ID for **Flight Team Notification Service** in the **Support Microsoft Account & Azure Active Directory** section of your cross-device experience registration in the Windows Dev Center. 126 | 127 | ### Add your cross-device app domain 128 | 129 | Set the `NotificationHostName` value in **local.settings.json** to the domain configured in the **Verify your cross-device app domain** section of your cross-device experience registration in the Windows Dev Center. 130 | -------------------------------------------------------------------------------- /Database/DatabaseHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Linq.Expressions; 5 | using System.Net; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using CreateFlightTeam.Models; 9 | using Microsoft.Azure.Documents; 10 | using Microsoft.Azure.Documents.Client; 11 | using Microsoft.Azure.Documents.Linq; 12 | 13 | namespace CreateFlightTeam.DocumentDB 14 | { 15 | public static class DatabaseHelper 16 | { 17 | private static readonly string databaseUri = Environment.GetEnvironmentVariable("DatabaseUri"); 18 | private static readonly string databaseKey = Environment.GetEnvironmentVariable("DatabaseKey"); 19 | private static readonly string databaseName = "FlightTeamProvisioning"; 20 | private static readonly string flightCollection = "FlightTeams"; 21 | private static readonly string subscriptionCollection = "Subscriptions"; 22 | 23 | private static DocumentClient client = null; 24 | 25 | #region Initialization 26 | 27 | public static void Initialize() 28 | { 29 | if (client == null) 30 | { 31 | client = new DocumentClient(new Uri(databaseUri), databaseKey); 32 | } 33 | CreateDatabaseIfNotExistsAsync().Wait(); 34 | CreateCollectionsIfNotExistsAsync().Wait(); 35 | } 36 | 37 | private static async Task CreateDatabaseIfNotExistsAsync() 38 | { 39 | try 40 | { 41 | await client.ReadDatabaseAsync(UriFactory.CreateDatabaseUri(databaseName)); 42 | } 43 | catch (DocumentClientException e) 44 | { 45 | if (e.StatusCode == HttpStatusCode.NotFound) 46 | { 47 | await client.CreateDatabaseAsync(new Database { Id = databaseName }); 48 | } 49 | else 50 | { 51 | throw; 52 | } 53 | } 54 | } 55 | 56 | private static async Task CreateCollectionsIfNotExistsAsync() 57 | { 58 | try 59 | { 60 | await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(databaseName, flightCollection)); 61 | } 62 | catch (DocumentClientException e) 63 | { 64 | if (e.StatusCode == HttpStatusCode.NotFound) 65 | { 66 | await client.CreateDocumentCollectionAsync( 67 | UriFactory.CreateDatabaseUri(databaseName), 68 | new DocumentCollection { Id = flightCollection }, 69 | new RequestOptions { OfferThroughput = 1000 }); 70 | } 71 | else 72 | { 73 | throw; 74 | } 75 | } 76 | 77 | try 78 | { 79 | await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(databaseName, subscriptionCollection)); 80 | } 81 | catch (DocumentClientException e) 82 | { 83 | if (e.StatusCode == HttpStatusCode.NotFound) 84 | { 85 | await client.CreateDocumentCollectionAsync( 86 | UriFactory.CreateDatabaseUri(databaseName), 87 | new DocumentCollection { Id = subscriptionCollection }, 88 | new RequestOptions { OfferThroughput = 1000 }); 89 | } 90 | else 91 | { 92 | throw; 93 | } 94 | } 95 | } 96 | 97 | #endregion 98 | 99 | #region FlightTeam operations 100 | 101 | public static async Task> GetFlightTeamsAsync() 102 | { 103 | return await GetItemsAsync(flightCollection); 104 | } 105 | 106 | public static async Task> GetFlightTeamsAsync(Expression> predicate) 107 | { 108 | return await GetItemsAsync(predicate, flightCollection); 109 | } 110 | 111 | public static async Task GetFlightTeamAsync(string id) 112 | { 113 | return await GetItemAsync(id, flightCollection); 114 | } 115 | 116 | public static async Task CreateFlightTeamAsync(FlightTeam flightTeam) 117 | { 118 | return await CreateItemAsync(flightTeam, flightCollection); 119 | } 120 | 121 | public static async Task UpdateFlightTeamAsync(string id, FlightTeam flightTeam) 122 | { 123 | return await UpdateItemAsync(id, flightTeam, flightCollection); 124 | } 125 | 126 | public static async Task DeleteFlightTeamAsync(string id) 127 | { 128 | await DeleteItemAsync(id, flightCollection); 129 | } 130 | 131 | #endregion 132 | 133 | #region ListSubscription operations 134 | 135 | public static async Task> GetListSubscriptionsAsync() 136 | { 137 | return await GetItemsAsync(subscriptionCollection); 138 | } 139 | 140 | public static async Task> GetListSubscriptionsAsync(Expression> predicate) 141 | { 142 | return await GetItemsAsync(predicate, subscriptionCollection); 143 | } 144 | 145 | public static async Task GetListSubscriptionAsync(string id) 146 | { 147 | return await GetItemAsync(id, subscriptionCollection); 148 | } 149 | 150 | public static async Task CreateListSubscriptionAsync(ListSubscription subscription) 151 | { 152 | // Check if there is an existing record and don't 153 | // let the create happen if there is. 154 | var existingSubscriptions = await GetListSubscriptionsAsync(s => s.Resource.CompareTo(subscription.Resource) == 0); 155 | if (existingSubscriptions.Count() > 0) 156 | { 157 | throw new InvalidOperationException("A subscription record already exists."); 158 | } 159 | return await CreateItemAsync(subscription, subscriptionCollection); 160 | } 161 | 162 | public static async Task UpdateListSubscriptionAsync(string id, ListSubscription subscription) 163 | { 164 | return await UpdateItemAsync(id, subscription, subscriptionCollection); 165 | } 166 | 167 | public static async Task DeleteListSubscriptionAsync(string id) 168 | { 169 | await DeleteItemAsync(id, subscriptionCollection); 170 | } 171 | 172 | #endregion 173 | 174 | #region Generic operations 175 | 176 | private static async Task> GetItemsAsync(string collection) 177 | { 178 | IDocumentQuery query = client.CreateDocumentQuery( 179 | UriFactory.CreateDocumentCollectionUri(databaseName, collection)) 180 | .AsDocumentQuery(); 181 | 182 | var results = new List(); 183 | while (query.HasMoreResults) 184 | { 185 | results.AddRange(await query.ExecuteNextAsync()); 186 | } 187 | 188 | return results; 189 | } 190 | 191 | private static async Task> GetItemsAsync(Expression> predicate, string collection) 192 | { 193 | IDocumentQuery query = client.CreateDocumentQuery( 194 | UriFactory.CreateDocumentCollectionUri(databaseName, collection)) 195 | .Where(predicate) 196 | .AsDocumentQuery(); 197 | 198 | var results = new List(); 199 | while (query.HasMoreResults) 200 | { 201 | results.AddRange(await query.ExecuteNextAsync()); 202 | } 203 | 204 | return results; 205 | } 206 | 207 | private static async Task GetItemAsync(string id, string collection) 208 | { 209 | try 210 | { 211 | Document document = await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(databaseName, collection, id)); 212 | return (T)(dynamic)document; 213 | } 214 | catch (DocumentClientException e) 215 | { 216 | if (HttpStatusCode.NotFound == e.StatusCode) 217 | { 218 | return default(T); 219 | } 220 | else 221 | { 222 | throw; 223 | } 224 | } 225 | } 226 | 227 | private static async Task CreateItemAsync(T item, string collection) 228 | { 229 | Document document = await client.CreateDocumentAsync( 230 | UriFactory.CreateDocumentCollectionUri(databaseName, collection), 231 | item); 232 | 233 | return (T)(dynamic)document; 234 | } 235 | 236 | private static async Task UpdateItemAsync(string id, T item, string collection) 237 | { 238 | Document document = await client.ReplaceDocumentAsync( 239 | UriFactory.CreateDocumentUri(databaseName, collection, id), item); 240 | 241 | return (T)(dynamic)document; 242 | } 243 | 244 | private static async Task DeleteItemAsync(string id, string collection) 245 | { 246 | try 247 | { 248 | await client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(databaseName, collection, id)); 249 | } 250 | catch (DocumentClientException e) 251 | { 252 | if (e.StatusCode != HttpStatusCode.NotFound) 253 | { 254 | throw; 255 | } 256 | } 257 | } 258 | #endregion 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /ListChanged.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.RegularExpressions; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Azure.WebJobs; 7 | using Microsoft.Azure.WebJobs.Extensions.Http; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.Logging; 10 | using Newtonsoft.Json; 11 | using CreateFlightTeam.Models; 12 | using CreateFlightTeam.DocumentDB; 13 | using CreateFlightTeam.Graph; 14 | using System.Linq; 15 | using CreateFlightTeam.Provisioning; 16 | 17 | namespace CreateFlightTeam 18 | { 19 | [StorageAccount("AzureWebJobsStorage")] 20 | public static class ListChanged 21 | { 22 | private static readonly string NotificationUrl = 23 | string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NgrokProxy")) ? 24 | $"https://{Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")}/api/ListChanged" : 25 | $"{Environment.GetEnvironmentVariable("NgrokProxy")}/api/ListChanged"; 26 | 27 | private static readonly string flightAdminSite = Environment.GetEnvironmentVariable("FlightAdminSite"); 28 | private static readonly string flightList = Environment.GetEnvironmentVariable("FlightList"); 29 | 30 | // This function implements a webhook 31 | // for a Graph subscription 32 | // https://docs.microsoft.com/graph/webhooks 33 | // This is called any time the Flights list is updated in 34 | // SharePoint 35 | [FunctionName("ListChanged")] 36 | public static async Task Run( 37 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 38 | [Queue("syncqueue")] ICollector outputQueueMessage, 39 | ILogger log) 40 | { 41 | // Is this a validation request? 42 | if (req.Query.ContainsKey("validationToken")) 43 | { 44 | var validationToken = req.Query["validationToken"].ToString(); 45 | log.LogInformation($"Validation request - Token : {validationToken}"); 46 | return new OkObjectResult(validationToken); 47 | } 48 | 49 | // Get the notification payload and deserialize 50 | string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); 51 | var request = JsonConvert.DeserializeObject(requestBody); 52 | 53 | // Add the notification to the queue 54 | outputQueueMessage.Add(request); 55 | 56 | // Return 202 57 | return new AcceptedResult(); 58 | } 59 | 60 | // This function triggers on an item being added to the 61 | // queue by the ListChange function. 62 | // It does the processing of the notification 63 | [FunctionName("SyncList")] 64 | [Singleton] 65 | public static async Task SyncList( 66 | [QueueTrigger("syncqueue")] ListChangedRequest request, 67 | ILogger log) 68 | { 69 | log.LogInformation($"Received queue item: {JsonConvert.SerializeObject(request)}"); 70 | 71 | DatabaseHelper.Initialize(); 72 | //AuthProvider.AzureLogger = log; 73 | 74 | // Validate the notification against the subscription 75 | var subscriptions = await DatabaseHelper.GetListSubscriptionsAsync( 76 | s => s.SubscriptionId == request.Changes[0].SubscriptionId); 77 | 78 | if (subscriptions.Count() > 1) 79 | { 80 | log.LogWarning($"There are {subscriptions.Count()} subscriptions in the database."); 81 | } 82 | 83 | var subscription = subscriptions.FirstOrDefault(); 84 | 85 | if (subscription != null) 86 | { 87 | // Verify client state. If no match, no-op 88 | if (request.Changes[0].ClientState == subscription.ClientState) 89 | { 90 | var graphClient = new GraphService(log); 91 | 92 | // Extract driveId from subscription 93 | string driveId = ""; 94 | var match = Regex.Match(subscription.Resource, @"\/drives\/(.*)\/root", RegexOptions.IgnoreCase | RegexOptions.Singleline); 95 | if (match.Success) 96 | { 97 | driveId = match.Groups[1].Value; 98 | } 99 | 100 | // Process changes 101 | var newDeltaLink = await ProcessDelta(graphClient, log, driveId: driveId, deltaLink: subscription.DeltaLink); 102 | 103 | if (!string.IsNullOrEmpty(newDeltaLink)) 104 | { 105 | subscription.DeltaLink = newDeltaLink; 106 | 107 | // Update the subscription in the database with new delta link 108 | await DatabaseHelper.UpdateListSubscriptionAsync(subscription.Id, subscription); 109 | } 110 | } 111 | } 112 | } 113 | 114 | // This function is used to manually seed the flight team database 115 | // It will sync the database with the SharePoint list 116 | // and provision/update/remove any teams as needed 117 | [FunctionName("EnsureDatabase")] 118 | public static async Task EnsureDatabase( 119 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 120 | [Queue("syncqueue")] ICollector outputQueueMessage, 121 | ILogger log) 122 | { 123 | var graphClient = new GraphService(log); 124 | 125 | DatabaseHelper.Initialize(); 126 | 127 | // Get the Flight Admin site 128 | var rootSite = await graphClient.GetSharePointSiteAsync("root"); 129 | var adminSite = await graphClient.GetSharePointSiteAsync( 130 | $"{rootSite.SiteCollection.Hostname}:/sites/{flightAdminSite}"); 131 | 132 | var drive = await graphClient.GetSiteDriveAsync(adminSite.Id, flightList); 133 | 134 | // Is there a subscription in the database? 135 | var subscriptions = await DatabaseHelper.GetListSubscriptionsAsync(s => s.Resource.Equals($"/drives/{drive.Id}/root")); 136 | var subscription = subscriptions.FirstOrDefault(); 137 | 138 | if (subscription == null || subscription.IsExpired()) 139 | { 140 | // Create a subscription 141 | var newSubscription = await graphClient.CreateListSubscription($"/drives/{drive.Id}/root", NotificationUrl); 142 | 143 | if (subscription == null) 144 | { 145 | subscription = await DatabaseHelper.CreateListSubscriptionAsync(new ListSubscription 146 | { 147 | ClientState = newSubscription.ClientState, 148 | Expiration = newSubscription.ExpirationDateTime.GetValueOrDefault().UtcDateTime, 149 | Resource = $"/drives/{drive.Id}/root", 150 | SubscriptionId = newSubscription.Id 151 | }); 152 | } 153 | else 154 | { 155 | subscription.ClientState = newSubscription.ClientState; 156 | subscription.Expiration = newSubscription.ExpirationDateTime.GetValueOrDefault().UtcDateTime; 157 | subscription.SubscriptionId = newSubscription.Id; 158 | 159 | subscription = await DatabaseHelper.UpdateListSubscriptionAsync(subscription.Id, subscription); 160 | } 161 | } 162 | 163 | string deltaLink = string.Empty; 164 | 165 | if (string.IsNullOrEmpty(subscription.DeltaLink)) 166 | { 167 | deltaLink = await ProcessDelta(graphClient, log, driveId: drive.Id); 168 | } 169 | else 170 | { 171 | deltaLink = await ProcessDelta(graphClient, log, driveId: drive.Id, deltaLink: subscription.DeltaLink); 172 | } 173 | 174 | subscription.DeltaLink = deltaLink; 175 | await DatabaseHelper.UpdateListSubscriptionAsync(subscription.Id, subscription); 176 | } 177 | 178 | // This function is used to manually remove all subscriptions 179 | // and optionally clear the team database 180 | [FunctionName("Unsubscribe")] 181 | public static async Task Unsubscribe( 182 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 183 | ILogger log) 184 | { 185 | DatabaseHelper.Initialize(); 186 | 187 | var graphClient = new GraphService(log); 188 | 189 | await graphClient.RemoveAllSubscriptions(); 190 | 191 | var subscriptions = await DatabaseHelper.GetListSubscriptionsAsync(); 192 | foreach(var subscription in subscriptions) 193 | { 194 | await DatabaseHelper.DeleteListSubscriptionAsync(subscription.Id); 195 | } 196 | 197 | if (!string.IsNullOrEmpty(req.Query["deleteTeams"])) 198 | { 199 | var flightGroups = await graphClient.GetAllGroupsAsync("startswith(displayName, 'Flight ')"); 200 | 201 | foreach(var group in flightGroups.CurrentPage) 202 | { 203 | if (group.DisplayName.CompareTo(flightAdminSite) == 0 || 204 | group.DisplayName.CompareTo("Flight Admins") == 0 || 205 | group.DisplayName.CompareTo("Flight Attendants") == 0) 206 | { 207 | log.LogInformation($"Skipping required group: {group.DisplayName}"); 208 | continue; 209 | } 210 | 211 | try 212 | { 213 | await graphClient.DeleteTeamAsync(group.Id); 214 | } 215 | catch (Microsoft.Graph.ServiceException ex) 216 | { 217 | log.LogWarning($"Error deleting team ${group.Id}: {ex.Message}"); 218 | } 219 | 220 | try 221 | { 222 | await graphClient.DeleteGroupAsync(group.Id); 223 | } 224 | catch (Microsoft.Graph.ServiceException ex) 225 | { 226 | log.LogWarning($"Error deleting group ${group.Id}: {ex.Message}"); 227 | } 228 | } 229 | 230 | var teams = await DatabaseHelper.GetFlightTeamsAsync(); 231 | foreach (var team in teams) 232 | { 233 | await DatabaseHelper.DeleteFlightTeamAsync(team.Id); 234 | } 235 | } 236 | } 237 | 238 | // This function is triggered by a timer to check 239 | // the subscription to the SharePoint list. If subscription is 240 | // expired or close to expiring, renew the subscription. 241 | [FunctionName("CheckGraphSubscription")] 242 | public static async Task CheckGraphSubscription([TimerTrigger("0 0 9,21 * * *")] TimerInfo timer, ILogger log) 243 | { 244 | DatabaseHelper.Initialize(); 245 | 246 | var graphClient = new GraphService(log); 247 | 248 | // Are there subscriptions in the database? 249 | var subscriptions = await DatabaseHelper.GetListSubscriptionsAsync(); 250 | foreach (var subscription in subscriptions) 251 | { 252 | if (subscription.IsExpiredOrCloseToExpired()) 253 | { 254 | try 255 | { 256 | var updatedSubscription = await graphClient.RenewListSubscription(subscription.SubscriptionId); 257 | 258 | subscription.Expiration = updatedSubscription.ExpirationDateTime.GetValueOrDefault().UtcDateTime; 259 | 260 | // Update the database 261 | await DatabaseHelper.UpdateListSubscriptionAsync(subscription.Id, subscription); 262 | continue; 263 | } 264 | catch (Microsoft.Graph.ServiceException) 265 | { 266 | log.LogInformation($"Renewing subscription with id {subscription.SubscriptionId} failed"); 267 | } 268 | 269 | // If renew failed, create a new subscription 270 | var newSubscription = await graphClient.CreateListSubscription(subscription.Resource, NotificationUrl); 271 | subscription.ClientState = newSubscription.ClientState; 272 | subscription.Expiration = newSubscription.ExpirationDateTime.GetValueOrDefault().UtcDateTime; 273 | subscription.SubscriptionId = newSubscription.Id; 274 | 275 | // Update the database 276 | await DatabaseHelper.UpdateListSubscriptionAsync(subscription.Id, subscription); 277 | } 278 | } 279 | } 280 | 281 | private static async Task ProcessDelta(GraphService graphClient, ILogger log, string driveId = null, string deltaLink = null) 282 | { 283 | string deltaRequestUrl = deltaLink; 284 | 285 | TeamProvisioning.Initialize(graphClient, log); 286 | 287 | Microsoft.Graph.IDriveItemDeltaCollectionPage delta = null; 288 | 289 | try 290 | { 291 | delta = await graphClient.GetListDelta(driveId, deltaRequestUrl); 292 | } 293 | catch (Microsoft.Graph.ServiceException ex) 294 | { 295 | if (ex.Error.Code == "resyncRequired") 296 | { 297 | delta = await graphClient.GetListDelta(driveId, null); 298 | } 299 | } 300 | 301 | foreach(var item in delta.CurrentPage) 302 | { 303 | await ProcessDriveItem(graphClient, item); 304 | } 305 | 306 | while(delta.NextPageRequest != null) 307 | { 308 | // There are more pages of results 309 | delta = await delta.NextPageRequest.GetAsync(); 310 | 311 | foreach(var item in delta.CurrentPage) 312 | { 313 | await ProcessDriveItem(graphClient, item); 314 | } 315 | } 316 | 317 | // Get the delta link 318 | object newDeltaLink; 319 | delta.AdditionalData.TryGetValue("@odata.deltaLink", out newDeltaLink); 320 | 321 | return newDeltaLink.ToString(); 322 | } 323 | 324 | private static async Task ProcessDriveItem(GraphService graphClient, Microsoft.Graph.DriveItem item) 325 | { 326 | if (item.File != null) 327 | { 328 | // Query the database 329 | var teams = await DatabaseHelper.GetFlightTeamsAsync(f => f.SharePointListItemId.Equals(item.Id)); 330 | var team = teams.FirstOrDefault(); 331 | 332 | if (item.Deleted != null && team != null) 333 | { 334 | // Remove the team 335 | await TeamProvisioning.ArchiveTeamAsync(team); 336 | 337 | // Remove the database item 338 | await DatabaseHelper.DeleteFlightTeamAsync(team.Id); 339 | 340 | return; 341 | } 342 | 343 | // Get the file's list data 344 | var listItem = await graphClient.GetDriveItemListItem(item.ParentReference.DriveId, item.Id); 345 | if (listItem == null) return; 346 | 347 | if (team == null) 348 | { 349 | team = FlightTeam.FromListItem(item.Id, listItem); 350 | if (team == null) 351 | { 352 | // Item was added to list but required metadata 353 | // isn't filled in yet. No-op. 354 | return; 355 | } 356 | 357 | // New item, provision team 358 | team.TeamId = await TeamProvisioning.ProvisionTeamAsync(team); 359 | 360 | await DatabaseHelper.CreateFlightTeamAsync(team); 361 | } 362 | else 363 | { 364 | var updatedTeam = FlightTeam.FromListItem(item.Id, listItem); 365 | updatedTeam.TeamId = team.TeamId; 366 | 367 | // Existing item, process changes 368 | await TeamProvisioning.UpdateTeamAsync(team, updatedTeam); 369 | updatedTeam.Id = team.Id; 370 | await DatabaseHelper.UpdateFlightTeamAsync(team.Id, updatedTeam); 371 | } 372 | } 373 | } 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /Graph/GraphService.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE.txt in the project root for license information. 2 | using Microsoft.Azure.WebJobs.Host; 3 | using Microsoft.Extensions.Logging; 4 | using Newtonsoft.Json; 5 | using Newtonsoft.Json.Serialization; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Net.Http; 9 | using System.Net.Http.Headers; 10 | using System.Text; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using Microsoft.Graph; 14 | using Microsoft.Graph.Auth; 15 | using Microsoft.Identity.Client; 16 | 17 | namespace CreateFlightTeam.Graph 18 | { 19 | public class GraphService 20 | { 21 | private static readonly string graphEndpoint = "https://graph.microsoft.com/"; 22 | 23 | private GraphServiceClient graphClient = null; 24 | 25 | private ILogger logger = null; 26 | 27 | public GraphService(ILogger log) 28 | { 29 | var clientCredential = new ClientCredential(Environment.GetEnvironmentVariable("AppSecret")); 30 | var authClient = ClientCredentialProvider.CreateClientApplication( 31 | Environment.GetEnvironmentVariable("AppId"), clientCredential, null, 32 | Environment.GetEnvironmentVariable("TenantId")); 33 | authClient.RedirectUri = Environment.GetEnvironmentVariable("RedirectUri"); 34 | var authProvider = new ClientCredentialProvider(authClient); 35 | 36 | graphClient = new GraphServiceClient(authProvider); 37 | 38 | logger = log; 39 | } 40 | 41 | #region User operations 42 | 43 | public async Task> GetUserIds(List pilots, List flightAttendants, bool fullyQualified = false) 44 | { 45 | var userIds = new List(); 46 | 47 | // Look up each user to get their Id property 48 | foreach(var pilot in pilots) 49 | { 50 | var user = await GetUserByUpn(pilot); 51 | userIds.Add(fullyQualified ? $"{graphEndpoint}beta/users/{user.Id}" : user.Id); 52 | } 53 | 54 | foreach(var flightAttendant in flightAttendants) 55 | { 56 | var user = await GetUserByUpn(flightAttendant); 57 | userIds.Add(fullyQualified ? $"{graphEndpoint}beta/users/{user.Id}" : user.Id); 58 | } 59 | 60 | return userIds; 61 | } 62 | 63 | public async Task GetUserByUpn(string upn) 64 | { 65 | try { 66 | var user = await graphClient.Users[upn].Request().GetAsync(); 67 | return user; 68 | } 69 | catch (Exception ex) { 70 | logger.LogError($"Graph exception: {ex.Message}"); 71 | return null; 72 | } 73 | } 74 | 75 | public async Task GetUserByEmail(string email) 76 | { 77 | var results = await graphClient.Users.Request().Filter($"mail eq '{email}'").GetAsync(); 78 | return results.CurrentPage[0]; 79 | } 80 | 81 | #endregion 82 | 83 | #region Group operations 84 | 85 | public async Task CreateGroupAsync(Group group) 86 | { 87 | return await graphClient.Groups.Request().AddAsync(group); 88 | } 89 | 90 | public async Task GetAllGroupsAsync(string filter = null) 91 | { 92 | var groupsRequest = graphClient.Groups.Request().Top(50); 93 | 94 | if (!string.IsNullOrEmpty(filter)) 95 | { 96 | groupsRequest = groupsRequest.Filter(filter); 97 | } 98 | 99 | return await groupsRequest.GetAsync(); 100 | } 101 | 102 | public async Task DeleteGroupAsync(string groupId) 103 | { 104 | await graphClient.Groups[groupId].Request().DeleteAsync(); 105 | } 106 | 107 | public async Task AddMemberAsync(string groupId, string userId, bool isOwner = false) 108 | { 109 | var user = new DirectoryObject { Id = userId }; 110 | 111 | try 112 | { 113 | await graphClient.Groups[groupId].Members.References.Request().AddAsync(user); 114 | } 115 | catch (Exception ex) 116 | { 117 | logger.LogWarning($"Add member returned an error: {ex.Message}"); 118 | } 119 | 120 | if (isOwner) 121 | { 122 | try 123 | { 124 | await graphClient.Groups[groupId].Owners.References.Request().AddAsync(user); 125 | } 126 | catch (Exception ex) 127 | { 128 | logger.LogWarning($"Add owner returned an error: {ex.Message}"); 129 | } 130 | } 131 | } 132 | 133 | public async Task RemoveMemberAsync(string groupId, string userId, bool isOwner = false) 134 | { 135 | if (isOwner) 136 | { 137 | try 138 | { 139 | await graphClient.Groups[groupId].Owners[userId].Reference.Request().DeleteAsync(); 140 | } 141 | catch (Exception ex) 142 | { 143 | logger.LogWarning($"Remove owner returned an error: {ex.Message}"); 144 | } 145 | } 146 | 147 | try 148 | { 149 | await graphClient.Groups[groupId].Members[userId].Reference.Request().DeleteAsync(); 150 | } 151 | catch (Exception ex) 152 | { 153 | logger.LogWarning($"Remove member returned an error: {ex.Message}"); 154 | } 155 | } 156 | 157 | public async Task GetTeamSiteAsync(string groupId) 158 | { 159 | return await graphClient.Groups[groupId].Sites["root"].Request().GetAsync(); 160 | } 161 | 162 | #endregion 163 | 164 | #region Invitation manager operations 165 | 166 | public async Task CreateGuestInvitationAsync(Invitation invite) 167 | { 168 | return await graphClient.Invitations.Request().AddAsync(invite); 169 | } 170 | 171 | #endregion 172 | 173 | #region Teams operations 174 | 175 | public async Task CreateTeamAsync(string groupId, Team team) 176 | { 177 | var response = await graphClient.Groups[groupId].Team.Request().PutAsync(team); 178 | } 179 | 180 | public async Task ArchiveTeamAsync(string teamId) 181 | { 182 | await graphClient.Teams[teamId].Archive().Request().PostAsync(); 183 | } 184 | 185 | public async Task DeleteTeamAsync(string teamId) 186 | { 187 | await graphClient.Teams[teamId].Request().DeleteAsync(); 188 | } 189 | 190 | public async Task GetTeamChannelsAsync(string teamId) 191 | { 192 | return await graphClient.Teams[teamId].Channels.Request().GetAsync(); 193 | } 194 | 195 | public async Task CreateTeamChannelAsync(string teamId, Channel channel) 196 | { 197 | return await graphClient.Teams[teamId].Channels.Request().AddAsync(channel); 198 | } 199 | 200 | public async Task AddAppToTeam(string teamId, TeamsAppInstallation app) 201 | { 202 | var response = await graphClient.Teams[teamId].InstalledApps.Request().AddAsync(app); 203 | } 204 | 205 | public async Task AddTeamChannelTab(string teamId, string channelId, TeamsTab tab) 206 | { 207 | await graphClient.Teams[teamId].Channels[channelId].Tabs.Request().AddAsync(tab); 208 | } 209 | 210 | #endregion 211 | 212 | #region SharePoint site operations 213 | 214 | public async Task GetSharePointSiteAsync(string sitePath) 215 | { 216 | return await graphClient.Sites[sitePath].Request().GetAsync(); 217 | } 218 | 219 | public async Task GetSiteDriveAsync(string siteId, string driveName) 220 | { 221 | var drives = await graphClient.Sites[siteId].Drives.Request() 222 | .Top(50).GetAsync(); 223 | 224 | foreach (var drive in drives.CurrentPage) 225 | { 226 | if (drive.Name == driveName) 227 | { 228 | return drive; 229 | } 230 | } 231 | 232 | return null; 233 | } 234 | 235 | public async Task CreateSharePointListAsync(string siteId, List list) 236 | { 237 | return await graphClient.Sites[siteId].Lists.Request().AddAsync(list); 238 | } 239 | 240 | public async Task GetSiteListsAsync(string siteId) 241 | { 242 | return await graphClient.Sites[siteId].Lists.Request().GetAsync(); 243 | } 244 | 245 | public async Task CreateSharePointPageAsync(string siteId, SitePage page) 246 | { 247 | return await graphClient.Sites[siteId].Pages.Request().AddAsync(page); 248 | } 249 | 250 | public async Task PublishSharePointPageAsync(string siteId, string pageId) 251 | { 252 | await graphClient.Sites[siteId].Pages[pageId].Publish().Request().PostAsync(); 253 | } 254 | 255 | #endregion 256 | 257 | #region Planner operations 258 | 259 | public async Task CreatePlanAsync(PlannerPlan plan) 260 | { 261 | return await graphClient.Planner.Plans.Request().AddAsync(plan); 262 | } 263 | 264 | public async Task CreateBucketAsync(PlannerBucket bucket) 265 | { 266 | return await graphClient.Planner.Buckets.Request().AddAsync(bucket); 267 | } 268 | 269 | public async Task CreatePlannerTaskAsync(PlannerTask task) 270 | { 271 | return await graphClient.Planner.Tasks.Request().AddAsync(task); 272 | } 273 | 274 | #endregion 275 | 276 | #region Subscription operations 277 | 278 | public async Task CreateListSubscription(string listUrl, string notificationUrl) 279 | { 280 | var newSubscription = new Subscription 281 | { 282 | ClientState = Guid.NewGuid().ToString(), 283 | Resource = listUrl, 284 | ChangeType = "updated", 285 | ExpirationDateTime = DateTime.UtcNow.AddDays(2), 286 | NotificationUrl = notificationUrl 287 | }; 288 | 289 | return await graphClient.Subscriptions.Request().AddAsync(newSubscription); 290 | } 291 | 292 | public async Task RemoveAllSubscriptions() 293 | { 294 | var subscriptions = await graphClient.Subscriptions.Request().GetAsync(); 295 | 296 | foreach (var subscription in subscriptions.CurrentPage) 297 | { 298 | await graphClient.Subscriptions[subscription.Id].Request().DeleteAsync(); 299 | } 300 | } 301 | 302 | public async Task RenewListSubscription(string subscriptionId) 303 | { 304 | var updateSubscription = new Subscription 305 | { 306 | ExpirationDateTime = DateTime.UtcNow.AddDays(2) 307 | }; 308 | 309 | return await graphClient.Subscriptions[subscriptionId].Request() 310 | .UpdateAsync(updateSubscription); 311 | } 312 | 313 | #endregion 314 | 315 | #region OneDrive operations 316 | public async Task GetListDelta(string driveId, string deltaRequestUrl) 317 | { 318 | IDriveItemDeltaCollectionPage changes = null; 319 | if (string.IsNullOrEmpty(deltaRequestUrl)) 320 | { 321 | if (string.IsNullOrEmpty(driveId)) 322 | { 323 | logger.LogError("GetListDelta: You must provide either a driveId or deltaRequestUrl"); 324 | return null; 325 | } 326 | 327 | // New delta request 328 | changes = await graphClient.Drives[driveId].Root.Delta().Request().GetAsync(); 329 | } 330 | else 331 | { 332 | changes = new DriveItemDeltaCollectionPage(); 333 | changes.InitializeNextPageRequest(graphClient, deltaRequestUrl); 334 | changes = await changes.NextPageRequest.GetAsync(); 335 | } 336 | 337 | return changes; 338 | } 339 | 340 | public async Task GetDriveItemListItem(string driveId, string itemId) 341 | { 342 | try 343 | { 344 | return await graphClient.Drives[driveId].Items[itemId].ListItem.Request().GetAsync(); 345 | } 346 | catch (Exception) 347 | { 348 | // When document is first created and no fields are filled in, this call 349 | // fails with a NotFound error 350 | return null; 351 | } 352 | } 353 | 354 | #endregion 355 | 356 | #region Cross-device notification operations 357 | 358 | public async Task SendUserNotification(string userId, string title, string message) 359 | { 360 | // Check for a user token for this user ID 361 | // If we do not have one, it may be because the user has not 362 | // ever used the mobile app. In this case, do nothing 363 | var token = await AuthProvider.GetUserToken(userId); 364 | if (string.IsNullOrEmpty(token)) { 365 | return; 366 | } 367 | 368 | var notifGraphClient = new GraphServiceClient( 369 | new DelegateAuthenticationProvider( 370 | async(requestMessage) => { 371 | var userToken = await AuthProvider.GetUserToken(userId); 372 | requestMessage.Headers.Authorization = 373 | new AuthenticationHeaderValue("Bearer", userToken); 374 | } 375 | ) 376 | ); 377 | 378 | var notification = new Notification 379 | { 380 | TargetHostName = Environment.GetEnvironmentVariable("NotificationHostName"), 381 | ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(2), 382 | DisplayTimeToLive = 30, 383 | Priority = Priority.High, 384 | GroupName = "FlightChanges", 385 | TargetPolicy = new TargetPolicyEndpoints 386 | { 387 | PlatformTypes = new string[] { "ios" } 388 | }, 389 | Payload = new PayloadTypes 390 | { 391 | VisualContent = new VisualProperties 392 | { 393 | Title = title, 394 | Body = message 395 | } 396 | }, 397 | AdditionalData = new Dictionary() 398 | }; 399 | 400 | notification.AdditionalData.Add("appNotificationId", Guid.NewGuid().ToString()); 401 | 402 | try 403 | { 404 | await notifGraphClient.Me.Notifications.Request().AddAsync(notification); 405 | } 406 | catch (ServiceException ex) 407 | { 408 | logger.LogWarning($"Error sending notification to {userId}: {ex.Message}"); 409 | } 410 | } 411 | 412 | #endregion 413 | 414 | #region Calendar operations 415 | 416 | public async Task CreateEventInUserCalendar(string userId, Event newEvent) 417 | { 418 | return await graphClient.Users[userId].Events.Request().AddAsync(newEvent); 419 | } 420 | 421 | public async Task GetEventsInUserCalendar(string userId, string filter = null) 422 | { 423 | var eventRequest = graphClient.Users[userId].Events.Request() 424 | .Expand("extensions($filter=id eq 'com.contoso.flightData')") 425 | .Select("subject,location,start,end,categories,extensions"); 426 | 427 | if (!string.IsNullOrEmpty(filter)) 428 | { 429 | eventRequest = eventRequest.Filter(filter); 430 | } 431 | 432 | return await eventRequest.GetAsync(); 433 | } 434 | 435 | public async Task UpdateEventInUserCalendar(string userId, Event updateEvent) 436 | { 437 | return await graphClient.Users[userId].Events[updateEvent.Id].Request().UpdateAsync(updateEvent); 438 | } 439 | 440 | public async Task UpdateFlightExtension(string userId, string eventId, OpenTypeExtension flightExtension) 441 | { 442 | await graphClient.Users[userId].Events[eventId].Extensions["com.contoso.flightData"].Request().UpdateAsync(flightExtension); 443 | } 444 | 445 | public async Task DeleteEventInUserCalendar(string userId, string eventId) 446 | { 447 | await graphClient.Users[userId].Events[eventId].Request().DeleteAsync(); 448 | } 449 | 450 | #endregion 451 | 452 | #region Unused code 453 | /* 454 | public async Task CreateChatMessageAsync(string teamId, string channelId, ChatMessage message) 455 | { 456 | var response = await MakeGraphCall(HttpMethod.Post, $"/teams/{teamId}/channels/{channelId}/messages", message); 457 | } 458 | 459 | public async Task GetOneDriveItemAsync(string siteId, string itemPath) 460 | { 461 | var response = await MakeGraphCall(HttpMethod.Get, $"/sites/{siteId}/drive/{itemPath}"); 462 | return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); 463 | } 464 | 465 | public async Task GetTeamOneDriveFolderAsync(string teamId, string folderName) 466 | { 467 | // Retry this call twice if it fails 468 | // There seems to be a delay between creating a Team and the drives being 469 | // fully created/enabled 470 | var response = await MakeGraphCall(HttpMethod.Get, $"/groups/{teamId}/drive/root:/{folderName}", retries: 3); 471 | return JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); 472 | } 473 | 474 | public async Task CopySharePointFileAsync(string siteId, string itemId, ItemReference target) 475 | { 476 | var copyPayload = new DriveItem 477 | { 478 | ParentReference = target 479 | }; 480 | 481 | var response = await MakeGraphCall(HttpMethod.Post, 482 | $"/sites/{siteId}/drive/items/{itemId}/copy", 483 | copyPayload); 484 | } 485 | 486 | public async Task AddOpenExtensionToGroupAsync(string groupId, ProvisioningExtension extension) 487 | { 488 | var response = await MakeGraphCall(HttpMethod.Post, $"/groups/{groupId}/extensions", extension); 489 | } 490 | 491 | public async Task GetSharePointListAsync(string siteId, string listName) 492 | { 493 | var response = await MakeGraphCall(HttpMethod.Get, $"/sites/{siteId}/lists?$top=50"); 494 | var lists = JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); 495 | foreach(var list in lists.Value) 496 | { 497 | if (list.DisplayName == listName) 498 | { 499 | return list; 500 | } 501 | } 502 | 503 | return null; 504 | } 505 | 506 | public async Task> FindGroupsBySharePointItemIdAsync(int itemId) 507 | { 508 | var response = await MakeGraphCall(HttpMethod.Get, $"/groups?$filter={Group.SchemaExtensionName}/sharePointItemId eq {itemId}"); 509 | return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); 510 | } 511 | 512 | public async Task> GetGroupMembersAsync(string groupId) 513 | { 514 | var response = await MakeGraphCall(HttpMethod.Get, $"/groups/{groupId}/members"); 515 | return JsonConvert.DeserializeObject>(await response.Content.ReadAsStringAsync()); 516 | } 517 | 518 | private async Task MakeGraphCall(HttpMethod method, string uri, object body = null, int retries = 0, string version = "beta") 519 | { 520 | // Initialize retry delay to 3 secs 521 | int retryDelay = 3; 522 | 523 | string payload = string.Empty; 524 | 525 | if (body != null && (method != HttpMethod.Get || method != HttpMethod.Delete)) 526 | { 527 | // Serialize the body 528 | payload = JsonConvert.SerializeObject(body, jsonSettings); 529 | } 530 | 531 | if (logger != null) 532 | { 533 | logger.LogInformation($"MakeGraphCall Request: {method} {uri}"); 534 | logger.LogInformation($"MakeGraphCall Payload: {payload}"); 535 | } 536 | 537 | do 538 | { 539 | var requestUrl = uri.StartsWith("https") ? uri : $"{graphEndpoint}{version}{uri}"; 540 | // Create the request 541 | var request = new HttpRequestMessage(method, requestUrl); 542 | 543 | 544 | if (!string.IsNullOrEmpty(payload)) 545 | { 546 | request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); 547 | } 548 | 549 | // Send the request 550 | var response = await httpClient.SendAsync(request); 551 | 552 | if (!response.IsSuccessStatusCode) 553 | { 554 | if (logger != null) 555 | logger.LogInformation($"MakeGraphCall Error: {response.StatusCode}"); 556 | if (retries > 0) 557 | { 558 | if (logger != null) 559 | logger.LogInformation($"MakeGraphCall Retrying after {retryDelay} seconds...({retries} retries remaining)"); 560 | Thread.Sleep(retryDelay * 1000); 561 | // Double the retry delay for subsequent retries 562 | retryDelay += retryDelay; 563 | } 564 | else 565 | { 566 | // No more retries, throw error 567 | var error = await response.Content.ReadAsStringAsync(); 568 | throw new Exception(error); 569 | } 570 | } 571 | else 572 | { 573 | return response; 574 | } 575 | } 576 | while (retries-- > 0); 577 | 578 | return null; 579 | } 580 | */ 581 | #endregion 582 | } 583 | } 584 | -------------------------------------------------------------------------------- /Provisioning/TeamProvisioning.cs: -------------------------------------------------------------------------------- 1 | using CreateFlightTeam.Graph; 2 | using CreateFlightTeam.Models; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.Graph; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | 10 | namespace CreateFlightTeam.Provisioning 11 | { 12 | public static class TeamProvisioning 13 | { 14 | private static readonly string teamAppId = Environment.GetEnvironmentVariable("TeamAppToInstall"); 15 | private static readonly string webPartId = Environment.GetEnvironmentVariable("WebPartId"); 16 | 17 | private static GraphService graphClient; 18 | private static ILogger logger; 19 | 20 | public static void Initialize(GraphService client, ILogger log) 21 | { 22 | graphClient = client; 23 | logger = log; 24 | } 25 | 26 | public static async Task ProvisionTeamAsync(FlightTeam flightTeam) 27 | { 28 | // Create the unified group 29 | var group = await CreateUnifiedGroupAsync(flightTeam); 30 | 31 | // Create the team in the group 32 | var teamChannel = await InitializeTeamInGroupAsync(group.Id, 33 | $"Welcome to Flight {flightTeam.FlightNumber}!"); 34 | 35 | // Create Planner plan and tasks 36 | // TODO: Disabled because you cannot create planner plans with app-only token 37 | // await CreatePreflightPlanAsync(group.Id, teamChannel.Id, flightTeam.DepartureTime); 38 | 39 | // Create SharePoint list 40 | await CreateChallengingPassengersListAsync(group.Id, teamChannel.Id); 41 | 42 | // Create SharePoint page 43 | await CreateSharePointPageAsync(group.Id, teamChannel.Id, flightTeam.FlightNumber); 44 | 45 | await AddFlightToCalendars(flightTeam); 46 | 47 | return group.Id; 48 | } 49 | 50 | public static async Task UpdateTeamAsync(FlightTeam originalTeam, FlightTeam updatedTeam) 51 | { 52 | // Look for changes that require an update via Graph 53 | // Did the admin change? 54 | var admin = await graphClient.GetUserByUpn(updatedTeam.Admin); 55 | updatedTeam.Admin = admin.Id; 56 | if (!admin.Id.Equals(originalTeam.Admin)) 57 | { 58 | // Add new owner 59 | await graphClient.AddMemberAsync(originalTeam.TeamId, admin.Id, true); 60 | // Remove old owner 61 | await graphClient.RemoveMemberAsync(originalTeam.TeamId, admin.Id, true); 62 | } 63 | 64 | bool isCrewChanged = false; 65 | 66 | // Add new pilots 67 | var newPilots = updatedTeam.Pilots.Except(originalTeam.Pilots); 68 | foreach (var pilot in newPilots) 69 | { 70 | isCrewChanged = true; 71 | var pilotUser = await graphClient.GetUserByUpn(pilot); 72 | await graphClient.AddMemberAsync(originalTeam.TeamId, pilotUser.Id); 73 | } 74 | 75 | if (newPilots.Count() > 0) 76 | { 77 | await TeamProvisioning.AddFlightToCalendars(updatedTeam, newPilots.ToList()); 78 | } 79 | 80 | // Remove any removed pilots 81 | var removedPilots = originalTeam.Pilots.Except(updatedTeam.Pilots); 82 | foreach (var pilot in removedPilots) 83 | { 84 | isCrewChanged = true; 85 | var pilotUser = await graphClient.GetUserByUpn(pilot); 86 | await graphClient.RemoveMemberAsync(originalTeam.TeamId, pilotUser.Id); 87 | } 88 | 89 | if (removedPilots.Count() > 0) 90 | { 91 | await TeamProvisioning.RemoveFlightFromCalendars(removedPilots.ToList(), updatedTeam.FlightNumber); 92 | } 93 | 94 | // Add new flight attendants 95 | var newFlightAttendants = updatedTeam.FlightAttendants.Except(originalTeam.FlightAttendants); 96 | foreach (var attendant in newFlightAttendants) 97 | { 98 | isCrewChanged = true; 99 | var attendantUser = await graphClient.GetUserByUpn(attendant); 100 | await graphClient.AddMemberAsync(originalTeam.TeamId, attendantUser.Id); 101 | } 102 | 103 | if (newFlightAttendants.Count() > 0) 104 | { 105 | await TeamProvisioning.AddFlightToCalendars(updatedTeam, newFlightAttendants.ToList()); 106 | } 107 | 108 | // Remove any removed flight attendants 109 | var removedFlightAttendants = originalTeam.FlightAttendants.Except(updatedTeam.FlightAttendants); 110 | foreach (var attendant in removedFlightAttendants) 111 | { 112 | isCrewChanged = true; 113 | var attendantUser = await graphClient.GetUserByUpn(attendant); 114 | await graphClient.RemoveMemberAsync(originalTeam.TeamId, attendantUser.Id); 115 | } 116 | 117 | if (removedFlightAttendants.Count() > 0) 118 | { 119 | await TeamProvisioning.RemoveFlightFromCalendars(removedFlightAttendants.ToList(), updatedTeam.FlightNumber); 120 | } 121 | 122 | // Swap out catering liaison if needed 123 | if (updatedTeam.CateringLiaison != null && 124 | !updatedTeam.CateringLiaison.Equals(originalTeam.CateringLiaison)) 125 | { 126 | var oldCateringLiaison = await graphClient.GetUserByEmail(originalTeam.CateringLiaison); 127 | await graphClient.RemoveMemberAsync(originalTeam.TeamId, oldCateringLiaison.Id); 128 | await AddGuestUser(originalTeam.TeamId, updatedTeam.CateringLiaison); 129 | } 130 | 131 | // Check for changes to gate, time 132 | bool isGateChanged = updatedTeam.DepartureGate != originalTeam.DepartureGate; 133 | bool isDepartureTimeChanged = updatedTeam.DepartureTime != originalTeam.DepartureTime; 134 | 135 | List crew = null; 136 | string newGate = null; 137 | 138 | if (isCrewChanged || isGateChanged || isDepartureTimeChanged) 139 | { 140 | crew = await graphClient.GetUserIds(updatedTeam.Pilots, updatedTeam.FlightAttendants); 141 | newGate = isGateChanged ? updatedTeam.DepartureGate : null; 142 | 143 | logger.LogInformation("Updating flight in crew members' calendars"); 144 | 145 | if (isDepartureTimeChanged) 146 | { 147 | await TeamProvisioning.UpdateFlightInCalendars(crew, updatedTeam.FlightNumber, updatedTeam.DepartureGate, updatedTeam.DepartureTime); 148 | } 149 | else 150 | { 151 | await TeamProvisioning.UpdateFlightInCalendars(crew, updatedTeam.FlightNumber, updatedTeam.DepartureGate); 152 | } 153 | } 154 | 155 | if (isGateChanged || isDepartureTimeChanged) 156 | { 157 | var localTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); 158 | string newDepartureTime = isDepartureTimeChanged ? TimeZoneInfo.ConvertTime(updatedTeam.DepartureTime, localTimeZone).ToString("g") : null; 159 | 160 | logger.LogInformation("Sending notification to crew members' devices"); 161 | await TeamProvisioning.SendDeviceNotifications(crew, updatedTeam.FlightNumber, 162 | newGate, newDepartureTime); 163 | } 164 | } 165 | 166 | public static async Task ArchiveTeamAsync(FlightTeam team) 167 | { 168 | var crew = await graphClient.GetUserIds(team.Pilots, team.FlightAttendants); 169 | 170 | // Remove event from crew calendars 171 | await TeamProvisioning.RemoveFlightFromCalendars(crew, team.FlightNumber); 172 | // Archive team 173 | try 174 | { 175 | await graphClient.ArchiveTeamAsync(team.TeamId); 176 | } 177 | catch (ServiceException ex) 178 | { 179 | logger.LogInformation($"Attempt to archive team failed: {ex.Message}"); 180 | } 181 | } 182 | 183 | private static async Task CreateUnifiedGroupAsync(FlightTeam flightTeam) 184 | { 185 | // Initialize members list with pilots and flight attendants 186 | var members = await graphClient.GetUserIds(flightTeam.Pilots, flightTeam.FlightAttendants, true); 187 | 188 | // Add admin and me as members 189 | members.Add($"https://graph.microsoft.com/beta/users/{flightTeam.Admin}"); 190 | 191 | // Create owner list 192 | var owners = new List() { $"https://graph.microsoft.com/beta/users/{flightTeam.Admin}" }; 193 | 194 | // Create the group 195 | var flightGroup = new Group 196 | { 197 | DisplayName = $"Flight {flightTeam.FlightNumber}", 198 | Description = flightTeam.Description, 199 | Visibility = "Private", 200 | MailEnabled = true, 201 | MailNickname = $"flight{flightTeam.FlightNumber}{GetTimestamp()}", 202 | GroupTypes = new string[] { "Unified" }, 203 | SecurityEnabled = false, 204 | AdditionalData = new Dictionary() 205 | }; 206 | 207 | flightGroup.AdditionalData.Add("members@odata.bind", members); 208 | flightGroup.AdditionalData.Add("owners@odata.bind", owners); 209 | 210 | var createdGroup = await graphClient.CreateGroupAsync(flightGroup); 211 | logger.LogInformation("Created group"); 212 | 213 | if (!string.IsNullOrEmpty(flightTeam.CateringLiaison)) 214 | { 215 | await AddGuestUser(createdGroup.Id, flightTeam.CateringLiaison); 216 | } 217 | 218 | return createdGroup; 219 | } 220 | 221 | private static async Task AddGuestUser(string groupId, string email) 222 | { 223 | // Add catering liaison as a guest 224 | var guestInvite = new Invitation 225 | { 226 | InvitedUserEmailAddress = email, 227 | InviteRedirectUrl = "https://teams.microsoft.com", 228 | SendInvitationMessage = true 229 | }; 230 | 231 | var createdInvite = await graphClient.CreateGuestInvitationAsync(guestInvite); 232 | 233 | // Add guest user to team 234 | await graphClient.AddMemberAsync(groupId, createdInvite.InvitedUser.Id); 235 | logger.LogInformation("Added guest user"); 236 | } 237 | 238 | private static async Task InitializeTeamInGroupAsync(string groupId, string welcomeMessage) 239 | { 240 | // Create the team 241 | var team = new Team 242 | { 243 | GuestSettings = new TeamGuestSettings 244 | { 245 | AllowCreateUpdateChannels = false, 246 | AllowDeleteChannels = false 247 | } 248 | }; 249 | 250 | await graphClient.CreateTeamAsync(groupId, team); 251 | logger.LogInformation("Created team"); 252 | 253 | // Get channels 254 | var channels = await graphClient.GetTeamChannelsAsync(groupId); 255 | 256 | // Get "General" channel. Since it is created by default and is the only 257 | // channel after creation, just get the first result. 258 | var generalChannel = channels.CurrentPage.First(); 259 | 260 | //// Create welcome message (new thread) 261 | //var welcomeThread = new ChatThread 262 | //{ 263 | // RootMessage = new ChatMessage 264 | // { 265 | // Body = new ItemBody { Content = welcomeMessage } 266 | // } 267 | //}; 268 | 269 | //await graphClient.CreateChatThreadAsync(groupId, generalChannel.Id, welcomeThread); 270 | //logger.LogInformation("Posted welcome message"); 271 | 272 | // Provision pilot channel 273 | var pilotChannel = new Channel 274 | { 275 | DisplayName = "Pilots", 276 | Description = "Discussion about flightpath, weather, etc." 277 | }; 278 | 279 | await graphClient.CreateTeamChannelAsync(groupId, pilotChannel); 280 | logger.LogInformation("Created Pilots channel"); 281 | 282 | // Provision flight attendants channel 283 | var flightAttendantsChannel = new Channel 284 | { 285 | DisplayName = "Flight Attendants", 286 | Description = "Discussion about duty assignments, etc." 287 | }; 288 | 289 | await graphClient.CreateTeamChannelAsync(groupId, flightAttendantsChannel); 290 | logger.LogInformation("Created FA channel"); 291 | 292 | // Add the requested team app 293 | if (!string.IsNullOrEmpty(teamAppId)) 294 | { 295 | var teamsApp = new TeamsAppInstallation 296 | { 297 | AdditionalData = new Dictionary() 298 | }; 299 | 300 | teamsApp.AdditionalData.Add("teamsApp@odata.bind", 301 | $"https://graph.microsoft.com/beta/appCatalogs/teamsApps/{teamAppId}"); 302 | 303 | await graphClient.AddAppToTeam(groupId, teamsApp); 304 | } 305 | logger.LogInformation("Added app to team"); 306 | 307 | // Return the general channel 308 | return generalChannel; 309 | } 310 | 311 | private static async Task CreateChallengingPassengersListAsync(string groupId, string channelId) 312 | { 313 | int retries = 3; 314 | 315 | while (retries > 0) 316 | { 317 | try 318 | { 319 | // Get the team site 320 | var teamSite = await graphClient.GetTeamSiteAsync(groupId); 321 | 322 | var challengingPassengers = new List 323 | { 324 | DisplayName = "Challenging Passengers", 325 | Columns = new ListColumnsCollectionPage { 326 | new ColumnDefinition 327 | { 328 | Name = "Name", 329 | Text = new TextColumn() 330 | }, 331 | new ColumnDefinition 332 | { 333 | Name = "SeatNumber", 334 | Text = new TextColumn() 335 | }, 336 | new ColumnDefinition 337 | { 338 | Name = "Notes", 339 | Text = new TextColumn() 340 | } 341 | } 342 | }; 343 | 344 | // Create the list 345 | var createdList = await graphClient.CreateSharePointListAsync(teamSite.Id, challengingPassengers); 346 | 347 | // Add the list as a team tab 348 | /* 349 | var listTab = new TeamsChannelTab 350 | { 351 | Name = "Challenging Passengers", 352 | TeamsAppId = "com.microsoft.teamspace.tab.web", 353 | Configuration = new TeamsChannelTabConfiguration 354 | { 355 | ContentUrl = createdList.WebUrl, 356 | WebsiteUrl = createdList.WebUrl 357 | } 358 | }; 359 | 360 | await graphClient.AddTeamChannelTab(groupId, channelId, listTab); 361 | */ 362 | 363 | logger.LogInformation("Created challenging passenger list"); 364 | return createdList; 365 | } 366 | catch (ServiceException ex) 367 | { 368 | logger.LogWarning($"CreateChallengingPassengersListAsync error: {ex.Message}"); 369 | retries--; 370 | logger.LogWarning($"{retries} retries remaining"); 371 | } 372 | } 373 | 374 | return null; 375 | } 376 | 377 | private static async Task CreateSharePointPageAsync(string groupId, string channelId, float flightNumber) 378 | { 379 | try 380 | { 381 | // Get the team site 382 | var teamSite = await graphClient.GetTeamSiteAsync(groupId); 383 | logger.LogInformation("Got team site"); 384 | 385 | // Initialize page 386 | var sharePointPage = new SitePage 387 | { 388 | Name = "Crew.aspx", 389 | Title = $"Flight {flightNumber} Crew" 390 | }; 391 | 392 | var webParts = new List 393 | { 394 | new WebPart 395 | { 396 | Type = webPartId, 397 | Data = new SitePageData 398 | { 399 | AdditionalData = new Dictionary 400 | { 401 | { "dataVersion", "1.0"}, 402 | { "properties", new Dictionary 403 | { 404 | { "description", "CrewBadges" } 405 | } 406 | } 407 | } 408 | } 409 | } 410 | }; 411 | 412 | sharePointPage.WebParts = webParts; 413 | 414 | var createdPage = await graphClient.CreateSharePointPageAsync(teamSite.Id, sharePointPage); 415 | logger.LogInformation("Created crew page"); 416 | 417 | // Publish the page 418 | await graphClient.PublishSharePointPageAsync(teamSite.Id, createdPage.Id); 419 | var pageUrl = createdPage.WebUrl.StartsWith("https") ? createdPage.WebUrl : 420 | $"{teamSite.WebUrl}/{createdPage.WebUrl}"; 421 | 422 | logger.LogInformation("Published crew page"); 423 | 424 | // Add the list as a team tab 425 | var pageTab = new TeamsTab 426 | { 427 | Name = createdPage.Title, 428 | TeamsAppId = "com.microsoft.teamspace.tab.web", 429 | Configuration = new TeamsTabConfiguration 430 | { 431 | ContentUrl = pageUrl, 432 | WebsiteUrl = pageUrl 433 | } 434 | }; 435 | 436 | await graphClient.AddTeamChannelTab(groupId, channelId, pageTab); 437 | 438 | logger.LogInformation("Added crew page as Teams tab"); 439 | } 440 | catch (Exception ex) 441 | { 442 | logger.LogWarning($"Failed to create crew page: ${ex.ToString()}"); 443 | } 444 | } 445 | 446 | private static async Task AddFlightToCalendars(FlightTeam flightTeam, List usersToAdd = null) 447 | { 448 | // Get all flight members 449 | var allCrewIds = await graphClient.GetUserIds(flightTeam.Pilots, flightTeam.FlightAttendants); 450 | 451 | // Initialize flight event 452 | var flightEvent = new Event 453 | { 454 | Subject = $"Flight {flightTeam.FlightNumber}", 455 | Location = new Location 456 | { 457 | DisplayName = flightTeam.Description 458 | }, 459 | Start = new DateTimeTimeZone 460 | { 461 | DateTime = flightTeam.DepartureTime.ToString("s"), 462 | TimeZone = "UTC" 463 | }, 464 | End = new DateTimeTimeZone 465 | { 466 | DateTime = flightTeam.DepartureTime.AddHours(4).ToString("s"), 467 | TimeZone = "UTC" 468 | }, 469 | Categories = new string[] { "Assigned Flight" }, 470 | Extensions = new EventExtensionsCollectionPage() 471 | }; 472 | 473 | var flightExtension = new OpenTypeExtension 474 | { 475 | ODataType = "microsoft.graph.openTypeExtension", 476 | ExtensionName = "com.contoso.flightData", 477 | AdditionalData = new Dictionary() 478 | }; 479 | 480 | flightExtension.AdditionalData.Add("departureGate", flightTeam.DepartureGate); 481 | flightExtension.AdditionalData.Add("crewMembers", allCrewIds); 482 | 483 | flightEvent.Extensions.Add(flightExtension); 484 | 485 | if (usersToAdd == null) 486 | { 487 | usersToAdd = allCrewIds; 488 | } 489 | 490 | foreach (var userId in usersToAdd) 491 | { 492 | //var user = await graphClient.GetUserByUpn(userId); 493 | 494 | await graphClient.CreateEventInUserCalendar(userId, flightEvent); 495 | } 496 | } 497 | 498 | private static async Task UpdateFlightInCalendars(List crewMembers, int flightNumber, string departureGate, DateTime? newDepartureTime = null) 499 | { 500 | foreach (var userId in crewMembers) 501 | { 502 | // Get the event 503 | var matchingEvents = await graphClient.GetEventsInUserCalendar(userId, 504 | $"categories/any(a:a eq 'Assigned Flight') and subject eq 'Flight {flightNumber}'"); 505 | 506 | var flightEvent = matchingEvents.CurrentPage.FirstOrDefault(); 507 | if (flightEvent != null) 508 | { 509 | if (newDepartureTime != null) 510 | { 511 | flightEvent.Start.DateTime = newDepartureTime?.ToString("s"); 512 | flightEvent.End.DateTime = newDepartureTime?.AddHours(4).ToString("s"); 513 | await graphClient.UpdateEventInUserCalendar(userId, flightEvent); 514 | } 515 | 516 | var flightExtension = new OpenTypeExtension 517 | { 518 | ODataType = "microsoft.graph.openTypeExtension", 519 | ExtensionName = "com.contoso.flightData", 520 | AdditionalData = new Dictionary() 521 | }; 522 | 523 | flightExtension.AdditionalData.Add("departureGate", departureGate); 524 | flightExtension.AdditionalData.Add("crewMembers", crewMembers); 525 | 526 | await graphClient.UpdateFlightExtension(userId, flightEvent.Id, flightExtension); 527 | } 528 | } 529 | } 530 | 531 | private static async Task RemoveFlightFromCalendars(List usersToRemove, int flightNumber) 532 | { 533 | foreach (var userId in usersToRemove) 534 | { 535 | logger.LogInformation($"Deleting flight from ${userId}"); 536 | // Get the event 537 | try 538 | { 539 | var matchingEvents = await graphClient.GetEventsInUserCalendar(userId, 540 | $"categories/any(a:a eq 'Assigned Flight') and subject eq 'Flight {flightNumber}'"); 541 | 542 | var flightEvent = matchingEvents.CurrentPage.FirstOrDefault(); 543 | if (flightEvent != null) 544 | { 545 | await graphClient.DeleteEventInUserCalendar(userId, flightEvent.Id); 546 | } 547 | } 548 | catch (Exception ex) 549 | { 550 | logger.LogWarning($"Delete event returned an error: {ex.Message}"); 551 | } 552 | } 553 | } 554 | 555 | private static async Task SendDeviceNotifications(List crewMembers, int flightNumber, string newGate = null, string newDepartureTime = null) 556 | { 557 | string notificationText = string.Empty; 558 | 559 | if (!string.IsNullOrEmpty(newGate)) 560 | { 561 | notificationText = $"New Departure Gate: {newGate}"; 562 | } 563 | 564 | if (!string.IsNullOrEmpty(newDepartureTime)) 565 | { 566 | notificationText = $"{(string.IsNullOrEmpty(notificationText) ? "" : notificationText + "\n")}New Departure Time: {newDepartureTime}"; 567 | } 568 | 569 | if (!string.IsNullOrEmpty(notificationText)) 570 | { 571 | foreach(var userId in crewMembers) 572 | { 573 | await graphClient.SendUserNotification(userId, $"Flight {flightNumber} Update", notificationText); 574 | } 575 | } 576 | } 577 | 578 | private static string GetTimestamp() 579 | { 580 | var now = DateTime.Now; 581 | return $"{now.Hour}{now.Minute}{now.Second}"; 582 | } 583 | } 584 | } 585 | --------------------------------------------------------------------------------