From eddef9284c41bfd8e8f2a1d9573337ebf0223404 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:52:13 +0200 Subject: [PATCH 1/2] remove optional error, prettier error. --- .../CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 index baf65914962df..f039e6cc8cbb1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMAdminRoles.ps1 @@ -83,23 +83,22 @@ function Set-CIPPSAMAdminRoles { $ActionLogs.Add('Added Service Principal to Compliance Center') } catch { $SpError = $_.Exception.Message - if ($SpError -match 'already exist') { - $ActionLogs.Add('Service Principal already added to Compliance Center') - } else { - $ActionLogs.Add("Failed to add Service Principal to Compliance Center: $SpError") - $HasFailures = $true + switch ($SpError) { + { $_ -match 'already exist' } { $ActionLogs.Add('Service Principal already added to Compliance Center') } + { $_ -match 'New-ServicePrincipal is not present' } { $ActionLogs.Add('Tenant does not have a license to use Compliance Center features. Skipping.') } + default { $ActionLogs.Add("Failed to add Service Principal to Compliance Center: $SpError"); $HasFailures = $true } } + } try { $null = New-ExoRequest -cmdlet 'New-ServicePrincipal' -cmdParams @{AppId = $env:ApplicationID; ObjectId = $id; DisplayName = 'CIPP-SAM' } -tenantid $TenantFilter -useSystemMailbox $true -AsApp $ActionLogs.Add('Added Service Principal to Exchange Online') } catch { $SpError = $_.Exception.Message - if ($SpError -match 'already exist') { - $ActionLogs.Add('Service Principal already added to Exchange Online') - } else { - $ActionLogs.Add("Failed to add Service Principal to Exchange Online: $SpError") - $HasFailures = $true + switch ($SpError) { + { $_ -match 'already exist' } { $ActionLogs.Add('Service Principal already added to Compliance Center') } + { $_ -match 'Response status code does not indicate success' } { $ActionLogs.Add('Could not connect to Exchange, we received an access denied. This is expected if you do not have an exchange license.'); $HasFailures = $true } + default { $ActionLogs.Add("Failed to add Service Principal to Compliance Center: $SpError"); $HasFailures = $true } } } From ffab43e1adacd72d54693248bef1d440e1e0f82d Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Thu, 16 Jul 2026 15:10:45 +0200 Subject: [PATCH 2/2] (Sharepoint) Site (in)activity cache and report --- Config/CIPPDBCacheTypes.json | 5 + .../Get-CIPPAlertInactiveSharePointSites.ps1 | 98 +++++ .../Get-CIPPAlertInactiveTeamsSites.ps1 | 101 +++++ .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + .../DBCache/Set-CIPPDBCacheSiteActivity.ps1 | 379 ++++++++++++++++++ .../Invoke-ListSiteActivity.ps1 | 95 +++++ 6 files changed, 679 insertions(+) create mode 100644 Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 create mode 100644 Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSiteActivity.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index ee0dc3de42c32..dd50f5a50ffa9 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -294,6 +294,11 @@ "friendlyName": "SharePoint Site Usage", "description": "SharePoint site usage statistics" }, + { + "type": "SiteActivity", + "friendlyName": "Site Activity", + "description": "Per-site activity with siteType classification (SharePoint, SharePointAndTeams, OneDrive) and separate workload activity columns" + }, { "type": "SharePointSharingLinks", "friendlyName": "SharePoint & OneDrive Sharing Links", diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 new file mode 100644 index 0000000000000..ba301c522572e --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 @@ -0,0 +1,98 @@ +function Get-CIPPAlertInactiveSharePointSites { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + $HasSharePoint = Test-CIPPStandardLicense -StandardName 'InactiveSharePointSites' -TenantFilter $TenantFilter -Preset SharePoint + if (-not $HasSharePoint) { + return + } + + try { + $DaysSinceActivity = 90 + $IncludeNeverActive = $false + + if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { + if ($null -ne $InputValue.IncludeNeverActive) { + $IncludeNeverActive = [bool]$InputValue.IncludeNeverActive + } + if ($null -ne $InputValue.DaysSinceActivity -and $InputValue.DaysSinceActivity -ne '') { + $ParsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceActivity.ToString(), [ref]$ParsedDays) -and $ParsedDays -gt 0) { + $DaysSinceActivity = $ParsedDays + } + } + } elseif ($InputValue) { + $ParsedDays = 0 + if ([int]::TryParse($InputValue.ToString(), [ref]$ParsedDays) -and $ParsedDays -gt 0) { + $DaysSinceActivity = $ParsedDays + } + } + + $Lookup = (Get-Date).AddDays(-$DaysSinceActivity).Date + + $SiteActivityRows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SiteActivity') + if (-not $SiteActivityRows) { + return + } + + $AlertData = foreach ($Site in $SiteActivityRows) { + if ($Site.isDeleted -eq $true) { continue } + if ($Site.siteType -ne 'SharePoint') { continue } + + $LastActivity = $Site.effectiveLastActivityDate + if (-not $LastActivity) { + if (-not $IncludeNeverActive) { continue } + } elseif ([DateTime]$LastActivity -gt $Lookup) { + continue + } + + $SiteUrl = $Site.webUrl + $SiteLabel = $Site.displayName ?? $SiteUrl + if ([string]::IsNullOrWhiteSpace($SiteLabel) -and $Site.ownerDisplayName) { + $SiteLabel = $Site.ownerDisplayName + } + + $LastActivityDisplay = if ($LastActivity) { $LastActivity } else { 'Never' } + $DaysSinceActivityValue = if ($LastActivity) { + [Math]::Round(((Get-Date).Date - [DateTime]$LastActivity).TotalDays) + } else { + 'N/A' + } + + $OwnerPart = if ($Site.ownerPrincipalName) { " Owner: $($Site.ownerPrincipalName)." } else { '' } + $Message = "Site '$SiteLabel' ($SiteUrl) SharePoint last active $LastActivityDisplay.$OwnerPart" + + [PSCustomObject]@{ + Message = $Message + Id = $Site.siteId + siteId = $Site.siteId + webUrl = $SiteUrl + displayName = $Site.displayName + ownerPrincipalName = $Site.ownerPrincipalName + lastActivityDate = $LastActivityDisplay + DaysSinceActivity = $DaysSinceActivityValue + rootWebTemplate = $Site.rootWebTemplate + cachedAt = $Site.cachedAt + reportPeriod = $Site.reportPeriod + teamLinkResolutionStatus = $Site.teamLinkResolutionStatus + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message "Inactive SharePoint sites alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 new file mode 100644 index 0000000000000..38efa0383f51a --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 @@ -0,0 +1,101 @@ +function Get-CIPPAlertInactiveTeamsSites { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + $HasTeams = Test-CIPPStandardLicense -StandardName 'InactiveTeamsSites' -TenantFilter $TenantFilter -Preset Teams + if (-not $HasTeams) { + return + } + + try { + $DaysSinceActivity = 90 + $IncludeNeverActive = $false + + if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { + if ($null -ne $InputValue.IncludeNeverActive) { + $IncludeNeverActive = [bool]$InputValue.IncludeNeverActive + } + if ($null -ne $InputValue.DaysSinceActivity -and $InputValue.DaysSinceActivity -ne '') { + $ParsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceActivity.ToString(), [ref]$ParsedDays) -and $ParsedDays -gt 0) { + $DaysSinceActivity = $ParsedDays + } + } + } elseif ($InputValue) { + $ParsedDays = 0 + if ([int]::TryParse($InputValue.ToString(), [ref]$ParsedDays) -and $ParsedDays -gt 0) { + $DaysSinceActivity = $ParsedDays + } + } + + $Lookup = (Get-Date).AddDays(-$DaysSinceActivity).Date + + $SiteActivityRows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SiteActivity') + if (-not $SiteActivityRows) { + return + } + + $AlertData = foreach ($Site in $SiteActivityRows) { + if ($Site.isDeleted -eq $true) { continue } + if ($Site.siteType -ne 'SharePointAndTeams') { continue } + + $LastActivity = $Site.effectiveLastActivityDate + if (-not $LastActivity) { + if (-not $IncludeNeverActive) { continue } + } elseif ([datetime]$LastActivity -gt $Lookup) { + continue + } + + $TeamsSiteUrl = $Site.webUrl + $TeamsSiteLabel = $Site.teamsTeamName ?? $Site.displayName ?? $TeamsSiteUrl + if ([string]::IsNullOrWhiteSpace($TeamsSiteLabel) -and $Site.ownerDisplayName) { + $TeamsSiteLabel = $Site.ownerDisplayName + } + + $LastActivityDisplay = if ($LastActivity) { $LastActivity } else { 'Never' } + $DaysSinceActivityValue = if ($LastActivity) { + [Math]::Round(((Get-Date).Date - [datetime]$LastActivity).TotalDays) + } else { + 'N/A' + } + + $OwnerPart = if ($Site.ownerPrincipalName) { " Owner: $($Site.ownerPrincipalName)." } else { '' } + $Message = "TeamsSite '$TeamsSiteLabel' ($TeamsSiteUrl) Teams last active $LastActivityDisplay.$OwnerPart" + + [PSCustomObject]@{ + Message = $Message + Id = $Site.siteId + siteId = $Site.siteId + teamsSiteName = $TeamsSiteLabel + teamsSiteUrl = $TeamsSiteUrl + teamsTeamId = $Site.teamsTeamId + teamsTeamName = $Site.teamsTeamName + teamsLastActivityDate = $LastActivityDisplay + DaysSinceActivity = $DaysSinceActivityValue + sharePointLastActivityDate = $Site.sharePointLastActivityDate + effectiveLastActivityDate = $Site.effectiveLastActivityDate + ownerPrincipalName = $Site.ownerPrincipalName + teamLinkResolutionStatus = $Site.teamLinkResolutionStatus + cachedAt = $Site.cachedAt + reportPeriod = $Site.reportPeriod + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-AlertMessage -message "Inactive TeamsSite alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 1c2eb64addd24..bc3eeaf918458 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -142,6 +142,7 @@ function Invoke-CIPPDBCacheCollection { 'SPOTenant' 'SPOTenantSyncClientRestriction' 'SharePointSiteUsage' + 'SiteActivity' 'OneDriveUsage' ) Teams = @( diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSiteActivity.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSiteActivity.ps1 new file mode 100644 index 0000000000000..b6c01f2bdcb65 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSiteActivity.ps1 @@ -0,0 +1,379 @@ +function Set-CIPPDBCacheSiteActivity { + <# + .SYNOPSIS + Caches per-site activity from Microsoft usage reports for a tenant. + + .DESCRIPTION + SiteActivity is not SharePoint-, OneDrive-, or Teams-specific. It stores one row per siteId + with a siteType classifier (SharePoint, SharePointAndTeams, OneDrive) and workload-prefixed + activity columns. SharePoint, OneDrive, and Teams are current data sources — additional + workloads could add their own prefixed fields later without new cache types. + + Each row also includes teamLinkResolutionStatus (Complete/Partial) so consumers can gauge + whether Team-link enrichment was fully resolved for the run. + + Site membership is the union of non-deleted rows from getSharePointSiteUsageDetail and + getOneDriveUsageAccountDetail, but a row is written only when required fields for its + siteType are present after getAllSites backfill (incomplete report rows are skipped). + sites/getAllSites is identity backfill only (displayName, webUrl, isPersonalSite, etc.). + Stored id/siteId values are normalized (lowercase, no braces) for stable Add-CIPPDbItem row keys. + + SharePointAndTeams means a provisioned Team exists for the Group-connected site, not that + the team activity report contains a row. teamsLastActivityDate may be null when the Team + exists but had no activity in the D180 window. + + effectiveLastActivityDate: passthrough report values for all siteTypes; for SharePointAndTeams + uses whichever of sharePointLastActivityDate and teamsLastActivityDate is later (original string). + + Foundational fetches (SP report, OneDrive report, getAllSites, getTeamsTeamActivityDetail) run + as one New-GraphBulkRequest and must succeed or the cache write is aborted. Graph usage reports + are not license-gated at the API level — unlicensed or inactive workloads typically return empty + rows, not errors. The SharePoint license gate above only skips tenants CIPP knows lack + SP/OneDrive SKUs before calling Graph. Team-provisioned groups lookup and bulk + groups/{id}/sites/root resolution are non-fatal enrichments. Graph groups list cannot + $expand=sites — site sharepointIds require per-group sites/root calls (batched). + + Cache write behavior follows CIPPDB convention: on foundational fetch failure, the function + logs and exits without replacing existing SiteActivity rows (last-known-good is preserved). + + Report data reflects a D180 window with typical Microsoft report lag. + + .PARAMETER TenantFilter + The tenant to cache site activity for + + .PARAMETER QueueId + Optional queue context passed by orchestrators; reserved for queue-aware cache flows. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'SiteActivityCache' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have SharePoint/OneDrive license, skipping SiteActivity cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SiteActivity' -sev Debug + + $FoundationalRequests = @( + @{ + id = 'sharePointUsage' + method = 'GET' + url = "reports/getSharePointSiteUsageDetail(period='D180')?`$format=application/json" + } + @{ + id = 'oneDriveUsage' + method = 'GET' + url = "reports/getOneDriveUsageAccountDetail(period='D180')?`$format=application/json" + } + @{ + id = 'listAllSites' + method = 'GET' + url = "sites/getAllSites?`$select=id,webUrl,displayName,isPersonalSite,createdDateTime,sharepointIds&`$top=999" + } + @{ + id = 'teamsActivity' + method = 'GET' + url = "reports/getTeamsTeamActivityDetail(period='D180')?`$format=application/json" + } + ) + $FoundationalResults = @(New-GraphBulkRequest -tenantid $TenantFilter -Requests @($FoundationalRequests) -asapp $true) + + function Get-SiteActivityBulkReportRows { + param( + [Parameter(Mandatory = $true)]$Responses, + [Parameter(Mandatory = $true)][string]$Id, + [Parameter(Mandatory = $true)][string]$Label + ) + $Response = $Responses | Where-Object { $_.id -eq $Id } | Select-Object -First 1 + if (-not $Response) { + throw "$Label response missing from Graph bulk batch" + } + if ($Response.status -and $Response.status -ne 200) { + throw ($Response.body.error.message ?? "$Label request failed with status $($Response.status)") + } + $Body = $Response.body + if ($Body -is [string]) { + $Json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Body)) + return @(($Json | ConvertFrom-Json).value) + } + return @($Body.value) + } + + $SharePointRows = @(Get-SiteActivityBulkReportRows -Responses $FoundationalResults -Id 'sharePointUsage' -Label 'SharePoint site usage report') + $OneDriveRows = @(Get-SiteActivityBulkReportRows -Responses $FoundationalResults -Id 'oneDriveUsage' -Label 'OneDrive usage report') + $TeamRows = @(Get-SiteActivityBulkReportRows -Responses $FoundationalResults -Id 'teamsActivity' -Label 'Teams team activity report') + + $AllSitesResponse = $FoundationalResults | Where-Object { $_.id -eq 'listAllSites' } | Select-Object -First 1 + if (-not $AllSitesResponse) { + throw 'getAllSites response missing from Graph bulk batch' + } + if ($AllSitesResponse.status -and $AllSitesResponse.status -ne 200) { + throw ($AllSitesResponse.body.error.message ?? "getAllSites request failed with status $($AllSitesResponse.status)") + } + $AllSites = @($AllSitesResponse.body.value) + + $SiteIndex = @{} + foreach ($Site in $AllSites) { + if ($null -eq $Site -or -not $Site.sharepointIds.siteId) { continue } + $IndexKey = $Site.sharepointIds.siteId.ToString().Trim().Trim('{}').ToLowerInvariant() + if ($IndexKey -and -not $SiteIndex.ContainsKey($IndexKey)) { + $SiteIndex[$IndexKey] = $Site + } + } + + $TeamActivityById = @{} + foreach ($Team in $TeamRows) { + if (-not $Team.teamId) { continue } + $TeamKey = $Team.teamId.ToString().Trim().Trim('{}').ToLowerInvariant() + if ($TeamKey -eq '00000000-0000-0000-0000-000000000000') { continue } + $TeamActivityById[$TeamKey] = $Team + } + + # Run-level Team link enrichment quality indicator written to every record. + $TeamLinkResolutionStatus = 'Complete' + $TeamProvisionedById = @{} + try { + $TeamGroups = @(New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$select=id,displayName&`$top=999" -AsApp $true) + foreach ($Group in $TeamGroups) { + if (-not $Group.id) { continue } + $GroupKey = $Group.id.ToString().Trim().Trim('{}').ToLowerInvariant() + if ($GroupKey -eq '00000000-0000-0000-0000-000000000000') { continue } + $TeamProvisionedById[$GroupKey] = $Group + } + } catch { + $TeamLinkResolutionStatus = 'Partial' + $GroupError = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SiteActivity: unable to load Team-provisioned groups (continuing): $($GroupError.NormalizedError)" -Sev 'Warning' -LogData $GroupError + } + + # Graph groups list does not support $expand=sites; resolve Team siteId per group via bulk sites/root. + # Any partial failure downgrades Team link resolution status for the entire run. + $TeamSiteBySiteKey = @{} + if ($TeamProvisionedById.Count -gt 0) { + try { + $TeamSiteRequests = foreach ($GroupKey in $TeamProvisionedById.Keys) { + @{ + id = $GroupKey + method = 'GET' + url = "groups/$GroupKey/sites/root?`$select=sharepointIds,webUrl,displayName" + } + } + $TeamSiteResponses = @(New-GraphBulkRequest -tenantid $TenantFilter -Requests @($TeamSiteRequests) -AsApp $true) + $TeamSiteFailures = 0 + foreach ($Response in $TeamSiteResponses) { + if ($Response.status -ne 200 -or -not $Response.body.sharepointIds.siteId) { + $TeamSiteFailures++ + continue + } + $MappedSiteKey = $Response.body.sharepointIds.siteId.ToString().Trim().Trim('{}').ToLowerInvariant() + if (-not $MappedSiteKey) { continue } + $TeamSiteBySiteKey[$MappedSiteKey] = $Response.id + } + if ($TeamSiteFailures -gt 0) { + $TeamLinkResolutionStatus = 'Partial' + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SiteActivity: $TeamSiteFailures Team site lookups failed or returned no sharepointIds (continuing)" -sev Debug + } + } catch { + $TeamLinkResolutionStatus = 'Partial' + $TeamSiteError = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SiteActivity: unable to resolve Team SharePoint sites (continuing): $($TeamSiteError.NormalizedError)" -Sev 'Warning' -LogData $TeamSiteError + } + } + + $CachedAt = (Get-Date).ToString('o') + $OneDriveSiteIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Records = [System.Collections.Generic.List[object]]::new() + $SkippedIncomplete = 0 + + function Test-SiteActivityCacheComplete { + param( + [Parameter(Mandatory = $true)] + [string]$SiteType, + [string]$WebUrl, + [string]$DisplayName, + [string]$TeamsTeamId, + [string]$TeamsTeamName, + [string]$OwnerPrincipalName + ) + if ([string]::IsNullOrWhiteSpace($WebUrl)) { return $false } + if ([string]::IsNullOrWhiteSpace($DisplayName)) { return $false } + switch ($SiteType) { + 'OneDrive' { + if ([string]::IsNullOrWhiteSpace($OwnerPrincipalName)) { return $false } + } + 'SharePointAndTeams' { + if ([string]::IsNullOrWhiteSpace($TeamsTeamId)) { return $false } + if ([string]::IsNullOrWhiteSpace($TeamsTeamName)) { return $false } + } + } + return $true + } + + foreach ($Row in $OneDriveRows) { + if ($null -eq $Row -or $Row.isDeleted -eq $true) { continue } + if (-not $Row.siteId) { continue } + + $SiteKey = $Row.siteId.ToString().Trim().Trim('{}').ToLowerInvariant() + if (-not $SiteKey) { continue } + + $IndexedSite = $SiteIndex[$SiteKey] + $WebUrl = $Row.siteUrl + if ([string]::IsNullOrWhiteSpace($WebUrl)) { $WebUrl = $IndexedSite.webUrl } + + $DisplayName = $Row.displayName + if ([string]::IsNullOrWhiteSpace($DisplayName)) { $DisplayName = $IndexedSite.displayName } + if ([string]::IsNullOrWhiteSpace($DisplayName)) { $DisplayName = $Row.ownerDisplayName } + + if (-not (Test-SiteActivityCacheComplete -SiteType 'OneDrive' -WebUrl $WebUrl -DisplayName $DisplayName -OwnerPrincipalName $Row.ownerPrincipalName)) { + $SkippedIncomplete++ + continue + } + + [void]$OneDriveSiteIds.Add($SiteKey) + $OneDriveLastActivity = $Row.lastActivityDate + + $Records.Add([PSCustomObject]@{ + id = $SiteKey + siteId = $SiteKey + siteType = 'OneDrive' + effectiveLastActivityDate = $OneDriveLastActivity + webUrl = $WebUrl + displayName = $DisplayName + ownerDisplayName = $Row.ownerDisplayName + ownerPrincipalName = $Row.ownerPrincipalName + rootWebTemplate = $Row.rootWebTemplate + isPersonalSite = $true + isDeleted = $false + createdDateTime = $IndexedSite?.createdDateTime + webId = $IndexedSite?.sharepointIds?.webId + oneDriveLastActivityDate = $OneDriveLastActivity + oneDriveFileCount = $Row.fileCount + oneDriveStorageUsedInBytes = $Row.storageUsedInBytes + oneDriveStorageAllocatedInBytes = $Row.storageAllocatedInBytes + teamLinkResolutionStatus = $TeamLinkResolutionStatus + reportPeriod = 'D180' + cachedAt = $CachedAt + }) + } + + foreach ($Row in $SharePointRows) { + if ($null -eq $Row -or $Row.isDeleted -eq $true) { continue } + if (-not $Row.siteId) { continue } + + $SiteKey = $Row.siteId.ToString().Trim().Trim('{}').ToLowerInvariant() + if (-not $SiteKey) { continue } + if ($OneDriveSiteIds.Contains($SiteKey)) { continue } + if ($Row.rootWebTemplate -eq 'SPSPERS') { continue } + + $IndexedSite = $SiteIndex[$SiteKey] + if ($IndexedSite.isPersonalSite -eq $true) { continue } + + $WebUrl = $Row.siteUrl + if ([string]::IsNullOrWhiteSpace($WebUrl)) { $WebUrl = $IndexedSite.webUrl } + + $DisplayName = $Row.displayName + if ([string]::IsNullOrWhiteSpace($DisplayName)) { $DisplayName = $IndexedSite.displayName } + if ([string]::IsNullOrWhiteSpace($DisplayName)) { $DisplayName = $Row.ownerDisplayName } + + $SharePointLastActivity = $Row.lastActivityDate + $SiteType = 'SharePoint' + $TeamsTeamId = $null + $TeamsTeamName = $null + $TeamsLastActivity = $null + $TeamsLinkStatus = $null + $EffectiveLastActivity = $SharePointLastActivity + + if ($TeamSiteBySiteKey.ContainsKey($SiteKey)) { + $GroupId = $TeamSiteBySiteKey[$SiteKey] + $SiteType = 'SharePointAndTeams' + $TeamsTeamId = $GroupId + $ProvisionedGroup = $TeamProvisionedById[$GroupId] + $TeamsTeamName = $ProvisionedGroup.displayName + $TeamsLinkStatus = 'Linked' + + if ($TeamActivityById.ContainsKey($GroupId)) { + $TeamRow = $TeamActivityById[$GroupId] + if ($TeamRow.teamName) { $TeamsTeamName = $TeamRow.teamName } + $TeamsLastActivity = $TeamRow.lastActivityDate + } + + if ($TeamsLastActivity) { + if ($SharePointLastActivity) { + try { + if ([datetime]$TeamsLastActivity -gt [datetime]$SharePointLastActivity) { + $EffectiveLastActivity = $TeamsLastActivity + } + } catch { + $EffectiveLastActivity = $TeamsLastActivity + } + } else { + $EffectiveLastActivity = $TeamsLastActivity + } + } + } + + $Record = [PSCustomObject]@{ + id = $SiteKey + siteId = $SiteKey + siteType = $SiteType + effectiveLastActivityDate = $EffectiveLastActivity + webUrl = $WebUrl + displayName = $DisplayName + ownerDisplayName = $Row.ownerDisplayName + ownerPrincipalName = $Row.ownerPrincipalName + rootWebTemplate = $Row.rootWebTemplate + isPersonalSite = $false + isDeleted = $false + createdDateTime = $IndexedSite?.createdDateTime + webId = $IndexedSite?.sharepointIds?.webId + sharePointLastActivityDate = $SharePointLastActivity + sharePointFileCount = $Row.fileCount + sharePointStorageUsedInBytes = $Row.storageUsedInBytes + sharePointStorageAllocatedInBytes = $Row.storageAllocatedInBytes + teamLinkResolutionStatus = $TeamLinkResolutionStatus + reportPeriod = 'D180' + cachedAt = $CachedAt + } + + if ($SiteType -eq 'SharePointAndTeams') { + $Record | Add-Member -NotePropertyName 'teamsTeamId' -NotePropertyValue $TeamsTeamId -Force + $Record | Add-Member -NotePropertyName 'teamsTeamName' -NotePropertyValue $TeamsTeamName -Force + $Record | Add-Member -NotePropertyName 'teamsLastActivityDate' -NotePropertyValue $TeamsLastActivity -Force + $Record | Add-Member -NotePropertyName 'teamsLinkStatus' -NotePropertyValue $TeamsLinkStatus -Force + } + + if (-not (Test-SiteActivityCacheComplete -SiteType $SiteType -WebUrl $WebUrl -DisplayName $DisplayName -TeamsTeamId $TeamsTeamId -TeamsTeamName $TeamsTeamName)) { + $SkippedIncomplete++ + continue + } + + $Records.Add($Record) + } + + if ($SkippedIncomplete -gt 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SiteActivity: skipped $SkippedIncomplete report rows with incomplete required data" -sev Debug + } + + if ($Records.Count -eq 0) { + if ($SkippedIncomplete -gt 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SiteActivity: no rows were cache-complete (skipped $SkippedIncomplete); preserving existing SiteActivity cache" -sev Warning + return + } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No site activity rows to cache; writing empty SiteActivity cache' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SiteActivity' -Data @() -AddCount + return + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SiteActivity' -Data @($Records) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached SiteActivity successfully ($($Records.Count) sites)" -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SiteActivity: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 new file mode 100644 index 0000000000000..0404fc65e7f9f --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteActivity.ps1 @@ -0,0 +1,95 @@ +function Invoke-ListSiteActivity { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists cached site activity rows from the CIPP reporting database for SharePoint and Teams sites. + Supports tenantFilter and optional Type filter (SharePoint or TeamsSite). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = 'ListSiteActivity' + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $Type = $Request.Query.Type ?? $Request.Body.Type + + if (-not $TenantFilter) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = 'tenantFilter is required' + }) + } + + if ($Type -and $Type -notin @('SharePoint', 'TeamsSite')) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = 'Type must be SharePoint or TeamsSite' + }) + } + + try { + $TypeMap = @{ + SharePoint = 'SharePoint' + TeamsSite = 'SharePointAndTeams' + } + $SelectedSiteType = if ($Type) { $TypeMap[$Type] } else { $null } + + $AllResults = [System.Collections.Generic.List[object]]::new() + + if ($TenantFilter -eq 'AllTenants') { + $AnyItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'SiteActivity' + $Tenants = @($AnyItems | Where-Object { $_.RowKey -notlike '*-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + foreach ($Tenant in $Tenants) { + try { + $TenantRows = @(New-CIPPDbRequest -TenantFilter $Tenant -Type 'SiteActivity') + if (-not $TenantRows) { continue } + + $CountRow = Get-CIPPDbItem -TenantFilter $Tenant -Type 'SiteActivity' -CountsOnly | Select-Object -First 1 + $CacheTimestamp = $CountRow.Timestamp + + foreach ($Row in $TenantRows) { + if ($Row.siteType -eq 'OneDrive') { continue } + if ($SelectedSiteType -and $Row.siteType -ne $SelectedSiteType) { continue } + + $Row | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $Row | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + [void]$AllResults.Add($Row) + } + } catch { + Write-LogMessage -API $APIName -tenant $Tenant -message "Failed to retrieve cached site activity: $($_.Exception.Message)" -sev Warning + } + } + } else { + $TenantRows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SiteActivity') + $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'SiteActivity' -CountsOnly | Select-Object -First 1 + $CacheTimestamp = $CountRow.Timestamp + + foreach ($Row in $TenantRows) { + if ($Row.siteType -eq 'OneDrive') { continue } + if ($SelectedSiteType -and $Row.siteType -ne $SelectedSiteType) { continue } + + $Row | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + [void]$AllResults.Add($Row) + } + } + + $GraphRequest = @($AllResults | Sort-Object -Property displayName) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to list site activity: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = @{ Error = $ErrorMessage.NormalizedError } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) +}