Files
request-portal/provisioning/Provision-Lists.ps1
Тимур Городецкий 6712d050a5
Tests / frontend-test (push) Successful in 1m13s
feat: вебхук-подписка новых списков в провизионере
2026-09-25 10:08:08 +05:00

457 lines
26 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
Идемпотентно создаёт списки данных форм портала заявок в SharePoint Online
по манифестам provisioning.json (единый источник правды по колонкам).
.DESCRIPTION
Обходит ВСЕ манифесты forms/**/provisioning.json (в т.ч. подпапки approval/)
ДВУМЯ проходами:
1) создаёт ВСЕ списки (которых ещё нет);
2) по каждому манифесту: при необходимости переименовывает колонку Title и
обеспечивает колонки.
Проходы разделены из-за Lookup'ов между формами: список-источник должен
существовать раньше колонки, а при перекрёстных ссылках (две формы ссылаются
друг на друга) одним проходом это недостижимо ни при каком порядке обхода.
Имена списков НУМЕРОВАННЫЕ: «<Форма> <N> — данные» / «<Форма> <N> — согласование».
Номер собирает сам скрипт из имени в манифесте (см. Get-NumberedListTitle) — в
манифестах имена остаются без номера, чтобы номер не дублировался по 14 манифестам
и ссылкам lookupList. Нумерация нужна под архивацию: когда список данных упирается
в порог элементов, рядом создаётся следующая пара с номером N+1.
Перед основным проходом идёт разовая МИГРАЦИЯ имён (только при -ListNumber 1):
существующие списки со старым безномерным именем переименовываются в «… 1 — …».
Переименовывается только Title — server-relative URL списка при этом НЕ меняется,
поэтому ссылки на список (request_type.list_id в approval-route) остаются валидными.
Колонки — через СТОЛБЦЫ САЙТА (эталоны):
- не-Lookup колонка сначала создаётся как столбец сайта (группа «Портал заявок»),
один раз на весь сайт, затем ПОДКЛЮЧАЕТСЯ к списку по ссылке. Так одинаковые
колонки (Status, Phase, StepOrder …) определены в одном месте и единообразны;
менять их (напр. варианты Choice) можно в эталоне. Choice при повторе объединяет
варианты (union) — наборы разных форм не затирают друг друга.
- Lookup остаётся столбцом СПИСКА: эталон-lookup привязан к одному списку, а у нас
Ticket у каждого списка согласования ссылается на свой список данных.
Повторный запуск безопасен (идемпотентно): существующие эталоны/колонки не
пересоздаются. Имена колонок (internalName) — как в манифестах, к ним обращается код.
Вход — по сертификату «от имени приложения» (app-only, право Sites.Selected).
Сертификат берётся из личного хранилища по отпечатку (Thumbprint).
.PARAMETER SiteUrl
URL сайта, например https://tnsukz.sharepoint.com/sites/requests
.PARAMETER ClientId
Client Id приложения-провижинера (Sites.Selected).
.PARAMETER Tenant
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My.
.PARAMETER FormsRoot
Папка с формами. По умолчанию — src/webparts/requestPortal/forms.
.PARAMETER ListNumber
Номер пары списков. 1 — текущие (рабочие) списки; 2, 3 … — следующие пары под
архивацию. Миграция безномерных имён выполняется только при 1.
.PARAMETER SkipRename
Не выполнять миграцию имён существующих списков (только создание/колонки).
.PARAMETER WebhookToken
Bearer-токен Microsoft (JWT, без слова «Bearer») для api.tnsu.kz. Если задан — на
КАЖДЫЙ НОВЫЙ список (и «— данные», и «— согласование») оформляется вебхук-подписка
через POST https://api.tnsu.kz/Webhook/Subscribe: бэкенд подписывается на изменения
в списке, уведомления идут на https://api.tnsu.kz/Webhook/SharePoint. Уже
существующие списки не подписываются. Не задан — списки создаются как раньше, по
каждому новому выводится предупреждение (подписать вручную). Токен живёт ~1 час —
берите свежий перед запуском. В коде и репозитории токен не хранится.
.EXAMPLE
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "AABBCC...."
.EXAMPLE
# Следующая пара списков под архивацию, без миграции имён.
./Provision-Lists.ps1 -SiteUrl "..." -ClientId "..." -Tenant "..." -Thumbprint "..." `
-ListNumber 2
.EXAMPLE
# С вебхук-подпиской новых списков.
./Provision-Lists.ps1 -SiteUrl "..." -ClientId "..." -Tenant "..." -Thumbprint "..." `
-WebhookToken "eyJ0eXAiOiJKV1Qi..."
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[string]$FormsRoot = "$PSScriptRoot/../src/webparts/requestPortal/forms",
[ValidateRange(1, 999)][int]$ListNumber = 1,
[switch]$SkipRename,
[string]$WebhookToken
)
$ErrorActionPreference = 'Stop'
# Вебхук-подписка новых списков (см. .PARAMETER WebhookToken).
$WEBHOOK_SUBSCRIBE_URL = 'https://api.tnsu.kz/Webhook/Subscribe'
$WEBHOOK_NOTIFICATION_URL = 'https://api.tnsu.kz/Webhook/SharePoint'
# Экранирование значений, попадающих в XML полей (Lookup/Number с точностью).
function ConvertTo-XmlSafe {
param([string]$Value)
return $Value.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;').Replace("'", '&apos;').Replace('"', '&quot;')
}
# Имя списка по конвенции: «<Форма> — данные» / «<Форма> — согласование».
$LIST_TITLE_PATTERN = '^(?<base>.+?)\s*—\s*(?<suffix>данные|согласование)\s*$'
# Имя в манифесте → нумерованное «<Форма> <N> — <суффикс>».
#
# Номер ставится ПЕРЕД суффиксом, и это не косметика: и портал
# (features/myRequests/myRequests.service.ts), и админка маршрутов
# (approval-route/frontend/.../requestManageService.ts) выводят имя списка
# согласования из имени списка данных регексом /\s*—\s*данные\s*$/, привязанным к
# КОНЦУ строки. «… — данные 1» этот регекс не матчит, и список согласования просто
# перестаёт находиться.
#
# Идемпотентно: уже нумерованное имя не получает второй номер, а перенумеровывается.
function Get-NumberedListTitle {
param([string]$Title, [int]$Number)
$m = [regex]::Match($Title, $LIST_TITLE_PATTERN)
if (-not $m.Success) {
throw "Имя списка «$Title» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда"
}
$base = $m.Groups['base'].Value -replace '\s+\d+$', ''
return "$base $Number — $($m.Groups['suffix'].Value)"
}
# Есть ли колонка НА СПИСКЕ (list-scope).
function Test-FieldExists {
param([string]$List, [string]$InternalName)
return $null -ne (Get-PnPField -List $List -Identity $InternalName -ErrorAction SilentlyContinue)
}
# Есть ли СТОЛБЕЦ САЙТА (site-scope, галерея столбцов сайта).
function Test-SiteFieldExists {
param([string]$InternalName)
return $null -ne (Get-PnPField -Identity $InternalName -ErrorAction SilentlyContinue)
}
$SITE_COLUMN_GROUP = 'Портал заявок'
# Типы, которые НЕ делаем эталоном (столбцом сайта), а создаём столбцом списка:
# - Lookup (эталон привязан к одному списку, а Ticket у каждого свой);
# - Number с decimals, UserMulti и MultiChoice (создаются через XML; web-level XML
# в PnP падает NRE, и это одноразовые поля конкретной формы — переиспользовать нечего).
function Test-NeedsListColumn {
param($Col)
return ($Col.type -eq 'Lookup') `
-or ($Col.type -eq 'Number' -and $null -ne $Col.decimals) `
-or ($Col.type -eq 'User' -and $Col.allowMultiple) `
-or ($Col.type -eq 'Choice' -and $Col.allowMultiple)
}
# Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые
# типы (без XML). Для Choice при повторе объединяет варианты (union), чтобы наборы
# разных форм не затирали друг друга.
function Ensure-SiteColumn {
param($Col)
if (Test-SiteFieldExists -InternalName $Col.internalName) {
if ($Col.type -eq 'Choice' -and $Col.choices) {
$existing = (Get-PnPField -Identity $Col.internalName).Choices
$merged = @($existing) + @($Col.choices) | Select-Object -Unique
Set-PnPField -Identity $Col.internalName -Values @{ Choices = [string[]]$merged } | Out-Null
}
return
}
$g = $SITE_COLUMN_GROUP
switch ($Col.type) {
'Text' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Text -Group $g | Out-Null }
'Note' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Note -Group $g | Out-Null }
'DateTime' {
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -Group $g | Out-Null
if ($Col.dateOnly) {
Set-PnPField -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null
}
}
'Boolean' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -Group $g | Out-Null }
'User' {
Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type User -Group $g | Out-Null
if ($Col.userSelectionMode) {
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
Set-PnPField -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
}
}
'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null }
'Number' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null }
'Url' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type URL -Group $g | Out-Null }
default { throw "Неизвестный тип столбца сайта '$($Col.type)'" }
}
Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
}
# Создаёт колонку прямо НА СПИСКЕ (для Lookup / XML-типов). Идемпотентно.
function Add-ListColumn {
param([string]$List, $Col)
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
# Тип существующей колонки скрипт НЕ меняет (данные!) — одиночный выбор в
# множественный переключают вручную: параметры столбца → «флажки».
if ($Col.type -eq 'Choice' -and $Col.allowMultiple) {
$existingType = (Get-PnPField -List $List -Identity $Col.internalName).TypeAsString
if ($existingType -ne 'MultiChoice') {
Write-Warning " [!] $($Col.internalName): в списке одиночный выбор ($existingType), а нужен множественный — переключите столбец на «флажки» вручную"
}
}
if ($Col.type -eq 'Choice' -and $Col.choices) {
try {
Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null
Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray
}
catch { Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)" }
}
else { Write-Host " [=] столбец списка есть: $($Col.internalName)" -ForegroundColor DarkGray }
return
}
try {
switch ($Col.type) {
'Text' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Text -AddToDefaultView | Out-Null }
'Note' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Note -AddToDefaultView | Out-Null }
'DateTime' {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -AddToDefaultView | Out-Null
if ($Col.dateOnly) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null }
}
'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -AddToDefaultView | Out-Null }
'User' {
if ($Col.allowMultiple) {
$xml = "<Field Type='UserMulti' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Mult='TRUE' />"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
else {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type User -AddToDefaultView | Out-Null
}
if ($Col.userSelectionMode) {
$mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 }
Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null
}
}
'Choice' {
if ($Col.allowMultiple) {
$choicesXml = ($Col.choices | ForEach-Object { "<CHOICE>$(ConvertTo-XmlSafe $_)</CHOICE>" }) -join ''
$xml = "<Field Type='MultiChoice' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' FillInChoice='FALSE'><CHOICES>$choicesXml</CHOICES></Field>"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
else {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null
}
}
'Url' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type URL -AddToDefaultView | Out-Null }
'Number' {
if ($null -ne $Col.decimals) {
$xml = "<Field Type='Number' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' Decimals='$($Col.decimals)' />"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
else {
Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Number -AddToDefaultView | Out-Null
}
}
'Lookup' {
# lookupList в манифесте — безномерное имя, целевой список тоже нумерован.
# Кросс-формные lookup'ы (WorkplaceSetup.SourceHire → «Прием на работу … — данные»)
# при этом предполагают ОДИН И ТОТ ЖЕ номер у обеих форм — при появлении
# реальных архивных пар это место придётся пересмотреть.
$lookupTitle = Get-NumberedListTitle -Title $Col.lookupList -Number $ListNumber
$src = Get-PnPList -Identity $lookupTitle -ErrorAction Stop
$guid = $src.Id.ToString()
$show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' }
$xml = "<Field Type='Lookup' DisplayName='$(ConvertTo-XmlSafe $Col.title)' Name='$($Col.internalName)' ShowField='$show' List='{$guid}' />"
Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null
}
default { throw "Неизвестный тип колонки '$($Col.type)'" }
}
if ($Col.required) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null }
Write-Host " [+] столбец списка: $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green
}
catch { Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)" }
}
# Обеспечивает колонку НА СПИСКЕ: простые типы — из эталона (столбец сайта →
# подключить), Lookup/XML-типы — обычным столбцом списка.
function Add-ColumnToList {
param([string]$List, $Col)
if (Test-NeedsListColumn -Col $Col) {
Add-ListColumn -List $List -Col $Col
return
}
# 1) эталон на сайте (создать/обновить)
try {
Ensure-SiteColumn -Col $Col
}
catch {
Write-Warning " [!] эталон $($Col.internalName): $($_.Exception.Message)"
return
}
# 2) подключить эталон к списку (если ещё не подключён). Без -AddToDefaultView:
# с параметром -Field он несовместим (другой набор параметров cmdlet).
if (Test-FieldExists -List $List -InternalName $Col.internalName) {
Write-Host " [=] уже на списке: $($Col.internalName)" -ForegroundColor DarkGray
}
else {
try {
Add-PnPField -List $List -Field $Col.internalName | Out-Null
Write-Host " [+] подключён эталон: $($Col.internalName)" -ForegroundColor Green
}
catch {
Write-Warning " [!] подключение $($Col.internalName): $($_.Exception.Message)"
}
}
# 3) обязательность — настройка уровня списка (у поля может отличаться по спискам)
if ($Col.required) {
try { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null } catch {}
}
}
# Оформляет вебхук-подписку на список через бэкенд api.tnsu.kz. Ошибка подписки
# провизионинг НЕ останавливает: список уже создан, подписать можно и вручную.
function Register-ListWebhook {
param([string]$ListTitle)
if (-not $WebhookToken) {
Write-Warning " [!] вебхук: «$ListTitle» не подписан — не передан -WebhookToken, подпишите список вручную"
return
}
# Токен могли вставить вместе со словом «Bearer» — убираем, иначе заголовок сломается.
$token = ($WebhookToken -replace '^\s*Bearer\s+', '').Trim()
$query = @(
"listName=$([uri]::EscapeDataString($ListTitle))",
"url=$([uri]::EscapeDataString($SiteUrl.TrimEnd('/')))",
"notificationUrl=$([uri]::EscapeDataString($WEBHOOK_NOTIFICATION_URL))"
) -join '&'
try {
Invoke-RestMethod -Method Post -Uri "$($WEBHOOK_SUBSCRIBE_URL)?$query" `
-Headers @{ Authorization = "Bearer $token"; Accept = '*/*' } | Out-Null
Write-Host " [+] вебхук: подписан «$ListTitle»" -ForegroundColor Green
}
catch {
Write-Warning " [!] вебхук: «$ListTitle» не подписан — $($_.Exception.Message) (подпишите список вручную)"
}
}
Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$manifests = Get-ChildItem -Path $FormsRoot -Recurse -Filter 'provisioning.json'
if (-not $manifests) {
Write-Warning "Манифесты provisioning.json не найдены в $FormsRoot"
Disconnect-PnPOnline
return
}
# Проход 0: миграция безномерных имён на «… 1 — …». Отдельным проходом ДО основного —
# так порядок обхода манифестов не важен: к моменту разрешения Lookup'а (по имени
# списка-источника) все списки уже переименованы.
#
# Переименовывается только Title. URL списка при переименовании Title в SharePoint не
# меняется, поэтому request_type.list_id (server-relative URL) в approval-route
# трогать не нужно.
if ($ListNumber -eq 1 -and -not $SkipRename) {
Write-Host "`n=== Миграция имён списков на «… 1 — …» ===" -ForegroundColor Cyan
foreach ($manifest in $manifests) {
$def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
$legacyTitle = $def.dataListTitle
$targetTitle = Get-NumberedListTitle -Title $legacyTitle -Number 1
if (Get-PnPList -Identity $targetTitle -ErrorAction SilentlyContinue) {
Write-Host " [=] уже нумерован: «$targetTitle»" -ForegroundColor DarkGray
continue
}
if (-not (Get-PnPList -Identity $legacyTitle -ErrorAction SilentlyContinue)) {
Write-Host " [ ] нет списка «$legacyTitle» — создастся ниже как «$targetTitle»" -ForegroundColor DarkGray
continue
}
try {
Set-PnPList -Identity $legacyTitle -Title $targetTitle | Out-Null
Write-Host " [~] «$legacyTitle» → «$targetTitle»" -ForegroundColor Yellow
}
catch {
Write-Warning " [!] не удалось переименовать «$legacyTitle»: $($_.Exception.Message)"
}
}
}
# Проход 1: САМИ СПИСКИ — все до единого, ДО колонок.
#
# Раньше список и его колонки создавались одним проходом по манифесту, и Lookup на
# список ДРУГОЙ формы (WorkplaceSetup.SourceHire → «Прием на работу … — данные»)
# резолвился только если та форма попадалась в обходе раньше — то есть держался на
# алфавитном везении, а для взаимных ссылок был бы невозможен в принципе. Поэтому
# сначала все списки, потом все колонки: к моменту разрешения любого Lookup'а
# список-источник уже существует.
Write-Host "`n=== Списки ===" -ForegroundColor Cyan
# Созданные В ЭТОМ запуске — только их подписываем на вебхук (см. конец скрипта).
$createdLists = [System.Collections.Generic.List[string]]::new()
foreach ($manifest in $manifests) {
$def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
$listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber
if (-not (Get-PnPList -Identity $listTitle -ErrorAction SilentlyContinue)) {
New-PnPList -Title $listTitle -Template GenericList -EnableVersioning | Out-Null
$createdLists.Add($listTitle)
Write-Host " [+] создан список «$listTitle»" -ForegroundColor Green
}
else {
Write-Host " [=] список уже существует: «$listTitle»" -ForegroundColor DarkGray
}
}
# Проход 2: колонки (и переименование Title) — по одному манифесту за раз.
foreach ($manifest in $manifests) {
$def = Get-Content -Path $manifest.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
$listTitle = Get-NumberedListTitle -Title $def.dataListTitle -Number $ListNumber
Write-Host "`n=== Форма: $($manifest.Directory.Name) → список «$listTitle» ===" -ForegroundColor Cyan
# 1) Переименование Title (если задано)
if ($def.titleColumnName) {
try {
Set-PnPField -List $listTitle -Identity 'Title' -Values @{ Title = $def.titleColumnName } | Out-Null
Write-Host " [~] Title → «$($def.titleColumnName)»" -ForegroundColor DarkGray
}
catch {
Write-Warning " [!] не удалось переименовать Title: $($_.Exception.Message)"
}
}
# 2) Колонки (идемпотентно): не-Lookup — через эталоны (столбцы сайта), Lookup — на список.
foreach ($col in $def.columns) {
Add-ColumnToList -List $listTitle -Col $col
}
}
# Проход 3: вебхуки — ПОСЛЕ колонок, чтобы бэкенд подписывался на уже готовый список.
# Только новые списки: существующие подписаны раньше (или вручную).
if ($createdLists.Count -gt 0) {
Write-Host "`n=== Вебхуки новых списков ===" -ForegroundColor Cyan
foreach ($listTitle in $createdLists) {
Register-ListWebhook -ListTitle $listTitle
}
}
Write-Host "`nГотово." -ForegroundColor Cyan
Disconnect-PnPOnline