Microsoft Teams Lifecycle - Delete

Register for free to comment or receive a notifications when a new articles are created.

This is the second part of the complete guide how to build a Microsoft teams lifecycle process. In the first part. we've setup a process that archives old teams (meaning, they are read only), now after certain period of time, we need to check with the team's owner do we need to delete this team.


Brief summary of the solution

This solution was probably by far the hardest one I've ever created. There we quite a lot of questions that occur during planning and building it. For some I needed a couple of days to clear my mind before come up with solution.


The solution is still using the SharePoint online list that stores all teams, from Part 1.


As you can see from bellow flow map, the process is quite big and every step has to be carefully planned. The process gets all archived teams, where archive date is longer than 90 days. Then sends a notification to the owner of the team asking him whether he want to set the team back to active or he wants to delete it.

If the owner select to keep the team, the team is unarchived and then update the list item status in the SharePoint list as active.

If the owner select to delete the team, then all team files are moved to another location (in my case this is another SharePoint site), an access to the site is granted to the team's owner, then the team is deleted, and last the SharePoint list item status is updated to Deleted.

Advice: This solution has quite a lot of steps, so my advice is to use a proper step naming as you will see here.

The solution

As in the archive solution, here we also want to have each item (team in this case) as individual flow run, so we can easily troubleshoot when some item fails. So again we need to split the the process into parent and child flow. For this we need to work in the same solution from Part 1, as child flows are working only when within a solution.


The parent flow

The parent flow is the same as with the archive parent flow.

  • We start with a recurrence trigger (once a day for example).

  • From the SharePoint list that stores all my teams that are being created (I've covered this list in first part of the article) I need all teams with status Archived, and archive date longer than 90 days.

TeamStatus eq 'Archived' and ArchiveDate le addDays(utcnow('yyyy-MM-ddTHH:mm:ssZ'),-1)

Here I'm using a manual trigger action just for the test, but you can change this to recurrence.
  • Last step is, for each item (archived team in this case), run a child flow, That way we we separate each item into a separate flow run. For the child flow, again as in the archive flow, we need to provide only the SPO list item id, because there we have all the information we need.

I will advice on stop here, because you will need to provide a child flow required input, which you yet don't have. You will get it once you start building the child flow.

The Child flow

Here is where it gets interesting.

Advice: Test the flow after a few action, rather than building the entire flow and then start troubleshooting error messages that might occur.
  • Begin the flow with manual trigger, and setup a Number input (this will be our SPO list item id which the parent flow will send us.) At this point you can save the flow, and return to the parent flow to complete the Run child flow step with adding the TeamID input to complete the flow.

  • Now we need to get the list item with the provided id.

  • Once we have the item, we need to send an adaptive card to the team's owner, to take a decision. I've used the same adaptive card JSON from the Part 1, just changed the message and the option for the user.


  • The recipient is the team's owner

  • The next step is to send a response back to the Parent flow. This was actually one of the problems I had to solve. You see, usually the response to the parent flow is send when the last step is executed, and this was wat caused my flow to timeout.

Respond to a PowerApps or flow step is actually an http call, which means that it doesn't use web sockets. Most gateways will timeout after 60 seconds.

When this step is at the end of the flow, there is no way the flow to complete within 60 seconds, and the flow will timeout. So I've moved it at the beginning, and provided as a response the user's decision (Delete/Postpone)

We can have the "Response to a PowerApp or flow" step and still add actions underneath it.

  • Next action is to take a decision for each response of the user. You can use the following expression:

body('NotifyTeamOwnerAndWaitForResponse')?['submitActionId']

  • If the response is Delete, then we need to get the team's SharePoint site behind. For this step you will need to use GraphAPI to get the Group's root site. For a full list of GraphAPIs check here.

For instructions on how to setup GraphAPI with PowerApps, check out one of my other articles.
  • Next action is to make sure that we've created a folder with the name of the team at the location where we will store the archived files.

Sit Address: The location where you will save all archives

List or Library: Here I'm using the default Document library, but you can have another one called Archives.

Folder Path: /<The Title of the archived team>

Here I hit my second big problem. Within a Team, you can have Public and Private channel. However, there is something special about the private channels in teams.

1. Private Channel is it’s own site collection.
2. Private Channel is not a group
3. Private channel site collection is named: sourceteamsite-privatechannelname

I had to find a way to get the private channel's SharePoint site. Since private channel is not a group (point 2), I cannot obviously use graph to get the Group root site as I did with the team.

But what i know for sure?

  1. The SharePoint site link format (point 3)

  2. The private channel name

  • My next action will be to list all channels for the selected team, so I can identify potential private channels in it.

The response will shows all channels with their names and membershiptype.

{
                "id": "19:fd8c9c07e6404e29b086cb22b8177e88@thread.tacv2",
                "createdDateTime": "2022-02-03T08:06:41.46Z",
                "displayName": "Board of Directors",
                "email": "",
                "webUrl": "https://teams.microsoft.com/l/channel/19%3afd8c9c07e6404e29b086cb22b8177e88%40thread.tacv2/Board+of+Directors?groupId=8ac0c3f8-6260-4207-a43c-06425c1b4d73&tenantId=8527ffd7-e153-4862-bf90-7a0cab40241e",
                "membershipType": "private"
            },
  • Now, I can use a decision step to tell the flow, what to do if the channel is Private.

Our focus here are the private channels, as we know the site address for the public one.

For each Channel list if the items('ForEachChannel')?['membershipType'] is private do the next step.


  • We solve one problem, identifying our private teams, however we still need to find the team's site link to be able to get the channel's files.

  • Since we know the root site url and the team name, and the private channel's format (sourceteamsite-privatechannelname) we can use a compose step to build the url our self. using the webUrl (value returned from the GetGroupRootSite step) - Display Name (returned from ListChannels step), but we will hit another error here, there must be no space in the name, but we cannot also substitute it with %2b as there is not such a site url. The real site url is like this:

https://m365x716942.sharepoint.com/sites/Production-BG01A-ORG-BoardofDirectors

  • We need to remove the spaces from the display name. This is why I've inserted a Compose action right before my ChannelType action. What this step is doing is to take the channel's display name and to remove the spaces from it, so the output will be a display name without space.

I've used the following expression to replace the space:

replace(item()?['displayName'],' ','')

This is the output result i need to complete the channel's site url.

  • If the channel is private, i want to build the channel's site url using a compose action.


This is the output result I was looking for. From here on the life is a little bit easier.

  • After I have the root site URL and the private channel URL I need to get all SharePoint list and libraries, which is my next action. This is why I set a Yes/No condition step for the ChannelType. If the channel is private, then I will use the composed URL to access the list and libraries of it, otherwise I will use the root site URL.

The steps for Public and Private channel are the same to get the lists and libraries.
  • For each of the libraries, we need to get the file properties. We need the properties of the files so we can tell SharePoint which files to copy to our archive location.

Site address: As we can see, here also we need to use for the private channel the composed url, and for the public one the root url.

Library name: Dynamically we get the name of each library returned.

  • The next step is to copy the files to the archive location. but here we face another problem, we will not receive any properties back from GetFileProperties steps, and therefore we cannot point the file identifier.

Without File identifier, PA will not know which file to copy.
  • In order to bypass this, we need to be more creative. You can either use Compose or ParseJSON action. I've used ParseJSON as this will allow me to get any value's property separately. Steps are the same for both sides.

Content: Body of the GetFileProperties step

Schema:


{
    "type": "object",
    "properties": {
        "value": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "@@odata.etag": {
                        "type": "string"
                    },
                    "ItemInternalId": {
                        "type": "string"
                    },
                    "ID": {
                        "type": "integer"
                    },
                    "Title": {
                        "type": "string"
                    },
                    "Modified": {
                        "type": "string"
                    },
                    "Editor": {
                        "type": "object",
                        "properties": {
                            "@@odata.type": {
                                "type": "string"
                            },
                            "Claims": {
                                "type": "string"
                            },
                            "DisplayName": {
                                "type": "string"
                            },
                            "Email": {
                                "type": "string"
                            },
                            "Picture": {},
                            "Department": {
                                "type": "string"
                            },
                            "JobTitle": {
                                "type": "string"
                            }
                        }
                    },
                    "Editor#Claims": {
                        "type": "string"
                    },
                    "Created": {
                        "type": "string"
                    },
                    "Author": {
                        "type": "object",
                        "properties": {
                            "@@odata.type": {
                                "type": "string"
                            },
                            "Claims": {
                                "type": "string"
                            },
                            "DisplayName": {
                                "type": "string"
                            },
                            "Email": {},
                            "Picture": {},
                            "Department": {},
                            "JobTitle": {}
                        }
                    },
                    "Author#Claims": {
                        "type": "string"
                    },
                    "OData__DisplayName": {
                        "type": "string"
                    },
                    "{Identifier}": {
                        "type": "string"
                    },
                    "{IsFolder}": {
                        "type": "boolean"
                    },
                    "{Thumbnail}": {
                        "type": "object",
                        "properties": {
                            "Large": {},
                            "Medium": {},
                            "Small": {}
                        }
                    },
                    "{Link}": {
                        "type": "string"
                    },
                    "{Name}": {
                        "type": "string"
                    },
                    "{FilenameWithExtension}": {
                        "type": "string"
                    },
                    "{Path}": {
                        "type": "string"
                    },
                    "{FullPath}": {
                        "type": "string"
                    },
                    "{IsCheckedOut}": {
                        "type": "boolean"
                    },
                    "{VersionNumber}": {
                        "type": "string"
                    }
                },
                "required": [
                    "@@odata.etag",
                    "ItemInternalId",
                    "ID",
                    "Modified",
                    "Editor",
                    "Editor#Claims",
                    "Created",
                    "Author",
                    "Author#Claims",
                    "OData__DisplayName",
                    "{Identifier}",
                    "{IsFolder}",
                    "{Thumbnail}",
                    "{Link}",
                    "{Name}",
                    "{FilenameWithExtension}",
                    "{Path}",
                    "{FullPath}",
                    "{IsCheckedOut}",
                    "{VersionNumber}"
                ]
            }
        }
    }
}

  • Now we can proceed with the CopyFiles action.

Current Site Address: This is the source site (here we have again Rootsite URl and the Composed Site url that we've created for the private channels.

Fail to Copy: We use the {Identifier} value from Parse JSON step

Destination Site Address: is the location where we want to store the copy of the archived files.

Destination Folder: /<Name of the root folder>/ Title of the folder with the team name we've created at the beginning of the process.

If another file is already there: What should PA do if the file with the same name exists in the destination folder. I use here to replace the file, but you can rename the file keeping the original name and add some letters at the end (1,2, etc.)

After I run the flow, all files and folder were moved, however the flow end with error message:

{"status":404,"message":"File not found\r\nclientRequestId: 169f54c6-346b-4e1b-8fd9-0dfba5b3b935\r\nserviceRequestId: 169f54c6-346b-4e1b-8fd9-0dfba5b3b935"}

This problem took me couple of days to bypass.

It turns out that there this message is thrown when PA is trying to copy a {isfolder} - true file.

Copy file action actually copies only files, but depside the error message, the folder is also copied.

But for the flow this was a big problem because it always end with fail.

  • To resolve this I've created a compose step, which was run after only when CopyFail step has failed.

Use the following expression for Inputs: outputs('CopyFile2')?['statusCode']


This way, even if a fail fail to copy, the flow will end with success.

  • Returning on a top level of the process, we need to add a next action, which is to grand the team's owner permissions to the archived location only.

Site Address: Archive site address

List or Library Name: In my case this is the root document library at the archive site

ID: Id of the CreateNewArchiveFolder step

Recipient: Team's owner from my SharePoint list containing details about the archived team.

Role: View or Edit

Message: If you want a personal message to the user.

Notify Recipient: If you want the recipient to receive an email when the folder is shared with him. I keep this Yes, but you can add an Send Email step if you want to say something like


"Dear Owner,


Your team: Production has been deleted on 15.02.2022 at 2PM.

All Team files have been stored on another location, you can access them from this link: https://linktothearchivedsite/document/Production


Regards

Power Automate"

  • Next step is to update the SharePoint list item's status to Deleted, and also to add the link to the archived location. For this purpose i will created a new filed of type URL in my SPO list.

ID: ID of the list item from first step

Delete Team: Yes

Delete Date: The date when the flow has completed the run. convertTimeZone(utcNow(),'UTC','W. Europe Standard Time','MM/dd/yyyy')

Archive Location: Link to item from CreateNewArchiveFolder step

This is it! You now have a process that manages teams dynamically, allowing you not to have the IT doing this manually.




Stay tuned for more interesting solutions, or reach us in the contact form if you need help with building a custom solution for your business.


6 views0 comments

Recent Posts

See All