chore(platform): merge platform-dev into dev (#308)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`. Refresh method in this update: merge from `origin/dev` into `platform-dev` on explicit user request. This PR was created by agent on user request; do not merge automatically. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #308
This commit is contained in:
parent
905b595880
commit
61feb48d8a
625
.github/skills/platform-feature-finish/SKILL.md
vendored
Normal file
625
.github/skills/platform-feature-finish/SKILL.md
vendored
Normal file
@ -0,0 +1,625 @@
|
|||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
name: platform-feature-finish
|
||||||
|
description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: platform-feature-finish
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Automate the TenantPilot platform feature completion workflow.
|
||||||
|
|
||||||
|
Trigger this skill when the user says something like:
|
||||||
|
|
||||||
|
- "alles committen pushen und PR gegen platform-dev"
|
||||||
|
- "feature fertig, bitte PR erstellen"
|
||||||
|
- "platform feature abschließen"
|
||||||
|
- "commit push PR mit Gitea MCP"
|
||||||
|
- "mach PR gegen platform-dev"
|
||||||
|
- "finish platform feature"
|
||||||
|
- "platform-dev nach dev vorbereiten"
|
||||||
|
- "platform-dev PR aktualisieren"
|
||||||
|
- "out-of-date mit dev beheben"
|
||||||
|
- "integration PR refresh"
|
||||||
|
- "platform-dev auf dev rebasen"
|
||||||
|
|
||||||
|
This skill handles:
|
||||||
|
|
||||||
|
1. Validate current Git branch
|
||||||
|
2. Commit all feature changes
|
||||||
|
3. Push current feature branch
|
||||||
|
4. Create a Gitea pull request into `platform-dev`
|
||||||
|
5. Refresh the `platform-dev` → `dev` integration PR when explicitly requested
|
||||||
|
6. Report the PR link and next integration step
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Model
|
||||||
|
|
||||||
|
TenantPilot uses area branches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dev = shared integration branch
|
||||||
|
platform-dev = platform/application area integration branch
|
||||||
|
website-dev = website/marketing area integration branch
|
||||||
|
```
|
||||||
|
|
||||||
|
For platform features:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev
|
||||||
|
↓
|
||||||
|
feature branch
|
||||||
|
↓
|
||||||
|
PR back to platform-dev
|
||||||
|
↓
|
||||||
|
platform-dev → dev integration PR
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Platform feature branches MUST target `platform-dev`.
|
||||||
|
- Do NOT target `dev` directly unless the user explicitly asks.
|
||||||
|
- Do NOT use `website-dev` for platform features.
|
||||||
|
- `platform-dev` is the default PR base for TenantPilot platform/application work.
|
||||||
|
- `dev` is the shared integration branch.
|
||||||
|
|
||||||
|
### Solo Workflow Rule
|
||||||
|
|
||||||
|
The user works alone on `platform-dev`.
|
||||||
|
|
||||||
|
For refreshing the integration branch before opening or updating the PR `platform-dev` → `dev`, prefer rebase over merge.
|
||||||
|
|
||||||
|
Do not repeatedly merge `origin/dev` into `platform-dev` for refresh.
|
||||||
|
|
||||||
|
Avoid creating repeated merge commits like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Merge remote-tracking branch 'origin/dev' into platform-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--force-with-lease`, never plain `--force`.
|
||||||
|
|
||||||
|
If rebase conflicts occur, stop and report the conflict files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
1. Confirm repository root.
|
||||||
|
2. Confirm current branch is not protected.
|
||||||
|
|
||||||
|
Protected branches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dev
|
||||||
|
platform-dev
|
||||||
|
website-dev
|
||||||
|
main
|
||||||
|
master
|
||||||
|
```
|
||||||
|
|
||||||
|
If the current branch is protected, STOP and report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Confirm remote exists.
|
||||||
|
4. Confirm there are local changes, untracked files, or unpushed commits.
|
||||||
|
5. Confirm there are no unresolved conflicts.
|
||||||
|
|
||||||
|
Do not ask for confirmation unless:
|
||||||
|
|
||||||
|
- The current branch is protected.
|
||||||
|
- Git status indicates unresolved conflicts.
|
||||||
|
- There is no remote configured.
|
||||||
|
- `.env` or other local secret/config files would be committed.
|
||||||
|
- Commit fails.
|
||||||
|
- Push fails.
|
||||||
|
- Gitea MCP PR creation fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Tools
|
||||||
|
|
||||||
|
Use terminal for Git operations.
|
||||||
|
|
||||||
|
Use Gitea MCP for pull request creation.
|
||||||
|
|
||||||
|
Preferred Gitea MCP operation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
create_pull_request
|
||||||
|
```
|
||||||
|
|
||||||
|
Required PR parameters:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "ahmido",
|
||||||
|
"repo": "TenantAtlas",
|
||||||
|
"head": "<current-feature-branch>",
|
||||||
|
"base": "platform-dev",
|
||||||
|
"title": "<generated-title>",
|
||||||
|
"body": "<generated-body>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1 — Inspect Git state
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
git status --porcelain
|
||||||
|
git status -sb
|
||||||
|
git config --get remote.origin.url
|
||||||
|
git log --oneline --max-count=5
|
||||||
|
```
|
||||||
|
|
||||||
|
Determine:
|
||||||
|
|
||||||
|
- repository root
|
||||||
|
- current branch
|
||||||
|
- changed files
|
||||||
|
- untracked files
|
||||||
|
- remote URL
|
||||||
|
- whether there are unpushed commits
|
||||||
|
- whether unresolved conflicts exist
|
||||||
|
|
||||||
|
If the current branch is protected, stop.
|
||||||
|
|
||||||
|
If unresolved conflicts exist, stop.
|
||||||
|
|
||||||
|
If no remote exists, stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Check for local environment files
|
||||||
|
|
||||||
|
Before `git add -A`, check whether local environment/config files are modified or untracked:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
If `.env` or another environment file is included, STOP and report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not commit secrets or local runtime configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Build commit message
|
||||||
|
|
||||||
|
Use the current branch name.
|
||||||
|
|
||||||
|
If branch starts with a spec number, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
256-external-support-desk-handoff
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(specs/256): external support desk handoff
|
||||||
|
```
|
||||||
|
|
||||||
|
If branch does not contain a spec number, generate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(platform): complete <branch-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Use lowercase subject.
|
||||||
|
- Use feature-style subject.
|
||||||
|
- Do not include `WIP`.
|
||||||
|
- Do not include `final`.
|
||||||
|
- Do not include overly generic `updates`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat(specs/256): external support desk handoff
|
||||||
|
feat(specs/252): platform localization v1
|
||||||
|
feat(platform): improve tenant review workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4 — Commit all changes
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<commit-message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are no local changes to commit, continue only if the branch has unpushed commits.
|
||||||
|
|
||||||
|
Check unpushed commits with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status -sb
|
||||||
|
git log --oneline origin/<current-branch>..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are no local changes and no unpushed commits, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then continue to PR creation only if the branch already exists remotely or can be pushed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5 — Push branch
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --set-upstream origin <current-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the upstream already exists, this is acceptable.
|
||||||
|
|
||||||
|
Never force-push unless the user explicitly requests it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6 — Create PR into platform-dev via Gitea MCP
|
||||||
|
|
||||||
|
Use Gitea MCP to create a pull request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "ahmido",
|
||||||
|
"repo": "TenantAtlas",
|
||||||
|
"head": "<current-feature-branch>",
|
||||||
|
"base": "platform-dev",
|
||||||
|
"title": "<commit-message>",
|
||||||
|
"body": "Implements platform feature branch `<current-feature-branch>`.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev` → `dev`."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a PR already exists for the same branch and base, do not create a duplicate.
|
||||||
|
|
||||||
|
Report the existing PR if available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional Step — Check platform-dev to dev PR
|
||||||
|
|
||||||
|
After creating the feature PR, check whether an open integration PR exists:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev → dev
|
||||||
|
```
|
||||||
|
|
||||||
|
If a Gitea MCP list/search pull request function is available, use it.
|
||||||
|
|
||||||
|
If one exists, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Der Folge-PR `platform-dev` → `dev` existiert bereits: <url>
|
||||||
|
```
|
||||||
|
|
||||||
|
If none exists, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev` → `dev` erstellt oder aktualisiert werden.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not automatically create the `platform-dev` → `dev` PR unless the user explicitly asks for it.
|
||||||
|
|
||||||
|
Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Refresh Mode
|
||||||
|
|
||||||
|
Use this mode when the user explicitly says one of the following:
|
||||||
|
|
||||||
|
- "platform-dev nach dev vorbereiten"
|
||||||
|
- "platform-dev PR aktualisieren"
|
||||||
|
- "out-of-date mit dev beheben"
|
||||||
|
- "integration PR refresh"
|
||||||
|
- "platform-dev auf dev rebasen"
|
||||||
|
- "auch platform-dev nach dev"
|
||||||
|
- "und danach platform-dev nach dev"
|
||||||
|
- "full integration"
|
||||||
|
- "kompletten platform-dev zu dev PR machen"
|
||||||
|
- "folge-pr erstellen"
|
||||||
|
|
||||||
|
This mode prepares or updates the integration PR:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev → dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the user works alone on `platform-dev`, prefer rebase over merge.
|
||||||
|
|
||||||
|
### Integration Refresh Preconditions
|
||||||
|
|
||||||
|
Before running this mode:
|
||||||
|
|
||||||
|
1. Ensure the working tree is clean.
|
||||||
|
2. Ensure there are no unresolved conflicts.
|
||||||
|
3. Fetch remote branches.
|
||||||
|
4. Ensure `origin/platform-dev` exists.
|
||||||
|
5. Ensure `origin/dev` exists.
|
||||||
|
|
||||||
|
If the working tree is dirty, STOP and report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird.
|
||||||
|
```
|
||||||
|
|
||||||
|
If unresolved conflicts exist, STOP and report the conflict files.
|
||||||
|
|
||||||
|
### Integration Refresh Workflow
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout platform-dev
|
||||||
|
git reset --hard origin/platform-dev
|
||||||
|
git rebase origin/dev
|
||||||
|
git push --force-with-lease origin platform-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git merge-base --is-ancestor origin/dev origin/platform-dev \
|
||||||
|
&& echo "OK: platform-dev contains dev" \
|
||||||
|
|| echo "OUTDATED: platform-dev does not contain dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not merge `origin/dev` into `platform-dev` for this refresh.
|
||||||
|
- Do not create repeated merge commits from `origin/dev` into `platform-dev`.
|
||||||
|
- Use `git push --force-with-lease origin platform-dev` after a successful rebase.
|
||||||
|
- Never use plain `git push --force`.
|
||||||
|
- If `git rebase origin/dev` reports conflicts, stop immediately.
|
||||||
|
- Do not continue to PR creation while a rebase is unresolved.
|
||||||
|
- Do not auto-merge the PR.
|
||||||
|
- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds.
|
||||||
|
|
||||||
|
If rebase conflicts occur, report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Rebase-Konflikte erkannt. Ich habe gestoppt.
|
||||||
|
|
||||||
|
Konfliktdateien:
|
||||||
|
<files>
|
||||||
|
|
||||||
|
Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create or Report Integration PR
|
||||||
|
|
||||||
|
After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "ahmido",
|
||||||
|
"repo": "TenantAtlas",
|
||||||
|
"head": "platform-dev",
|
||||||
|
"base": "dev",
|
||||||
|
"title": "chore(platform): merge platform-dev into dev",
|
||||||
|
"body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If an open PR already exists for `platform-dev` → `dev`, do not create a duplicate. Report the existing PR.
|
||||||
|
|
||||||
|
### Integration Refresh Reporting Format
|
||||||
|
|
||||||
|
Final response for this mode must include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fertig.
|
||||||
|
|
||||||
|
- Branch aktualisiert: platform-dev
|
||||||
|
- Refresh-Methode: rebase auf origin/dev
|
||||||
|
- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev
|
||||||
|
- Push: --force-with-lease origin/platform-dev
|
||||||
|
- Integration PR: <url>
|
||||||
|
- Base: dev
|
||||||
|
- Hinweis: PR wurde nicht automatisch gemerged.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not claim tests passed unless they were actually executed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting Format
|
||||||
|
|
||||||
|
Final response must be concise and include:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fertig.
|
||||||
|
|
||||||
|
- Branch: <branch>
|
||||||
|
- Commit: <commit-sha or "keine neuen Änderungen">
|
||||||
|
- Push: origin/<branch>
|
||||||
|
- PR: <url>
|
||||||
|
- Base: platform-dev
|
||||||
|
- Nächster Schritt: Nach Merge `platform-dev` → `dev` PR aktualisieren/erstellen
|
||||||
|
```
|
||||||
|
|
||||||
|
If tests were not run, say:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests wurden in diesem Skill nicht automatisch ausgeführt.
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not claim tests passed unless the tool actually ran them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`.
|
||||||
|
- Never force-push unless explicitly requested.
|
||||||
|
- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`.
|
||||||
|
- Never auto-merge PRs unless explicitly requested.
|
||||||
|
- Never target `dev` directly for platform feature PRs unless explicitly requested.
|
||||||
|
- Never delete branches unless explicitly requested.
|
||||||
|
- Never claim tests were run unless the tool actually ran them.
|
||||||
|
- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes.
|
||||||
|
- If migrations were created, mention that the target environment needs migration execution after deployment.
|
||||||
|
- If unresolved conflicts exist, stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
Inspect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
git rev-parse --abbrev-ref HEAD
|
||||||
|
git status --porcelain
|
||||||
|
git status -sb
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
Detect protected branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
case "$branch" in
|
||||||
|
dev|platform-dev|website-dev|main|master)
|
||||||
|
echo "PROTECTED_BRANCH:$branch"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
Detect unresolved conflicts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only --diff-filter=U
|
||||||
|
```
|
||||||
|
|
||||||
|
Detect `.env` changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Latest commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --short HEAD
|
||||||
|
git log -1 --pretty=%s
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration refresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout platform-dev
|
||||||
|
git reset --hard origin/platform-dev
|
||||||
|
git rebase origin/dev
|
||||||
|
git push --force-with-lease origin platform-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify integration refresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git merge-base --is-ancestor origin/dev origin/platform-dev \
|
||||||
|
&& echo "OK: platform-dev contains dev" \
|
||||||
|
|| echo "OUTDATED: platform-dev does not contain dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
Check rebase conflicts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only --diff-filter=U
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example User Request
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
alles committen pushen und pr gegen platform-dev mit gitea mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Assistant should:
|
||||||
|
|
||||||
|
1. Check current branch.
|
||||||
|
2. Stop if branch is protected.
|
||||||
|
3. Stop if `.env` or secrets would be committed.
|
||||||
|
4. Commit all changes.
|
||||||
|
5. Push current branch.
|
||||||
|
6. Create PR into `platform-dev` with Gitea MCP.
|
||||||
|
7. Report result.
|
||||||
|
|
||||||
|
Do not ask unnecessary follow-up questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Integration Refresh Request
|
||||||
|
|
||||||
|
User:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platform-dev PR aktualisieren
|
||||||
|
```
|
||||||
|
|
||||||
|
Assistant should:
|
||||||
|
|
||||||
|
1. Ensure the working tree is clean.
|
||||||
|
2. Fetch origin.
|
||||||
|
3. Checkout `platform-dev`.
|
||||||
|
4. Reset local `platform-dev` to `origin/platform-dev`.
|
||||||
|
5. Rebase `platform-dev` onto `origin/dev`.
|
||||||
|
6. Push with `--force-with-lease`.
|
||||||
|
7. Verify `origin/dev` is an ancestor of `origin/platform-dev`.
|
||||||
|
8. Create or report the PR `platform-dev` → `dev`.
|
||||||
|
9. Report result.
|
||||||
|
|
||||||
|
Do not merge the PR automatically.
|
||||||
674
apps/platform/app/Filament/Pages/CrossTenantComparePage.php
Normal file
674
apps/platform/app/Filament/Pages/CrossTenantComparePage.php
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||||
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class CrossTenantComparePage extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
|
||||||
|
|
||||||
|
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
|
||||||
|
|
||||||
|
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Cross-Tenant Compare';
|
||||||
|
|
||||||
|
protected static ?string $slug = 'cross-tenant-compare';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.cross-tenant-compare';
|
||||||
|
|
||||||
|
public ?string $sourceTenantId = null;
|
||||||
|
|
||||||
|
public ?string $targetTenantId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $selectedPolicyTypes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $preview = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $preflight = null;
|
||||||
|
|
||||||
|
public ?string $selectionMessage = null;
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
|
||||||
|
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
|
||||||
|
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||||
|
|
||||||
|
$this->hydrateSelectionFromRequest();
|
||||||
|
$this->refreshPreview();
|
||||||
|
|
||||||
|
$this->form->fill($this->formState());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Grid::make([
|
||||||
|
'default' => 1,
|
||||||
|
'xl' => 3,
|
||||||
|
])
|
||||||
|
->schema([
|
||||||
|
Select::make('sourceTenantId')
|
||||||
|
->label('Source tenant')
|
||||||
|
->options(fn (): array => $this->tenantOptions())
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('Select a source tenant')
|
||||||
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
|
||||||
|
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
|
||||||
|
Select::make('targetTenantId')
|
||||||
|
->label('Target tenant')
|
||||||
|
->options(fn (): array => $this->tenantOptions())
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('Select a target tenant')
|
||||||
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
|
||||||
|
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
|
||||||
|
Select::make('selectedPolicyTypes')
|
||||||
|
->label('Governed subjects')
|
||||||
|
->options(fn (): array => $this->policyTypeOptions())
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All governed subjects')
|
||||||
|
->helperText(fn (): ?string => $this->policyTypeOptions() === []
|
||||||
|
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
|
||||||
|
: null)
|
||||||
|
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
|
||||||
|
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action>
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
$navigationContext = $this->navigationContext();
|
||||||
|
|
||||||
|
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('return_to_origin')
|
||||||
|
->label($navigationContext->backLinkLabel)
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($navigationContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceTenant = $this->selectedSourceTenant();
|
||||||
|
|
||||||
|
if ($sourceTenant instanceof Tenant) {
|
||||||
|
$actions[] = Action::make('open_source_tenant')
|
||||||
|
->label('Open source tenant')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetTenant = $this->selectedTargetTenant();
|
||||||
|
|
||||||
|
if ($targetTenant instanceof Tenant) {
|
||||||
|
$actions[] = Action::make('open_target_tenant')
|
||||||
|
->label('Open target tenant')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$preflightAction = Action::make('generatePromotionPreflight')
|
||||||
|
->label('Generate promotion preflight')
|
||||||
|
->icon('heroicon-o-sparkles')
|
||||||
|
->color('primary')
|
||||||
|
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||||
|
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||||
|
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||||
|
|
||||||
|
$preflightAction = WorkspaceUiEnforcement::forAction(
|
||||||
|
$preflightAction,
|
||||||
|
fn (): ?Workspace => $this->workspace(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
|
->preserveDisabled()
|
||||||
|
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||||
|
->apply()
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if ($resolver->isMember($user, $workspace)
|
||||||
|
&& ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||||
|
return 'You need workspace baseline manage access to generate a promotion preflight.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->preflightDisabledReason();
|
||||||
|
});
|
||||||
|
|
||||||
|
$actions[] = $preflightAction;
|
||||||
|
|
||||||
|
return $actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applySelection(): void
|
||||||
|
{
|
||||||
|
$this->selectionMessage = null;
|
||||||
|
$this->preflight = null;
|
||||||
|
|
||||||
|
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
|
||||||
|
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
|
||||||
|
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
|
||||||
|
|
||||||
|
if ($this->sourceTenantId !== null
|
||||||
|
&& $this->targetTenantId !== null
|
||||||
|
&& $this->sourceTenantId === $this->targetTenantId) {
|
||||||
|
$this->selectionMessage = 'Choose two different tenants.';
|
||||||
|
$this->addError('targetTenantId', $this->selectionMessage);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect($this->selectionUrl(), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generatePromotionPreflight(): void
|
||||||
|
{
|
||||||
|
$this->authorizePageAccess();
|
||||||
|
$this->authorizePreflightExecution();
|
||||||
|
|
||||||
|
if ($this->preview === null) {
|
||||||
|
$this->refreshPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->preview === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = $this->compareSelection();
|
||||||
|
|
||||||
|
if (! $selection instanceof CrossTenantCompareSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
|
||||||
|
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if ($workspace instanceof Workspace && $user instanceof User) {
|
||||||
|
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
|
||||||
|
workspace: $workspace,
|
||||||
|
sourceTenant: $selection->sourceTenant,
|
||||||
|
targetTenant: $selection->targetTenant,
|
||||||
|
preflight: $this->preflight,
|
||||||
|
actor: $user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearSelectionUrl(): string
|
||||||
|
{
|
||||||
|
return static::getUrl($this->routeParameters([
|
||||||
|
self::SOURCE_TENANT_QUERY_KEY => null,
|
||||||
|
self::TARGET_TENANT_QUERY_KEY => null,
|
||||||
|
self::POLICY_TYPE_QUERY_KEY => null,
|
||||||
|
]), panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectionUrl(): string
|
||||||
|
{
|
||||||
|
return static::getUrl($this->routeParameters(), panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function launchUrl(
|
||||||
|
?Tenant $sourceTenant = null,
|
||||||
|
?Tenant $targetTenant = null,
|
||||||
|
?CanonicalNavigationContext $navigationContext = null,
|
||||||
|
): string {
|
||||||
|
$parameters = [];
|
||||||
|
|
||||||
|
if ($sourceTenant instanceof Tenant) {
|
||||||
|
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetTenant instanceof Tenant) {
|
||||||
|
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($navigationContext instanceof CanonicalNavigationContext) {
|
||||||
|
$parameters = array_replace($parameters, $navigationContext->toQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::getUrl($parameters, panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasActiveSelection(): bool
|
||||||
|
{
|
||||||
|
return $this->sourceTenantId !== null
|
||||||
|
|| $this->targetTenantId !== null
|
||||||
|
|| $this->selectedPolicyTypes !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stateColor(string $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
'match', 'ready' => 'success',
|
||||||
|
'different', 'manual_mapping_required' => 'warning',
|
||||||
|
'missing' => 'info',
|
||||||
|
'ambiguous' => 'gray',
|
||||||
|
'blocked' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stateLabel(string $value): string
|
||||||
|
{
|
||||||
|
return Str::headline(str_replace('_', ' ', $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reasonLabel(string $reasonCode): string
|
||||||
|
{
|
||||||
|
return Str::headline(str_replace('_', ' ', $reasonCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceTenantUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->selectedSourceTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetTenantUrl(): ?string
|
||||||
|
{
|
||||||
|
$tenant = $this->selectedTargetTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function formState(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sourceTenantId' => $this->sourceTenantId,
|
||||||
|
'targetTenantId' => $this->targetTenantId,
|
||||||
|
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrateSelectionFromRequest(): void
|
||||||
|
{
|
||||||
|
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
|
||||||
|
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
|
||||||
|
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshPreview(): void
|
||||||
|
{
|
||||||
|
$this->selectionMessage = null;
|
||||||
|
$this->preview = null;
|
||||||
|
$this->preflight = null;
|
||||||
|
|
||||||
|
$selection = $this->compareSelection();
|
||||||
|
|
||||||
|
if (! $selection instanceof CrossTenantCompareSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePageAccess(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizePreflightExecution(): void
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareSelection(): ?CrossTenantCompareSelection
|
||||||
|
{
|
||||||
|
$sourceTenant = $this->selectedSourceTenant();
|
||||||
|
$targetTenant = $this->selectedTargetTenant();
|
||||||
|
|
||||||
|
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
|
||||||
|
$this->selectionMessage = 'Choose two different tenants.';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CrossTenantCompareSelection(
|
||||||
|
sourceTenant: $sourceTenant,
|
||||||
|
targetTenant: $targetTenant,
|
||||||
|
policyTypes: $this->selectedPolicyTypes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectedSourceTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
if ($this->sourceTenantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveAuthorizedTenant($this->sourceTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectedTargetTenant(): ?Tenant
|
||||||
|
{
|
||||||
|
if ($this->targetTenantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveAuthorizedTenant($this->targetTenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAuthorizedTenant(string $tenantId): Tenant
|
||||||
|
{
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereKey((int) $tenantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function tenantOptions(): array
|
||||||
|
{
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$tenants = $user->tenants()
|
||||||
|
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||||
|
->select('tenants.*')
|
||||||
|
->orderBy('tenants.name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$resolver->primeMemberships($user, $tenants->modelKeys());
|
||||||
|
|
||||||
|
return $tenants
|
||||||
|
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
|
||||||
|
->mapWithKeys(fn (Tenant $tenant): array => [
|
||||||
|
(string) $tenant->getKey() => (string) $tenant->name,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function policyTypeOptions(): array
|
||||||
|
{
|
||||||
|
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventoryItem::query()
|
||||||
|
->whereIn('tenant_id', $tenantIds)
|
||||||
|
->whereNotNull('policy_type')
|
||||||
|
->where('policy_type', '!=', '')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('policy_type')
|
||||||
|
->pluck('policy_type')
|
||||||
|
->mapWithKeys(fn (string $policyType): array => [
|
||||||
|
$policyType => Str::headline($policyType),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preflightDisabledReason(): ?string
|
||||||
|
{
|
||||||
|
if ($this->selectionMessage !== null) {
|
||||||
|
return $this->selectionMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($this->preview)) {
|
||||||
|
return 'Select an authorized source and target tenant to generate a promotion preflight.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
|
||||||
|
return 'No governed subjects are available for this compare selection yet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private function normalizeTenantIdentifier(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value) && ! is_int($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
|
||||||
|
return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizePolicyTypes(mixed $value): array
|
||||||
|
{
|
||||||
|
$allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true);
|
||||||
|
|
||||||
|
$values = match (true) {
|
||||||
|
is_string($value) && $value !== '' => [$value],
|
||||||
|
is_array($value) => $value,
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return array_values(array_filter(array_unique(array_map(
|
||||||
|
static fn (mixed $item): string => is_string($item) ? trim($item) : '',
|
||||||
|
$values,
|
||||||
|
)), static fn (string $item): bool => $item !== '' && isset($allowed[$item])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function routeParameters(array $overrides = []): array
|
||||||
|
{
|
||||||
|
$parameters = [
|
||||||
|
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
|
||||||
|
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
|
||||||
|
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (is_array($this->navigationContextPayload)) {
|
||||||
|
$parameters['nav'] = $this->navigationContextPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($overrides as $key => $value) {
|
||||||
|
$parameters[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
if (! is_array($this->navigationContextPayload)) {
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspace(): ?Workspace
|
||||||
|
{
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
return $workspace instanceof Workspace ? $workspace : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantResource\Pages;
|
use App\Filament\Resources\TenantResource\Pages;
|
||||||
use App\Filament\Resources\TenantResource\RelationManagers;
|
use App\Filament\Resources\TenantResource\RelationManagers;
|
||||||
@ -15,9 +16,11 @@
|
|||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\TenantTriageReview;
|
use App\Models\TenantTriageReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Directory\RoleDefinitionsSyncService;
|
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
@ -44,6 +47,7 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
@ -68,6 +72,7 @@
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Infolists;
|
use Filament\Infolists;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -824,6 +829,27 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||||
|
Actions\Action::make('compareTenants')
|
||||||
|
->label('Compare tenants')
|
||||||
|
->icon('heroicon-o-scale')
|
||||||
|
->color('gray')
|
||||||
|
->url(function (Tenant $record, mixed $livewire): string {
|
||||||
|
$triageState = $livewire instanceof Pages\ListTenants
|
||||||
|
? static::currentPortfolioTriageState($livewire)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (! static::hasActivePortfolioTriageState(
|
||||||
|
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||||
|
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||||
|
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||||
|
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||||
|
)) {
|
||||||
|
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::crossTenantCompareOpenUrl($record, $triageState);
|
||||||
|
})
|
||||||
|
->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
@ -966,6 +992,34 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
|
Actions\BulkAction::make('compareSelected')
|
||||||
|
->label('Compare selected')
|
||||||
|
->icon('heroicon-o-scale')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||||
|
->extraAttributes(fn (mixed $livewire): array => [
|
||||||
|
'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null',
|
||||||
|
'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire),
|
||||||
|
'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire),
|
||||||
|
'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }',
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, mixed $livewire): void {
|
||||||
|
$disabledReason = static::crossTenantCompareBulkDisabledReason($records);
|
||||||
|
|
||||||
|
if ($disabledReason !== null) {
|
||||||
|
Notification::make()
|
||||||
|
->title($disabledReason)
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($livewire, 'redirect')) {
|
||||||
|
$livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
Actions\BulkAction::make('syncSelected')
|
Actions\BulkAction::make('syncSelected')
|
||||||
->label('Sync selected')
|
->label('Sync selected')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -1158,6 +1212,52 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* backup_posture?: list<string>,
|
||||||
|
* recovery_evidence?: list<string>,
|
||||||
|
* review_state?: list<string>,
|
||||||
|
* triage_sort?: string|null
|
||||||
|
* } $triageState
|
||||||
|
*/
|
||||||
|
public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string
|
||||||
|
{
|
||||||
|
return static::crossTenantCompareOpenUrlForSelection(
|
||||||
|
targetTenant: $record,
|
||||||
|
triageState: $triageState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* backup_posture?: list<string>,
|
||||||
|
* recovery_evidence?: list<string>,
|
||||||
|
* review_state?: list<string>,
|
||||||
|
* triage_sort?: string|null
|
||||||
|
* } $triageState
|
||||||
|
*/
|
||||||
|
public static function crossTenantCompareOpenUrlForSelection(
|
||||||
|
Tenant $targetTenant,
|
||||||
|
array $triageState = [],
|
||||||
|
?Tenant $sourceTenant = null,
|
||||||
|
): string {
|
||||||
|
$normalizedState = static::portfolioReturnFilters(
|
||||||
|
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||||
|
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||||
|
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||||
|
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
return CrossTenantComparePage::launchUrl(
|
||||||
|
sourceTenant: $sourceTenant,
|
||||||
|
targetTenant: $targetTenant,
|
||||||
|
navigationContext: CanonicalNavigationContext::forTenantRegistry(
|
||||||
|
backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState),
|
||||||
|
tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{
|
* @param array{
|
||||||
* backup_posture?: list<string>,
|
* backup_posture?: list<string>,
|
||||||
@ -1248,6 +1348,168 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function crossTenantCompareActionVisible(Tenant $record): bool
|
||||||
|
{
|
||||||
|
if (! $record->isActive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
|
|
||||||
|
if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||||
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $workspaceResolver->isMember($user, $workspace)
|
||||||
|
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $tenantResolver */
|
||||||
|
$tenantResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $user->canAccessTenant($record)
|
||||||
|
&& $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = $records
|
||||||
|
->filter(fn ($record): bool => $record instanceof Tenant)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($records->count() !== 2 || $tenants->count() !== 2) {
|
||||||
|
return 'Select exactly two tenants to compare.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) {
|
||||||
|
return 'Only active tenants can be compared.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceIds = $tenants
|
||||||
|
->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($workspaceIds->count() !== 1) {
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = $workspaceIds->first();
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||||
|
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $workspaceResolver->isMember($user, $workspace)
|
||||||
|
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
|
||||||
|
return UiTooltips::insufficientPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $tenantResolver */
|
||||||
|
$tenantResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
$isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant)
|
||||||
|
|| ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW));
|
||||||
|
|
||||||
|
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string
|
||||||
|
{
|
||||||
|
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
|
||||||
|
|
||||||
|
return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string
|
||||||
|
{
|
||||||
|
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
|
||||||
|
|
||||||
|
return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string
|
||||||
|
{
|
||||||
|
$inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire));
|
||||||
|
|
||||||
|
return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array
|
||||||
|
{
|
||||||
|
if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tableRecords = $livewire->getTableRecords();
|
||||||
|
|
||||||
|
if (method_exists($tableRecords, 'getCollection')) {
|
||||||
|
$tableRecords = $tableRecords->getCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($tableRecords)
|
||||||
|
->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive())
|
||||||
|
->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string
|
||||||
|
{
|
||||||
|
$triageState = $livewire instanceof Pages\ListTenants
|
||||||
|
? static::currentPortfolioTriageState($livewire)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (! static::hasActivePortfolioTriageState(
|
||||||
|
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
|
||||||
|
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
|
||||||
|
static::sanitizeReviewStates($triageState['review_state'] ?? []),
|
||||||
|
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
|
||||||
|
)) {
|
||||||
|
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenants = $records
|
||||||
|
->filter(fn ($record): bool => $record instanceof Tenant)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return static::crossTenantCompareOpenUrlForSelection(
|
||||||
|
targetTenant: $tenants->get(1),
|
||||||
|
triageState: $triageState,
|
||||||
|
sourceTenant: $tenants->get(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static function hasActivePortfolioTriageState(
|
private static function hasActivePortfolioTriageState(
|
||||||
array $backupPostures,
|
array $backupPostures,
|
||||||
array $recoveryEvidence,
|
array $recoveryEvidence,
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
@ -181,6 +182,7 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
|
CrossTenantComparePage::class,
|
||||||
GovernanceInbox::class,
|
GovernanceInbox::class,
|
||||||
FindingsHygieneReport::class,
|
FindingsHygieneReport::class,
|
||||||
FindingsIntakeQueue::class,
|
FindingsIntakeQueue::class,
|
||||||
|
|||||||
@ -139,6 +139,43 @@ public function logSupportDiagnosticsOpened(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $preflight
|
||||||
|
*/
|
||||||
|
public function logCrossTenantPromotionPreflightGenerated(
|
||||||
|
Workspace $workspace,
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
Tenant $targetTenant,
|
||||||
|
array $preflight,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$summary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $workspace,
|
||||||
|
action: AuditActionId::CrossTenantPromotionPreflightGenerated,
|
||||||
|
context: [
|
||||||
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
|
'source_tenant_name' => (string) $sourceTenant->name,
|
||||||
|
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||||
|
'target_tenant_name' => (string) $targetTenant->name,
|
||||||
|
'ready_count' => (int) ($summary['ready'] ?? 0),
|
||||||
|
'blocked_count' => (int) ($summary['blocked'] ?? 0),
|
||||||
|
'manual_mapping_required_count' => (int) ($summary['manual_mapping_required'] ?? 0),
|
||||||
|
'total_count' => (int) ($summary['total'] ?? 0),
|
||||||
|
'blocked_reason_counts' => is_array($preflight['blockedReasonCounts'] ?? null)
|
||||||
|
? $preflight['blockedReasonCounts']
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'cross_tenant_promotion_preflight',
|
||||||
|
resourceId: sprintf('%s:%s', $sourceTenant->getKey(), $targetTenant->getKey()),
|
||||||
|
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
|
||||||
|
summary: 'Cross-tenant promotion preflight generated for '.$sourceTenant->name.' -> '.$targetTenant->name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function logSupportRequestCreated(
|
public function logSupportRequestCreated(
|
||||||
SupportRequest $supportRequest,
|
SupportRequest $supportRequest,
|
||||||
User|PlatformUser|null $actor = null,
|
User|PlatformUser|null $actor = null,
|
||||||
|
|||||||
@ -69,6 +69,7 @@ enum AuditActionId: string
|
|||||||
case BaselineCompareStarted = 'baseline_compare.started';
|
case BaselineCompareStarted = 'baseline_compare.started';
|
||||||
case BaselineCompareCompleted = 'baseline_compare.completed';
|
case BaselineCompareCompleted = 'baseline_compare.completed';
|
||||||
case BaselineCompareFailed = 'baseline_compare.failed';
|
case BaselineCompareFailed = 'baseline_compare.failed';
|
||||||
|
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
|
||||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
||||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
||||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
||||||
@ -218,6 +219,7 @@ private static function labels(): array
|
|||||||
self::BaselineCompareStarted->value => 'Baseline compare started',
|
self::BaselineCompareStarted->value => 'Baseline compare started',
|
||||||
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
||||||
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
||||||
|
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||||
@ -312,6 +314,7 @@ private static function summaries(): array
|
|||||||
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
self::BaselineProfileUpdated->value => 'Baseline profile updated',
|
||||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||||
|
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
namespace App\Support\Navigation;
|
namespace App\Support\Navigation;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
final readonly class CanonicalNavigationContext
|
final readonly class CanonicalNavigationContext
|
||||||
@ -82,6 +84,17 @@ public static function forGovernanceInbox(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sourceSurface: 'tenant_registry',
|
||||||
|
canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
backLinkLabel: 'Back to tenant registry',
|
||||||
|
backLinkUrl: $backLinkUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\PortfolioCompare;
|
||||||
|
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||||
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class CrossTenantComparePreviewBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CurrentStateHashResolver $currentStateHashResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* selection: array{
|
||||||
|
* workspaceId: int,
|
||||||
|
* sourceTenantId: int,
|
||||||
|
* sourceTenantName: string,
|
||||||
|
* targetTenantId: int,
|
||||||
|
* targetTenantName: string,
|
||||||
|
* policyTypes: list<string>
|
||||||
|
* },
|
||||||
|
* summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int},
|
||||||
|
* subjects: list<array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(CrossTenantCompareSelection $selection): array
|
||||||
|
{
|
||||||
|
$sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes);
|
||||||
|
$targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes);
|
||||||
|
|
||||||
|
$sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']);
|
||||||
|
$targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $targetIndex['evidence_subjects']);
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
$summary = [
|
||||||
|
'match' => 0,
|
||||||
|
'different' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'ambiguous' => 0,
|
||||||
|
'blocked' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sourceIndex['preview_subjects'] as $sourceSubject) {
|
||||||
|
$previewSubject = $this->buildPreviewSubject(
|
||||||
|
sourceSubject: $sourceSubject,
|
||||||
|
sourceTenant: $selection->sourceTenant,
|
||||||
|
targetTenant: $selection->targetTenant,
|
||||||
|
targetIndex: $targetIndex['subjects'],
|
||||||
|
sourceEvidence: $sourceEvidence,
|
||||||
|
targetEvidence: $targetEvidence,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subjects[] = $previewSubject;
|
||||||
|
$summary[$previewSubject['state']]++;
|
||||||
|
$summary['total']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($subjects, function (array $left, array $right): int {
|
||||||
|
$policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? ''));
|
||||||
|
|
||||||
|
if ($policyTypeComparison !== 0) {
|
||||||
|
return $policyTypeComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayNameComparison = strcmp(
|
||||||
|
Str::lower((string) ($left['displayName'] ?? '')),
|
||||||
|
Str::lower((string) ($right['displayName'] ?? '')),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($displayNameComparison !== 0) {
|
||||||
|
return $displayNameComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'selection' => [
|
||||||
|
'workspaceId' => $selection->workspaceId(),
|
||||||
|
'sourceTenantId' => $selection->sourceTenantId(),
|
||||||
|
'sourceTenantName' => (string) $selection->sourceTenant->name,
|
||||||
|
'targetTenantId' => $selection->targetTenantId(),
|
||||||
|
'targetTenantName' => (string) $selection->targetTenant->name,
|
||||||
|
'policyTypes' => $selection->policyTypes,
|
||||||
|
],
|
||||||
|
'summary' => $summary,
|
||||||
|
'subjects' => $subjects,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Tenant $tenant
|
||||||
|
* @param list<string> $policyTypes
|
||||||
|
* @return array{
|
||||||
|
* preview_subjects: list<array<string, mixed>>,
|
||||||
|
* evidence_subjects: list<array{policy_type: string, subject_external_id: string}>,
|
||||||
|
* subjects: array<string, array<string, mixed>>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function indexTenantSubjects(Tenant $tenant, array $policyTypes): array
|
||||||
|
{
|
||||||
|
$inventoryItems = InventoryItem::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->when(
|
||||||
|
$policyTypes !== [],
|
||||||
|
fn ($query) => $query->whereIn('policy_type', $policyTypes),
|
||||||
|
)
|
||||||
|
->orderBy('policy_type')
|
||||||
|
->orderBy('display_name')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$subjects = [];
|
||||||
|
$previewSubjects = [];
|
||||||
|
$evidenceSubjects = [];
|
||||||
|
|
||||||
|
foreach ($inventoryItems as $inventoryItem) {
|
||||||
|
if (! $inventoryItem instanceof InventoryItem) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = trim((string) $inventoryItem->policy_type);
|
||||||
|
$subjectKey = BaselineSubjectKey::forPolicy(
|
||||||
|
$policyType,
|
||||||
|
is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null,
|
||||||
|
is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$subjectRecord = $this->inventorySubjectRecord($tenant, $inventoryItem, $policyType, $subjectKey);
|
||||||
|
|
||||||
|
if ($subjectKey === null) {
|
||||||
|
$previewSubjects[] = [
|
||||||
|
...$subjectRecord,
|
||||||
|
'resolution' => 'identifier_missing',
|
||||||
|
'duplicateCount' => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexKey = $this->subjectIndexKey($policyType, $subjectKey);
|
||||||
|
|
||||||
|
if (! array_key_exists($indexKey, $subjects)) {
|
||||||
|
$subjects[$indexKey] = [
|
||||||
|
'policyType' => $policyType,
|
||||||
|
'subjectKey' => $subjectKey,
|
||||||
|
'displayName' => $subjectRecord['displayName'],
|
||||||
|
'items' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[$indexKey]['items'][] = $subjectRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($subjects as $indexKey => $subjectGroup) {
|
||||||
|
$items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : [];
|
||||||
|
$firstItem = $items[0] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($firstItem)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previewSubjects[] = [
|
||||||
|
...$firstItem,
|
||||||
|
'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved',
|
||||||
|
'duplicateCount' => count($items),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($items) === 1) {
|
||||||
|
$evidenceSubjects[] = [
|
||||||
|
'policy_type' => (string) $firstItem['policyType'],
|
||||||
|
'subject_external_id' => (string) $firstItem['subjectExternalId'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjects[$indexKey]['representative'] = $firstItem;
|
||||||
|
$subjects[$indexKey]['duplicateCount'] = count($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'preview_subjects' => $previewSubjects,
|
||||||
|
'evidence_subjects' => $evidenceSubjects,
|
||||||
|
'subjects' => $subjects,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $targetIndex
|
||||||
|
* @param array<string, ResolvedEvidence|null> $sourceEvidence
|
||||||
|
* @param array<string, ResolvedEvidence|null> $targetEvidence
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildPreviewSubject(
|
||||||
|
array $sourceSubject,
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
Tenant $targetTenant,
|
||||||
|
array $targetIndex,
|
||||||
|
array $sourceEvidence,
|
||||||
|
array $targetEvidence,
|
||||||
|
): array {
|
||||||
|
$policyType = (string) ($sourceSubject['policyType'] ?? '');
|
||||||
|
$displayName = (string) ($sourceSubject['displayName'] ?? '');
|
||||||
|
$subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null;
|
||||||
|
$reasonCodes = [];
|
||||||
|
$state = 'blocked';
|
||||||
|
$trustLevel = 'unusable';
|
||||||
|
|
||||||
|
$sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject);
|
||||||
|
$targetEvidenceRecord = null;
|
||||||
|
$targetSubject = null;
|
||||||
|
|
||||||
|
if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') {
|
||||||
|
$reasonCodes[] = 'source_identifier_missing';
|
||||||
|
} elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') {
|
||||||
|
$state = 'ambiguous';
|
||||||
|
$trustLevel = 'diagnostic_only';
|
||||||
|
$reasonCodes[] = 'source_subject_ambiguous';
|
||||||
|
} elseif ($subjectKey !== null) {
|
||||||
|
$targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($targetSubject)) {
|
||||||
|
$state = 'missing';
|
||||||
|
$trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence
|
||||||
|
&& $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
|
||||||
|
? 'trustworthy'
|
||||||
|
: 'limited_confidence';
|
||||||
|
$reasonCodes[] = 'target_subject_missing';
|
||||||
|
} elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) {
|
||||||
|
$state = 'ambiguous';
|
||||||
|
$trustLevel = 'diagnostic_only';
|
||||||
|
$reasonCodes[] = 'target_subject_ambiguous';
|
||||||
|
} else {
|
||||||
|
$representative = $targetSubject['representative'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($representative)) {
|
||||||
|
$targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $sourceEvidenceRecord instanceof ResolvedEvidence) {
|
||||||
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $targetEvidenceRecord instanceof ResolvedEvidence) {
|
||||||
|
$reasonCodes[] = 'target_evidence_refresh_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) {
|
||||||
|
$state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different';
|
||||||
|
$trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
|
||||||
|
&& $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
|
||||||
|
? 'trustworthy'
|
||||||
|
: 'limited_confidence';
|
||||||
|
|
||||||
|
if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
|
||||||
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
|
||||||
|
$reasonCodes[] = 'target_evidence_refresh_required';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$state = 'blocked';
|
||||||
|
$trustLevel = 'unusable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) {
|
||||||
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === 'blocked' && $reasonCodes === []) {
|
||||||
|
$reasonCodes[] = 'source_evidence_refresh_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCodes = array_values(array_unique($reasonCodes));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policyType' => $policyType,
|
||||||
|
'displayName' => $displayName,
|
||||||
|
'subjectKey' => $subjectKey,
|
||||||
|
'state' => $state,
|
||||||
|
'trustLevel' => $trustLevel,
|
||||||
|
'reasonCodes' => $reasonCodes,
|
||||||
|
'source' => $this->subjectSidePayload($sourceTenant, $sourceSubject, $sourceEvidenceRecord),
|
||||||
|
'target' => $this->subjectSidePayload(
|
||||||
|
$targetTenant,
|
||||||
|
is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null,
|
||||||
|
$targetEvidenceRecord,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
||||||
|
* @return array<string, ResolvedEvidence|null>
|
||||||
|
*/
|
||||||
|
private function resolvedEvidenceMap(Tenant $tenant, array $subjects): array
|
||||||
|
{
|
||||||
|
return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $subject
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'tenantName' => (string) $tenant->name,
|
||||||
|
'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null,
|
||||||
|
'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null,
|
||||||
|
'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null,
|
||||||
|
'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null,
|
||||||
|
'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null,
|
||||||
|
'evidence' => $this->evidencePayload($evidence),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* policyType: string,
|
||||||
|
* displayName: string,
|
||||||
|
* subjectKey: ?string,
|
||||||
|
* inventoryItemId: int,
|
||||||
|
* subjectExternalId: string,
|
||||||
|
* lastSeenAt: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function inventorySubjectRecord(Tenant $tenant, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array
|
||||||
|
{
|
||||||
|
$displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : '';
|
||||||
|
$displayName = $displayName !== ''
|
||||||
|
? $displayName
|
||||||
|
: ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenantId' => (int) $tenant->getKey(),
|
||||||
|
'policyType' => $policyType,
|
||||||
|
'displayName' => $displayName,
|
||||||
|
'subjectKey' => $subjectKey,
|
||||||
|
'inventoryItemId' => (int) $inventoryItem->getKey(),
|
||||||
|
'subjectExternalId' => (string) $inventoryItem->external_id,
|
||||||
|
'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, ResolvedEvidence|null> $evidenceMap
|
||||||
|
*/
|
||||||
|
private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence
|
||||||
|
{
|
||||||
|
$policyType = trim((string) ($subject['policyType'] ?? ''));
|
||||||
|
$subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? ''));
|
||||||
|
|
||||||
|
if ($policyType === '' || $subjectExternalId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $policyType.'|'.$subjectExternalId;
|
||||||
|
$evidence = $evidenceMap[$key] ?? null;
|
||||||
|
|
||||||
|
return $evidence instanceof ResolvedEvidence ? $evidence : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* hash: string,
|
||||||
|
* fidelity: string,
|
||||||
|
* source: string,
|
||||||
|
* observedAt: ?string,
|
||||||
|
* policyVersionId: ?int,
|
||||||
|
* operationRunId: ?int,
|
||||||
|
* capturePurpose: ?string
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
private function evidencePayload(?ResolvedEvidence $evidence): ?array
|
||||||
|
{
|
||||||
|
if (! $evidence instanceof ResolvedEvidence) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hash' => $evidence->hash,
|
||||||
|
'fidelity' => $evidence->fidelity,
|
||||||
|
'source' => $evidence->source,
|
||||||
|
'observedAt' => $evidence->observedAt?->toIso8601String(),
|
||||||
|
'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null)
|
||||||
|
? (int) $evidence->meta['policy_version_id']
|
||||||
|
: null,
|
||||||
|
'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null)
|
||||||
|
? (int) $evidence->meta['operation_run_id']
|
||||||
|
: null,
|
||||||
|
'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null)
|
||||||
|
? (string) $evidence->meta['capture_purpose']
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subjectIndexKey(string $policyType, string $subjectKey): string
|
||||||
|
{
|
||||||
|
return $policyType.'|'.$subjectKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\PortfolioCompare;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class CrossTenantCompareSelection
|
||||||
|
{
|
||||||
|
public Tenant $sourceTenant;
|
||||||
|
|
||||||
|
public Tenant $targetTenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $policyTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $policyTypes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
Tenant $sourceTenant,
|
||||||
|
Tenant $targetTenant,
|
||||||
|
array $policyTypes = [],
|
||||||
|
) {
|
||||||
|
$this->sourceTenant = $sourceTenant;
|
||||||
|
$this->targetTenant = $targetTenant;
|
||||||
|
|
||||||
|
if ((int) $this->sourceTenant->getKey() === (int) $this->targetTenant->getKey()) {
|
||||||
|
throw new InvalidArgumentException('Source and target tenants must differ.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $this->sourceTenant->workspace_id !== (int) $this->targetTenant->workspace_id) {
|
||||||
|
throw new InvalidArgumentException('Source and target tenants must belong to the same workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->policyTypes = $this->normalizePolicyTypes($policyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspaceId(): int
|
||||||
|
{
|
||||||
|
return (int) $this->sourceTenant->workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceTenantId(): int
|
||||||
|
{
|
||||||
|
return (int) $this->sourceTenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetTenantId(): int
|
||||||
|
{
|
||||||
|
return (int) $this->targetTenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPolicyTypeFilter(): bool
|
||||||
|
{
|
||||||
|
return $this->policyTypes !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $policyTypes
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizePolicyTypes(array $policyTypes): array
|
||||||
|
{
|
||||||
|
$normalized = array_values(array_unique(array_filter(array_map(static function (mixed $policyType): ?string {
|
||||||
|
if (! is_string($policyType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedPolicyType = trim($policyType);
|
||||||
|
|
||||||
|
return $normalizedPolicyType !== '' ? $normalizedPolicyType : null;
|
||||||
|
}, $policyTypes))));
|
||||||
|
|
||||||
|
sort($normalized, SORT_STRING);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\PortfolioCompare;
|
||||||
|
|
||||||
|
final class CrossTenantPromotionPreflight
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* selection?: array<string, mixed>,
|
||||||
|
* subjects?: list<array<string, mixed>>
|
||||||
|
* } $preview
|
||||||
|
* @return array{
|
||||||
|
* selection: array<string, mixed>,
|
||||||
|
* summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int},
|
||||||
|
* blockedReasonCounts: array<string, int>,
|
||||||
|
* buckets: array{
|
||||||
|
* ready: list<array<string, mixed>>,
|
||||||
|
* blocked: list<array<string, mixed>>,
|
||||||
|
* manual_mapping_required: list<array<string, mixed>>
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(array $preview): array
|
||||||
|
{
|
||||||
|
$subjects = is_array($preview['subjects'] ?? null) ? $preview['subjects'] : [];
|
||||||
|
$buckets = [
|
||||||
|
'ready' => [],
|
||||||
|
'blocked' => [],
|
||||||
|
'manual_mapping_required' => [],
|
||||||
|
];
|
||||||
|
$blockedReasonCounts = [];
|
||||||
|
|
||||||
|
foreach ($subjects as $subject) {
|
||||||
|
if (! is_array($subject)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decision = $this->classifySubject($subject);
|
||||||
|
$subject['preflight'] = $decision;
|
||||||
|
$buckets[$decision['bucket']][] = $subject;
|
||||||
|
|
||||||
|
if ($decision['bucket'] !== 'ready') {
|
||||||
|
foreach ($decision['reasonCodes'] as $reasonCode) {
|
||||||
|
$blockedReasonCounts[$reasonCode] = ($blockedReasonCounts[$reasonCode] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'selection' => is_array($preview['selection'] ?? null) ? $preview['selection'] : [],
|
||||||
|
'summary' => [
|
||||||
|
'ready' => count($buckets['ready']),
|
||||||
|
'blocked' => count($buckets['blocked']),
|
||||||
|
'manual_mapping_required' => count($buckets['manual_mapping_required']),
|
||||||
|
'total' => count($buckets['ready']) + count($buckets['blocked']) + count($buckets['manual_mapping_required']),
|
||||||
|
],
|
||||||
|
'blockedReasonCounts' => $blockedReasonCounts,
|
||||||
|
'buckets' => $buckets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $subject
|
||||||
|
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
|
||||||
|
*/
|
||||||
|
private function classifySubject(array $subject): array
|
||||||
|
{
|
||||||
|
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
|
||||||
|
$reasonCodes = is_array($subject['reasonCodes'] ?? null)
|
||||||
|
? array_values(array_filter($subject['reasonCodes'], 'is_string'))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (in_array('source_identifier_missing', $reasonCodes, true)) {
|
||||||
|
return $this->decision('blocked', ['source_identifier_missing']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('source_subject_ambiguous', $reasonCodes, true)) {
|
||||||
|
return $this->decision('blocked', ['source_subject_ambiguous']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('target_subject_ambiguous', $reasonCodes, true) || $state === 'ambiguous') {
|
||||||
|
return $this->decision('manual_mapping_required', ['target_subject_ambiguous']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceEvidence = is_array(data_get($subject, 'source.evidence')) ? data_get($subject, 'source.evidence') : null;
|
||||||
|
$targetEvidence = is_array(data_get($subject, 'target.evidence')) ? data_get($subject, 'target.evidence') : null;
|
||||||
|
|
||||||
|
if (! $this->evidenceSupportsPromotion($sourceEvidence)) {
|
||||||
|
return $this->decision('blocked', ['source_evidence_refresh_required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state !== 'missing' && ! $this->evidenceSupportsPromotion($targetEvidence)) {
|
||||||
|
return $this->decision('blocked', ['target_evidence_refresh_required']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($state) {
|
||||||
|
'match' => $this->decision('ready', ['target_already_aligned']),
|
||||||
|
'different' => $this->decision('ready', ['target_subject_requires_update']),
|
||||||
|
'missing' => $this->decision('ready', ['target_subject_missing']),
|
||||||
|
default => $this->decision('blocked', ['source_evidence_refresh_required']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $evidence
|
||||||
|
*/
|
||||||
|
private function evidenceSupportsPromotion(?array $evidence): bool
|
||||||
|
{
|
||||||
|
return is_array($evidence)
|
||||||
|
&& is_string($evidence['fidelity'] ?? null)
|
||||||
|
&& (string) $evidence['fidelity'] === 'content';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $reasonCodes
|
||||||
|
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
|
||||||
|
*/
|
||||||
|
private function decision(string $bucket, array $reasonCodes): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'bucket' => $bucket,
|
||||||
|
'reasonCodes' => $reasonCodes,
|
||||||
|
'reasonLabels' => array_map(fn (string $reasonCode): string => $this->reasonLabel($reasonCode), $reasonCodes),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reasonLabel(string $reasonCode): string
|
||||||
|
{
|
||||||
|
return match ($reasonCode) {
|
||||||
|
'source_identifier_missing' => 'Source tenant subject is missing a stable compare identifier.',
|
||||||
|
'source_subject_ambiguous' => 'Source tenant subject resolves to multiple candidates and cannot drive promotion safely.',
|
||||||
|
'target_subject_ambiguous' => 'Target tenant has multiple matching subjects and needs manual mapping.',
|
||||||
|
'source_evidence_refresh_required' => 'Refresh source evidence before relying on this subject.',
|
||||||
|
'target_evidence_refresh_required' => 'Refresh target evidence before relying on this subject.',
|
||||||
|
'target_already_aligned' => 'Target tenant already matches the source for this subject.',
|
||||||
|
'target_subject_requires_update' => 'Target tenant differs from the source but has enough evidence for later promotion planning.',
|
||||||
|
'target_subject_missing' => 'Target tenant does not currently contain this subject and can be planned as a missing target item.',
|
||||||
|
default => 'This subject needs additional review before promotion planning can continue.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,208 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
@php
|
||||||
|
$preview = is_array($preview ?? null) ? $preview : null;
|
||||||
|
$preflight = is_array($preflight ?? null) ? $preflight : null;
|
||||||
|
$previewSummary = is_array($preview['summary'] ?? null) ? $preview['summary'] : [];
|
||||||
|
$preflightSummary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
|
||||||
|
$blockedReasonCounts = is_array($preflight['blockedReasonCounts'] ?? null) ? $preflight['blockedReasonCounts'] : [];
|
||||||
|
$sourceTenantName = data_get($preview, 'selection.sourceTenantName');
|
||||||
|
$targetTenantName = data_get($preview, 'selection.targetTenantName');
|
||||||
|
$selectedPolicyTypes = data_get($preview, 'selection.policyTypes', []);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::section heading="Cross-tenant compare">
|
||||||
|
<x-slot name="description">
|
||||||
|
Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<form wire:submit.prevent="applySelection" class="space-y-4">
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Shareable compare scope</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Source tenant, target tenant, and governed-subject filters live on the URL so the same compare preview can be reopened or shared.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
||||||
|
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applySelection,generatePromotionPreflight">
|
||||||
|
Run compare preview
|
||||||
|
</x-filament::button>
|
||||||
|
|
||||||
|
@if ($this->hasActiveSelection())
|
||||||
|
<x-filament::button tag="a" :href="$this->clearSelectionUrl()" color="gray" size="sm">
|
||||||
|
Clear selection
|
||||||
|
</x-filament::button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($selectionMessage))
|
||||||
|
<div class="rounded-2xl border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-100">
|
||||||
|
{{ $selectionMessage }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($preview === null)
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-5 py-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
|
||||||
|
Choose a source tenant and a target tenant to build a compare preview. The source and target must be different tenants inside the active workspace.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($preview !== null)
|
||||||
|
<x-filament::section heading="Compare preview">
|
||||||
|
<x-slot name="description">
|
||||||
|
Decision-first summary of governed subjects. Raw payloads stay on the existing tenant and baseline surfaces.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4" data-testid="cross-tenant-compare-preview">
|
||||||
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<x-filament::badge color="info" size="sm">Source tenant: {{ $sourceTenantName }}</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray" size="sm">Target tenant: {{ $targetTenantName }}</x-filament::badge>
|
||||||
|
@foreach ($selectedPolicyTypes as $policyType)
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ $this->stateLabel($policyType) }}</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
@if (filled($this->sourceTenantUrl()))
|
||||||
|
<a href="{{ $this->sourceTenantUrl() }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
Open source tenant
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($this->targetTenantUrl()))
|
||||||
|
<a href="{{ $this->targetTenantUrl() }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||||
|
Open target tenant
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
The preview groups governed subjects into reproducible compare states so you can decide whether the target is aligned, missing, blocked, or needs manual review.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid gap-3 sm:grid-cols-3 xl:w-[28rem]">
|
||||||
|
@foreach (['match', 'different', 'missing', 'ambiguous', 'blocked', 'total'] as $state)
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $this->stateLabel($state) }}</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">{{ (int) ($previewSummary[$state] ?? 0) }}</dd>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-3 border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||||||
|
<div>Governed subject</div>
|
||||||
|
<div>Reasoning</div>
|
||||||
|
<div>Compare state</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
@foreach (data_get($preview, 'subjects', []) as $subject)
|
||||||
|
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-3 px-4 py-4" data-testid="cross-tenant-compare-subject">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ data_get($subject, 'displayName') }}</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{{ $this->stateLabel((string) data_get($subject, 'policyType', 'unknown')) }}</span>
|
||||||
|
@if (filled(data_get($subject, 'subjectKey')))
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ data_get($subject, 'subjectKey') }}</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@forelse (data_get($subject, 'reasonCodes', []) as $reasonCode)
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ $this->reasonLabel((string) $reasonCode) }}</x-filament::badge>
|
||||||
|
@empty
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">No blocking reason.</span>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start justify-start xl:justify-end">
|
||||||
|
<x-filament::badge :color="$this->stateColor((string) data_get($subject, 'state', 'unknown'))" size="sm">
|
||||||
|
{{ $this->stateLabel((string) data_get($subject, 'state', 'unknown')) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($preflight !== null)
|
||||||
|
<x-filament::section heading="Promotion preflight">
|
||||||
|
<x-slot name="description">
|
||||||
|
Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice.
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="space-y-4" data-testid="cross-tenant-preflight">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
@foreach (['ready', 'blocked', 'manual_mapping_required', 'total'] as $state)
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $this->stateLabel($state) }}</div>
|
||||||
|
<div class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">{{ (int) ($preflightSummary[$state] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($blockedReasonCounts !== [])
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">Top blocked reasons</div>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
@foreach ($blockedReasonCounts as $reasonCode => $count)
|
||||||
|
<x-filament::badge color="danger" size="sm">{{ $this->reasonLabel((string) $reasonCode) }}: {{ (int) $count }}</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-3">
|
||||||
|
@foreach (['ready', 'manual_mapping_required', 'blocked'] as $bucket)
|
||||||
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $this->stateLabel($bucket) }}</div>
|
||||||
|
<x-filament::badge :color="$this->stateColor($bucket)" size="sm">
|
||||||
|
{{ count(data_get($preflight, 'buckets.'.$bucket, [])) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
@forelse (data_get($preflight, 'buckets.'.$bucket, []) as $subject)
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-3 py-3 dark:border-gray-800 dark:bg-gray-900/50">
|
||||||
|
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ data_get($subject, 'displayName') }}</div>
|
||||||
|
@if (data_get($subject, 'preflight.reasonLabels', []) !== [])
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
@foreach (data_get($subject, 'preflight.reasonLabels', []) as $reasonLabel)
|
||||||
|
<x-filament::badge color="gray" size="sm">{{ $reasonLabel }}</x-filament::badge>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">No governed subjects in this bucket.</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Concerns;
|
||||||
|
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
trait BuildsPortfolioCompareFixtures
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{user: User, workspace: Workspace, sourceTenant: Tenant, targetTenant: Tenant}
|
||||||
|
*/
|
||||||
|
protected function makeCrossTenantCompareFixture(
|
||||||
|
string $workspaceRole = 'owner',
|
||||||
|
string $tenantRole = 'owner',
|
||||||
|
): array {
|
||||||
|
$sourceTenant = Tenant::factory()->create([
|
||||||
|
'name' => 'Source Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$user, $sourceTenant] = createUserWithTenant(
|
||||||
|
tenant: $sourceTenant,
|
||||||
|
role: $tenantRole,
|
||||||
|
workspaceRole: $workspaceRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->findOrFail((int) $sourceTenant->workspace_id);
|
||||||
|
|
||||||
|
$targetTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Target Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $targetTenant->getKey() => ['role' => $tenantRole],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(CapabilityResolver::class)->clearCache();
|
||||||
|
app(WorkspaceCapabilityResolver::class)->clearCache();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user' => $user,
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'sourceTenant' => $sourceTenant,
|
||||||
|
'targetTenant' => $targetTenant,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
protected function setAdminWorkspaceContext(User $user, Workspace $workspace): array
|
||||||
|
{
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
return [WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $snapshot
|
||||||
|
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
|
||||||
|
*/
|
||||||
|
protected function createPortfolioCompareSubject(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $displayName,
|
||||||
|
array $snapshot,
|
||||||
|
string $policyType = 'deviceConfiguration',
|
||||||
|
?string $externalId = null,
|
||||||
|
): array {
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'platform' => 'windows',
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => $snapshot,
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventory = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'external_id' => (string) $policy->external_id,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policy' => $policy,
|
||||||
|
'version' => $version,
|
||||||
|
'inventory' => $inventory,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||||
|
|
||||||
|
it('returns 404 for non-members on the cross-tenant compare route', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(CrossTenantComparePage::getUrl(panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 for workspace members missing baseline view capability on the compare route', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'readonly',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||||
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$resolver->shouldReceive('can')->andReturnFalse();
|
||||||
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(CrossTenantComparePage::getUrl(panel: 'admin'))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when the requested target tenant is outside the actor scope', function (): void {
|
||||||
|
$fixture = $this->makeCrossTenantCompareFixture();
|
||||||
|
$hiddenTarget = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'name' => 'Hidden Target',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(CrossTenantComparePage::getUrl(parameters: [
|
||||||
|
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'target_tenant_id' => (int) $hiddenTarget->getKey(),
|
||||||
|
], panel: 'admin'))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps promotion preflight visible but disabled for readonly members and forbids forced execution', function (): void {
|
||||||
|
$fixture = $this->makeCrossTenantCompareFixture(workspaceRole: 'readonly', tenantRole: 'readonly');
|
||||||
|
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Readonly Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Readonly Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$query = [
|
||||||
|
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||||
|
'policy_type' => ['deviceConfiguration'],
|
||||||
|
];
|
||||||
|
|
||||||
|
Livewire::withQueryParams($query)
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(CrossTenantComparePage::class)
|
||||||
|
->assertActionVisible('generatePromotionPreflight')
|
||||||
|
->assertActionDisabled('generatePromotionPreflight')
|
||||||
|
->assertActionExists('generatePromotionPreflight', fn (Action $action): bool => $action->getTooltip() === 'You need workspace baseline manage access to generate a promotion preflight.')
|
||||||
|
->call('generatePromotionPreflight')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
|
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||||
|
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class);
|
||||||
|
|
||||||
|
function crossTenantCompareLaunchQuery(string $url): array
|
||||||
|
{
|
||||||
|
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('launches cross-tenant compare from the tenant registry with target prefill and return context', function (): void {
|
||||||
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||||
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||||
|
|
||||||
|
$backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||||
|
$this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
|
||||||
|
|
||||||
|
$triageState = $this->portfolioReturnFilters(
|
||||||
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||||
|
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
||||||
|
[],
|
||||||
|
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||||
|
);
|
||||||
|
|
||||||
|
$expectedUrl = TenantResource::crossTenantCompareOpenUrl($targetTenant, $triageState);
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
|
||||||
|
->assertTableActionVisible('compareTenants', $targetTenant)
|
||||||
|
->assertTableActionHasUrl('compareTenants', $expectedUrl, $targetTenant);
|
||||||
|
|
||||||
|
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
||||||
|
$backUrl = urldecode((string) data_get($query, 'nav.back_url'));
|
||||||
|
|
||||||
|
expect($query)->toMatchArray([
|
||||||
|
'target_tenant_id' => (string) $targetTenant->getKey(),
|
||||||
|
])
|
||||||
|
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
|
||||||
|
->and(data_get($query, 'nav.tenant_id'))->toBe((string) $targetTenant->getKey())
|
||||||
|
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry')
|
||||||
|
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
|
||||||
|
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
|
||||||
|
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
||||||
|
|
||||||
|
Livewire::withQueryParams($query)
|
||||||
|
->actingAs($user)
|
||||||
|
->test(CrossTenantComparePage::class)
|
||||||
|
->assertSet('sourceTenantId', null)
|
||||||
|
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
||||||
|
->assertActionVisible('return_to_origin')
|
||||||
|
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
|
||||||
|
&& $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('launches cross-tenant compare from an exact-two bulk selection with both tenants prefilled', function (): void {
|
||||||
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||||
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||||
|
|
||||||
|
$anchorBackupSet = $this->seedPortfolioBackupConcern($anchorTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||||
|
$this->seedPortfolioRecoveryConcern($anchorTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $anchorBackupSet);
|
||||||
|
|
||||||
|
$backupSet = $this->seedPortfolioBackupConcern($targetTenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||||
|
$this->seedPortfolioRecoveryConcern($targetTenant, RestoreResultAttention::STATE_COMPLETED_WITH_FOLLOW_UP, $backupSet);
|
||||||
|
|
||||||
|
$triageState = $this->portfolioReturnFilters(
|
||||||
|
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||||
|
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
||||||
|
[],
|
||||||
|
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||||
|
);
|
||||||
|
|
||||||
|
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
|
||||||
|
targetTenant: $targetTenant,
|
||||||
|
triageState: $triageState,
|
||||||
|
sourceTenant: $anchorTenant,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant, $triageState)
|
||||||
|
->selectTableRecords([$anchorTenant, $targetTenant])
|
||||||
|
->assertTableBulkActionVisible('compareSelected')
|
||||||
|
->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant])
|
||||||
|
->assertRedirect($expectedUrl);
|
||||||
|
|
||||||
|
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
||||||
|
$backUrl = urldecode((string) data_get($query, 'nav.back_url'));
|
||||||
|
|
||||||
|
expect($query)->toMatchArray([
|
||||||
|
'source_tenant_id' => (string) $anchorTenant->getKey(),
|
||||||
|
'target_tenant_id' => (string) $targetTenant->getKey(),
|
||||||
|
])
|
||||||
|
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
|
||||||
|
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry')
|
||||||
|
->and(data_get($query, 'nav.tenant_id'))->toBeNull()
|
||||||
|
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
|
||||||
|
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
|
||||||
|
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
|
||||||
|
|
||||||
|
Livewire::withQueryParams($query)
|
||||||
|
->actingAs($user)
|
||||||
|
->test(CrossTenantComparePage::class)
|
||||||
|
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
|
||||||
|
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
||||||
|
->assertActionVisible('return_to_origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
|
||||||
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||||
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||||
|
$thirdTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Third Tenant');
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
||||||
|
->selectTableRecords([$anchorTenant])
|
||||||
|
->assertTableBulkActionVisible('compareSelected')
|
||||||
|
->callTableBulkAction('compareSelected', [$anchorTenant])
|
||||||
|
->assertNotified('Select exactly two tenants to compare.');
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
||||||
|
->selectTableRecords([$anchorTenant, $targetTenant, $thirdTenant])
|
||||||
|
->assertTableBulkActionVisible('compareSelected')
|
||||||
|
->callTableBulkAction('compareSelected', [$anchorTenant, $targetTenant, $thirdTenant])
|
||||||
|
->assertNotified('Select exactly two tenants to compare.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the bulk compare action when a selected tenant is not active', function (): void {
|
||||||
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||||
|
$onboardingTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Onboarding Tenant');
|
||||||
|
|
||||||
|
$onboardingTenant->forceFill([
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
||||||
|
->selectTableRecords([$anchorTenant, $onboardingTenant])
|
||||||
|
->assertTableBulkActionVisible('compareSelected')
|
||||||
|
->callTableBulkAction('compareSelected', [$anchorTenant, $onboardingTenant])
|
||||||
|
->assertNotified('Only active tenants can be compared.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the compare launch action when workspace baseline view capability is missing', function (): void {
|
||||||
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||||
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||||
|
|
||||||
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
||||||
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$resolver->shouldReceive('can')->andReturnFalse();
|
||||||
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
||||||
|
->assertTableActionHidden('compareTenants', $targetTenant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the compare launch action when the actor lacks tenant view on the launched tenant', function (): void {
|
||||||
|
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||||
|
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||||
|
|
||||||
|
$resolver = \Mockery::mock(CapabilityResolver::class);
|
||||||
|
$resolver->shouldReceive('primeMemberships')->andReturnNull();
|
||||||
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
||||||
|
$resolver->shouldReceive('can')->andReturnUsing(function (mixed $actor, mixed $tenant, string $capability) use ($targetTenant): bool {
|
||||||
|
if ($tenant instanceof Tenant
|
||||||
|
&& (int) $tenant->getKey() === (int) $targetTenant->getKey()
|
||||||
|
&& $capability === Capabilities::TENANT_VIEW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
app()->instance(CapabilityResolver::class, $resolver);
|
||||||
|
|
||||||
|
$this->portfolioTriageRegistryList($user, $anchorTenant)
|
||||||
|
->assertTableActionHidden('compareTenants', $targetTenant);
|
||||||
|
});
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||||
|
|
||||||
|
it('renders a reproducible compare preview and promotion preflight for two authorized tenants', function (): void {
|
||||||
|
$fixture = $this->makeCrossTenantCompareFixture();
|
||||||
|
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'WiFi Corp',
|
||||||
|
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'WiFi Corp',
|
||||||
|
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Windows Compliance',
|
||||||
|
snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Windows Compliance',
|
||||||
|
snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
$query = [
|
||||||
|
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||||
|
'policy_type' => ['deviceConfiguration'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(CrossTenantComparePage::getUrl(parameters: $query, panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Cross-tenant compare')
|
||||||
|
->assertSee('Compare preview')
|
||||||
|
->assertSee('WiFi Corp')
|
||||||
|
->assertSee('Windows Compliance')
|
||||||
|
->assertSee('Source tenant: '.$fixture['sourceTenant']->name)
|
||||||
|
->assertSee('Target tenant: '.$fixture['targetTenant']->name)
|
||||||
|
->assertSee(TenantResource::getUrl('view', ['record' => $fixture['sourceTenant']], panel: 'admin'), false)
|
||||||
|
->assertSee(TenantResource::getUrl('view', ['record' => $fixture['targetTenant']], panel: 'admin'), false);
|
||||||
|
|
||||||
|
Livewire::withQueryParams($query)
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(CrossTenantComparePage::class)
|
||||||
|
->assertActionVisible('generatePromotionPreflight')
|
||||||
|
->assertActionEnabled('generatePromotionPreflight')
|
||||||
|
->call('generatePromotionPreflight')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSee('Promotion preflight')
|
||||||
|
->assertSee('WiFi Corp')
|
||||||
|
->assertSee('Windows Compliance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the same tenant as source and target without rendering compare results', function (): void {
|
||||||
|
$fixture = $this->makeCrossTenantCompareFixture();
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(CrossTenantComparePage::getUrl(parameters: [
|
||||||
|
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'target_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
], panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Choose two different tenants.')
|
||||||
|
->assertDontSee('data-testid="cross-tenant-compare-preview"', false)
|
||||||
|
->assertDontSee('Promotion preflight');
|
||||||
|
});
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\CrossTenantComparePage;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class, BuildsPortfolioCompareFixtures::class);
|
||||||
|
|
||||||
|
it('audits promotion preflight generation without creating writes or operation runs', function (): void {
|
||||||
|
$fixture = $this->makeCrossTenantCompareFixture();
|
||||||
|
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Audit Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'audit', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
$this->createPortfolioCompareSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Audit Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'audit', 'value' => 2]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
$operationRunCount = OperationRun::query()->count();
|
||||||
|
$policyVersionCount = PolicyVersion::query()->count();
|
||||||
|
|
||||||
|
Livewire::withQueryParams([
|
||||||
|
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||||
|
'policy_type' => ['deviceConfiguration'],
|
||||||
|
])
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(CrossTenantComparePage::class)
|
||||||
|
->call('generatePromotionPreflight')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('workspace_id', (int) $fixture['workspace']->getKey())
|
||||||
|
->where('action', AuditActionId::CrossTenantPromotionPreflightGenerated->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->status)->toBe('success')
|
||||||
|
->and($audit?->resource_type)->toBe('cross_tenant_promotion_preflight')
|
||||||
|
->and(data_get($audit?->metadata, 'source_tenant_id'))->toBe((int) $fixture['sourceTenant']->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'target_tenant_id'))->toBe((int) $fixture['targetTenant']->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'ready_count'))->toBe(1)
|
||||||
|
->and(data_get($audit?->metadata, 'blocked_count'))->toBe(0)
|
||||||
|
->and(data_get($audit?->metadata, 'manual_mapping_required_count'))->toBe(0);
|
||||||
|
|
||||||
|
expect(OperationRun::query()->count())->toBe($operationRunCount)
|
||||||
|
->and(PolicyVersion::query()->count())->toBe($policyVersionCount);
|
||||||
|
});
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('builds stable compare states for matching, differing, and missing subjects', function (): void {
|
||||||
|
$fixture = crossTenantCompareFixture();
|
||||||
|
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'WiFi Corp',
|
||||||
|
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'WiFi Corp',
|
||||||
|
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Windows Compliance',
|
||||||
|
snapshot: ['settings' => [['key' => 'compliance', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Windows Compliance',
|
||||||
|
snapshot: ['settings' => [['key' => 'compliance', 'value' => 2]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'VPN Profile',
|
||||||
|
snapshot: ['settings' => [['key' => 'vpn', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$selection = new CrossTenantCompareSelection(
|
||||||
|
sourceTenant: $fixture['sourceTenant'],
|
||||||
|
targetTenant: $fixture['targetTenant'],
|
||||||
|
policyTypes: ['deviceConfiguration'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder = app(CrossTenantComparePreviewBuilder::class);
|
||||||
|
$preview = $builder->build($selection);
|
||||||
|
|
||||||
|
expect($preview['summary'])->toBe([
|
||||||
|
'match' => 1,
|
||||||
|
'different' => 1,
|
||||||
|
'missing' => 1,
|
||||||
|
'ambiguous' => 0,
|
||||||
|
'blocked' => 0,
|
||||||
|
'total' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$subjects = collect($preview['subjects'])->keyBy('displayName');
|
||||||
|
|
||||||
|
expect($subjects->get('WiFi Corp'))->not->toBeNull()
|
||||||
|
->and($subjects->get('WiFi Corp')['state'])->toBe('match')
|
||||||
|
->and(data_get($subjects->get('WiFi Corp'), 'source.evidence.fidelity'))->toBe('content')
|
||||||
|
->and(data_get($subjects->get('WiFi Corp'), 'target.evidence.fidelity'))->toBe('content')
|
||||||
|
->and($subjects->get('Windows Compliance')['state'])->toBe('different')
|
||||||
|
->and($subjects->get('VPN Profile')['state'])->toBe('missing');
|
||||||
|
|
||||||
|
expect($builder->build($selection))->toBe($preview);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks unresolved source identity and duplicate target matches distinctly', function (): void {
|
||||||
|
$fixture = crossTenantCompareFixture();
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => ' ',
|
||||||
|
'external_id' => 'source-without-identifier',
|
||||||
|
]);
|
||||||
|
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Duplicated Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Duplicated Policy',
|
||||||
|
externalId: 'dup-target-1',
|
||||||
|
snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
createComparedPolicy(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Duplicated Policy',
|
||||||
|
externalId: 'dup-target-2',
|
||||||
|
snapshot: ['settings' => [['key' => 'dup', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection(
|
||||||
|
sourceTenant: $fixture['sourceTenant'],
|
||||||
|
targetTenant: $fixture['targetTenant'],
|
||||||
|
policyTypes: ['deviceConfiguration'],
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($preview['summary'])->toBe([
|
||||||
|
'match' => 0,
|
||||||
|
'different' => 0,
|
||||||
|
'missing' => 0,
|
||||||
|
'ambiguous' => 1,
|
||||||
|
'blocked' => 1,
|
||||||
|
'total' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$identifierGap = collect($preview['subjects'])
|
||||||
|
->first(fn (array $subject): bool => in_array('source_identifier_missing', $subject['reasonCodes'], true));
|
||||||
|
$ambiguousTarget = collect($preview['subjects'])
|
||||||
|
->first(fn (array $subject): bool => in_array('target_subject_ambiguous', $subject['reasonCodes'], true));
|
||||||
|
|
||||||
|
expect($identifierGap)->toBeArray()
|
||||||
|
->and($identifierGap['state'])->toBe('blocked')
|
||||||
|
->and($identifierGap['trustLevel'])->toBe('unusable')
|
||||||
|
->and($ambiguousTarget)->toBeArray()
|
||||||
|
->and($ambiguousTarget['state'])->toBe('ambiguous')
|
||||||
|
->and($ambiguousTarget['trustLevel'])->toBe('diagnostic_only');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sourceTenant: Tenant, targetTenant: Tenant}
|
||||||
|
*/
|
||||||
|
function crossTenantCompareFixture(): array
|
||||||
|
{
|
||||||
|
$sourceTenant = Tenant::factory()->create();
|
||||||
|
$targetTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $sourceTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sourceTenant' => $sourceTenant,
|
||||||
|
'targetTenant' => $targetTenant,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $snapshot
|
||||||
|
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
|
||||||
|
*/
|
||||||
|
function createComparedPolicy(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $displayName,
|
||||||
|
array $snapshot,
|
||||||
|
string $policyType = 'deviceConfiguration',
|
||||||
|
?string $externalId = null,
|
||||||
|
): array {
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'platform' => 'windows',
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => $snapshot,
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventory = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'external_id' => (string) $policy->external_id,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policy' => $policy,
|
||||||
|
'version' => $version,
|
||||||
|
'inventory' => $inventory,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||||
|
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('classifies ready, blocked, and manual mapping subjects from the compare preview', function (): void {
|
||||||
|
$fixture = crossTenantPromotionFixture();
|
||||||
|
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Aligned Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'aligned', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Aligned Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'aligned', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Different Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'different', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Different Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'different', 'value' => 2]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Missing Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'missing', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Manual Mapping Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Manual Mapping Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
|
||||||
|
externalId: 'manual-target-1',
|
||||||
|
);
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['targetTenant'],
|
||||||
|
displayName: 'Manual Mapping Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'manual', 'value' => 1]]],
|
||||||
|
externalId: 'manual-target-2',
|
||||||
|
);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => ' ',
|
||||||
|
'external_id' => 'missing-source-identifier',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => 'meta-only-source',
|
||||||
|
'display_name' => 'Refresh Required Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => 'meta-only-source',
|
||||||
|
'display_name' => 'Refresh Required Policy',
|
||||||
|
'meta_jsonb' => ['etag' => 'source-meta-only'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => 'meta-only-target',
|
||||||
|
'display_name' => 'Refresh Required Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'external_id' => 'meta-only-target',
|
||||||
|
'display_name' => 'Refresh Required Policy',
|
||||||
|
'meta_jsonb' => ['etag' => 'target-meta-only'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$selection = new CrossTenantCompareSelection(
|
||||||
|
sourceTenant: $fixture['sourceTenant'],
|
||||||
|
targetTenant: $fixture['targetTenant'],
|
||||||
|
policyTypes: ['deviceConfiguration'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
|
||||||
|
$preflight = app(CrossTenantPromotionPreflight::class)->build($preview);
|
||||||
|
|
||||||
|
expect($preflight['summary'])->toBe([
|
||||||
|
'ready' => 3,
|
||||||
|
'blocked' => 2,
|
||||||
|
'manual_mapping_required' => 1,
|
||||||
|
'total' => 6,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bucketByName = collect($preflight['buckets']['ready'])
|
||||||
|
->merge($preflight['buckets']['blocked'])
|
||||||
|
->merge($preflight['buckets']['manual_mapping_required'])
|
||||||
|
->mapWithKeys(static fn (array $subject): array => [
|
||||||
|
(string) ($subject['displayName'] ?? '') => $subject['preflight'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(data_get($bucketByName, 'Aligned Policy.bucket'))->toBe('ready')
|
||||||
|
->and(data_get($bucketByName, 'Different Policy.bucket'))->toBe('ready')
|
||||||
|
->and(data_get($bucketByName, 'Missing Policy.bucket'))->toBe('ready')
|
||||||
|
->and(data_get($bucketByName, 'Manual Mapping Policy.bucket'))->toBe('manual_mapping_required')
|
||||||
|
->and(data_get($bucketByName, 'Refresh Required Policy.bucket'))->toBe('blocked');
|
||||||
|
|
||||||
|
$identifierGap = collect($preflight['buckets']['blocked'])
|
||||||
|
->first(fn (array $subject): bool => in_array('source_identifier_missing', data_get($subject, 'preflight.reasonCodes', []), true));
|
||||||
|
|
||||||
|
expect($identifierGap)->toBeArray()
|
||||||
|
->and(data_get($identifierGap, 'preflight.reasonLabels.0'))->toBe('Source tenant subject is missing a stable compare identifier.')
|
||||||
|
->and($preflight['blockedReasonCounts'])->toMatchArray([
|
||||||
|
'source_identifier_missing' => 1,
|
||||||
|
'source_evidence_refresh_required' => 1,
|
||||||
|
'target_subject_ambiguous' => 1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remains read only when building a promotion preflight', function (): void {
|
||||||
|
$fixture = crossTenantPromotionFixture();
|
||||||
|
|
||||||
|
createPromotionSubject(
|
||||||
|
tenant: $fixture['sourceTenant'],
|
||||||
|
displayName: 'Readonly Policy',
|
||||||
|
snapshot: ['settings' => [['key' => 'readonly', 'value' => 1]]],
|
||||||
|
);
|
||||||
|
|
||||||
|
$preview = app(CrossTenantComparePreviewBuilder::class)->build(new CrossTenantCompareSelection(
|
||||||
|
sourceTenant: $fixture['sourceTenant'],
|
||||||
|
targetTenant: $fixture['targetTenant'],
|
||||||
|
policyTypes: ['deviceConfiguration'],
|
||||||
|
));
|
||||||
|
|
||||||
|
$operationRunCount = OperationRun::query()->count();
|
||||||
|
$policyVersionCount = PolicyVersion::query()->count();
|
||||||
|
|
||||||
|
app(CrossTenantPromotionPreflight::class)->build($preview);
|
||||||
|
|
||||||
|
expect(OperationRun::query()->count())->toBe($operationRunCount)
|
||||||
|
->and(PolicyVersion::query()->count())->toBe($policyVersionCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{sourceTenant: Tenant, targetTenant: Tenant}
|
||||||
|
*/
|
||||||
|
function crossTenantPromotionFixture(): array
|
||||||
|
{
|
||||||
|
$sourceTenant = Tenant::factory()->create();
|
||||||
|
$targetTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $sourceTenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sourceTenant' => $sourceTenant,
|
||||||
|
'targetTenant' => $targetTenant,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $snapshot
|
||||||
|
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
|
||||||
|
*/
|
||||||
|
function createPromotionSubject(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $displayName,
|
||||||
|
array $snapshot,
|
||||||
|
string $policyType = 'deviceConfiguration',
|
||||||
|
?string $externalId = null,
|
||||||
|
): array {
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'platform' => 'windows',
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => $snapshot,
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventory = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'external_id' => (string) $policy->external_id,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'policy' => $policy,
|
||||||
|
'version' => $version,
|
||||||
|
'inventory' => $inventory,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight
|
# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight
|
||||||
|
|
||||||
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
|
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
|
||||||
**Created**: 2026-04-27
|
**Created**: 2026-04-27
|
||||||
**Feature**: [spec.md](../spec.md)
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
## Content Quality
|
## Content Quality
|
||||||
@ -54,4 +54,6 @@ ## Notes
|
|||||||
|
|
||||||
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists.
|
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists.
|
||||||
- The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation.
|
- The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation.
|
||||||
- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets.
|
- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets.
|
||||||
|
- Implementation sync on 2026-04-30 confirmed the code still honors those guardrails: the landed slice remains read-only, adds no compare resource to global search, and introduces no new asset registration.
|
||||||
|
- TEST-GOV-001 close-out for the landed slice stays `keep`: focused `Unit` + `Feature` proof only, with actual execution, mapping automation, and multi-provider compare explicitly deferred as follow-up work rather than hidden scope growth.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
|
# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
|
||||||
|
|
||||||
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
|
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-30 | **Spec**: [spec.md](spec.md)
|
||||||
**Input**: Feature specification from [spec.md](spec.md)
|
**Input**: Feature specification from [spec.md](spec.md)
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
@ -9,22 +9,45 @@ ## Summary
|
|||||||
|
|
||||||
Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected.
|
Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected.
|
||||||
|
|
||||||
|
## Implementation Sync
|
||||||
|
|
||||||
|
- Landed runtime artifacts:
|
||||||
|
- `App\Filament\Pages\CrossTenantComparePage`
|
||||||
|
- `App\Support\PortfolioCompare\CrossTenantCompareSelection`
|
||||||
|
- `App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder`
|
||||||
|
- `App\Support\PortfolioCompare\CrossTenantPromotionPreflight`
|
||||||
|
- tenant-registry row launch, exact-two bulk launch, and return wiring in `TenantResource` and `CanonicalNavigationContext`
|
||||||
|
- bounded preflight audit logging in `WorkspaceAuditLogger` and `AuditActionId`
|
||||||
|
- Landed validation artifacts:
|
||||||
|
- focused `Unit/Support/PortfolioCompare` tests for compare preview and promotion preflight
|
||||||
|
- focused `Feature/PortfolioCompare` tests for page rendering, auth semantics, audit semantics, and registry launch continuity
|
||||||
|
- Confirmed implementation constraints:
|
||||||
|
- read-only only; no target mutation, queue, or `OperationRun`
|
||||||
|
- no new asset registration
|
||||||
|
- no new globally searchable resource
|
||||||
|
- admin panel provider registration remains unchanged outside explicit page registration in Filament's admin panel provider
|
||||||
|
- Deferred follow-up remains unchanged:
|
||||||
|
- actual promotion execution
|
||||||
|
- persisted promotion drafts or compare snapshots
|
||||||
|
- mapping automation
|
||||||
|
- multi-provider compare
|
||||||
|
|
||||||
## Technical Context
|
## Technical Context
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4, Laravel 12
|
**Language/Version**: PHP 8.4, Laravel 12
|
||||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
|
||||||
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
|
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
|
||||||
**Testing**: Pest v4 `Unit` and `Feature` coverage only
|
**Testing**: Pest v4 `Unit` and `Feature` coverage only
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
||||||
**Project Type**: Web application (Laravel monolith with Filament pages)
|
**Project Type**: Web application (Laravel monolith with Filament pages)
|
||||||
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
|
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
|
||||||
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
|
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
|
||||||
**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders
|
**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context
|
- **Guardrail scope**: one new canonical compare page plus bounded row and exact-two bulk launch actions from existing tenant-registry/portfolio context
|
||||||
- **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives
|
- **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives
|
||||||
- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy
|
- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy
|
||||||
- **State layers in scope**: page, query state
|
- **State layers in scope**: page, query state
|
||||||
@ -32,7 +55,7 @@ ## UI / Surface Guardrail Plan
|
|||||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces
|
- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces
|
||||||
- **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages
|
- **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages
|
||||||
- **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary
|
- **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary
|
||||||
- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly
|
- **Launch default**: the row launch prefills the launched tenant as `target tenant`; the exact-two bulk launch prefills both selected tenants while preserving the same registry return context
|
||||||
- **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope
|
- **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope
|
||||||
- **Repository-signal treatment**: review-mandatory
|
- **Repository-signal treatment**: review-mandatory
|
||||||
- **Special surface test profiles**: standard-native-filament
|
- **Special surface test profiles**: standard-native-filament
|
||||||
|
|||||||
@ -1,11 +1,21 @@
|
|||||||
# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
|
# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
|
||||||
|
|
||||||
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
|
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
|
||||||
**Created**: 2026-01-07
|
**Created**: 2026-01-07
|
||||||
**Updated**: 2026-04-27
|
**Updated**: 2026-04-30
|
||||||
**Status**: Ready for implementation
|
**Status**: Implemented (read-only slice)
|
||||||
**Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition.
|
**Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition.
|
||||||
|
|
||||||
|
## Implementation Sync *(2026-04-30)*
|
||||||
|
|
||||||
|
- The canonical admin compare surface is implemented as `CrossTenantComparePage` under `/admin/cross-tenant-compare` with shareable query state, direct tenant drill-down links, and one dominant read-only action: `Generate promotion preflight`.
|
||||||
|
- The reusable compare contract is implemented in `App\Support\PortfolioCompare\CrossTenantCompareSelection`, `CrossTenantComparePreviewBuilder`, and `CrossTenantPromotionPreflight`.
|
||||||
|
- Portfolio launch continuity is implemented from the tenant registry via a bounded row-level `Compare tenants` action, an exact-two bulk compare launch, and `CanonicalNavigationContext` return-state wiring.
|
||||||
|
- Preflight audit is implemented through the existing workspace audit pipeline using `AuditActionId::CrossTenantPromotionPreflightGenerated` and `WorkspaceAuditLogger`.
|
||||||
|
- The focused `Unit` + `Feature` PortfolioCompare suite is green for compare preview, preflight, authorization, audit, and launch/return continuity.
|
||||||
|
- Explicitly deferred and still out of scope: actual promotion execution, target mutation, queueing, `OperationRun`, persisted compare snapshots, persisted promotion drafts, mapping automation, customer-facing compare, and multi-provider compare.
|
||||||
|
- Guardrails remain unchanged in implementation: Filament v5 on Livewire v4, provider registration stays in `bootstrap/providers.php`, no globally searchable compare resource was introduced, and no new asset registration was added.
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
- **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision.
|
- **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision.
|
||||||
@ -98,7 +108,7 @@ ## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are chang
|
|||||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker |
|
| Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker |
|
||||||
| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row |
|
| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` / `Compare selected` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row |
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
@ -112,7 +122,7 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang
|
|||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none |
|
| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none |
|
||||||
| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none |
|
| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenants should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants / Compare selected | none |
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
@ -242,7 +252,7 @@ ### User Story 3 - Launch compare from portfolio context without losing return s
|
|||||||
|
|
||||||
**Acceptance Scenarios**:
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`.
|
1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or an exact-two bulk selection, **Then** the compare page preserves a return token and prefills the launched tenant context without dropping the current registry filters.
|
||||||
2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored.
|
2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|||||||
@ -6,31 +6,31 @@
|
|||||||
|
|
||||||
# Tasks: Cross-Tenant Compare Preview and Promotion Preflight
|
# Tasks: Cross-Tenant Compare Preview and Promotion Preflight
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/`
|
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/`
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required)
|
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required)
|
||||||
|
|
||||||
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice.
|
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice.
|
||||||
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only.
|
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only.
|
||||||
**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`.
|
**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`.
|
||||||
**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood.
|
**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood.
|
||||||
**Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist.
|
**Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist.
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [ ] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
|
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
|
||||||
- [ ] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only.
|
- [x] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only.
|
||||||
- [ ] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history.
|
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history.
|
||||||
- [ ] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope.
|
- [x] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope.
|
||||||
- [ ] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces.
|
- [x] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces.
|
||||||
- [ ] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth.
|
- [x] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth.
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Context)
|
## Phase 1: Setup (Shared Context)
|
||||||
|
|
||||||
**Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins.
|
**Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins.
|
||||||
|
|
||||||
- [ ] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references.
|
- [x] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references.
|
||||||
- [ ] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`.
|
- [x] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`.
|
||||||
- [ ] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`.
|
- [x] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -40,11 +40,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
|||||||
|
|
||||||
**Critical**: No user-story work should begin until this phase is complete.
|
**Critical**: No user-story work should begin until this phase is complete.
|
||||||
|
|
||||||
- [ ] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework.
|
- [x] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework.
|
||||||
- [ ] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics.
|
- [x] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics.
|
||||||
- [ ] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth.
|
- [x] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth.
|
||||||
- [ ] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation.
|
- [x] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation.
|
||||||
- [ ] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only.
|
- [x] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only.
|
||||||
|
|
||||||
**Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently.
|
**Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently.
|
||||||
|
|
||||||
@ -58,15 +58,15 @@ ## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP
|
|||||||
|
|
||||||
### Tests for User Story 1
|
### Tests for User Story 1
|
||||||
|
|
||||||
- [ ] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
|
- [x] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
|
||||||
- [ ] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
|
- [x] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
|
||||||
- [ ] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`.
|
- [x] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`.
|
||||||
|
|
||||||
### Implementation for User Story 1
|
### Implementation for User Story 1
|
||||||
|
|
||||||
- [ ] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder.
|
- [x] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder.
|
||||||
- [ ] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics.
|
- [x] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics.
|
||||||
- [ ] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary.
|
- [x] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary.
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants.
|
**Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants.
|
||||||
|
|
||||||
@ -80,15 +80,15 @@ ## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P
|
|||||||
|
|
||||||
### Tests for User Story 2
|
### Tests for User Story 2
|
||||||
|
|
||||||
- [ ] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
|
- [x] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
|
||||||
- [ ] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
|
- [x] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
|
||||||
- [ ] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`.
|
- [x] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`.
|
||||||
|
|
||||||
### Implementation for User Story 2
|
### Implementation for User Story 2
|
||||||
|
|
||||||
- [ ] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects.
|
- [x] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects.
|
||||||
- [ ] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons.
|
- [x] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons.
|
||||||
- [ ] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only.
|
- [x] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only.
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page.
|
**Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page.
|
||||||
|
|
||||||
@ -102,13 +102,13 @@ ## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing
|
|||||||
|
|
||||||
### Tests for User Story 3
|
### Tests for User Story 3
|
||||||
|
|
||||||
- [ ] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
- [x] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
||||||
- [ ] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
|
- [x] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
|
||||||
|
|
||||||
### Implementation for User Story 3
|
### Implementation for User Story 3
|
||||||
|
|
||||||
- [ ] T023 [US3] Add a bounded launch action from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` that opens the canonical compare page with the current tenant prefilled as the `target tenant`.
|
- [x] T023 [US3] Add bounded registry launch actions from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` so row launch can prefill the current tenant as the `target tenant` and exact-two bulk launch can prefill both selected tenants.
|
||||||
- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format.
|
- [x] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format.
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters.
|
**Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters.
|
||||||
|
|
||||||
@ -118,11 +118,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
|||||||
|
|
||||||
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
|
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
|
||||||
|
|
||||||
- [ ] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
|
- [x] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
|
||||||
- [ ] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
- [x] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
||||||
- [ ] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
- [x] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
- [ ] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource.
|
- [x] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource.
|
||||||
- [ ] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes.
|
- [x] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user