Compare commits
7 Commits
254-remove
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 61feb48d8a | |||
| 905b595880 | |||
| 7b394918ce | |||
| 4b36d2c64f | |||
| ab9c36f21e | |||
| 54fb65a63a | |||
| 29ad8852ca |
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.
|
||||||
@ -59,6 +59,13 @@ MAIL_PASSWORD=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
SUPPORT_DESK_ENABLED=false
|
||||||
|
SUPPORT_DESK_NAME="External support desk"
|
||||||
|
SUPPORT_DESK_CREATE_URL=
|
||||||
|
SUPPORT_DESK_API_TOKEN=
|
||||||
|
SUPPORT_DESK_TICKET_URL_TEMPLATE=
|
||||||
|
SUPPORT_DESK_TIMEOUT_SECONDS=5
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -105,14 +105,26 @@ public function mount(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
Action::make('clear_tenant_filter')
|
|
||||||
|
$governanceContext = $this->incomingGovernanceContext();
|
||||||
|
|
||||||
|
if ($governanceContext?->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('return_to_governance_inbox')
|
||||||
|
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($governanceContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('clear_tenant_filter')
|
||||||
->label('Clear tenant filter')
|
->label('Clear tenant filter')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
->action(fn (): mixed => $this->clearTenantFilter());
|
||||||
];
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
$context = CanonicalNavigationContext::fromRequest(request());
|
||||||
|
|
||||||
|
return $context?->sourceSurface === 'governance.inbox'
|
||||||
|
? $context
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private function queueUrl(array $overrides = []): string
|
private function queueUrl(array $overrides = []): string
|
||||||
{
|
{
|
||||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||||
|
|||||||
@ -97,14 +97,26 @@ public function mount(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
Action::make('clear_tenant_filter')
|
|
||||||
|
$governanceContext = $this->incomingGovernanceContext();
|
||||||
|
|
||||||
|
if ($governanceContext?->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('return_to_governance_inbox')
|
||||||
|
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($governanceContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('clear_tenant_filter')
|
||||||
->label('Clear tenant filter')
|
->label('Clear tenant filter')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
||||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
->action(fn (): mixed => $this->clearTenantFilter());
|
||||||
];
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
$context = CanonicalNavigationContext::fromRequest(request());
|
||||||
|
|
||||||
|
return $context?->sourceSurface === 'governance.inbox'
|
||||||
|
? $context
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private function queueUrl(): string
|
private function queueUrl(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->filteredTenant();
|
$tenant = $this->filteredTenant();
|
||||||
|
|||||||
@ -75,6 +75,8 @@ class GovernanceInbox extends Page
|
|||||||
|
|
||||||
private ?bool $visibleAlertsFamily = null;
|
private ?bool $visibleAlertsFamily = null;
|
||||||
|
|
||||||
|
private ?bool $visibleFindingExceptionsFamily = null;
|
||||||
|
|
||||||
public ?int $tenantId = null;
|
public ?int $tenantId = null;
|
||||||
|
|
||||||
public ?string $family = null;
|
public ?string $family = null;
|
||||||
@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string
|
|||||||
|
|
||||||
public function navigationContext(): CanonicalNavigationContext
|
public function navigationContext(): CanonicalNavigationContext
|
||||||
{
|
{
|
||||||
return new CanonicalNavigationContext(
|
return CanonicalNavigationContext::forGovernanceInbox(
|
||||||
sourceSurface: 'governance.inbox',
|
|
||||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||||
tenantId: $this->tenantId,
|
tenantId: $this->tenantId,
|
||||||
backLinkLabel: 'Back to governance inbox',
|
|
||||||
backLinkUrl: $this->pageUrl(),
|
backLinkUrl: $this->pageUrl(),
|
||||||
|
familyKey: $this->family,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void
|
|||||||
if (
|
if (
|
||||||
$this->hasVisibleOperationsFamily()
|
$this->hasVisibleOperationsFamily()
|
||||||
|| $this->visibleFindingTenants() !== []
|
|| $this->visibleFindingTenants() !== []
|
||||||
|
|| $this->hasVisibleFindingExceptionsFamily()
|
||||||
|| $this->reviewTenants() !== []
|
|| $this->reviewTenants() !== []
|
||||||
|| $this->hasVisibleAlertsFamily()
|
|| $this->hasVisibleAlertsFamily()
|
||||||
) {
|
) {
|
||||||
@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool
|
|||||||
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasVisibleFindingExceptionsFamily(): bool
|
||||||
|
{
|
||||||
|
if (is_bool($this->visibleFindingExceptionsFamily)) {
|
||||||
|
return $this->visibleFindingExceptionsFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->authorizedTenants() === []) {
|
||||||
|
return $this->visibleFindingExceptionsFamily = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||||
|
return $this->visibleFindingExceptionsFamily = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class)
|
||||||
|
->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, Tenant>
|
||||||
*/
|
*/
|
||||||
@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string
|
|||||||
return in_array($family, [
|
return in_array($family, [
|
||||||
'assigned_findings',
|
'assigned_findings',
|
||||||
'intake_findings',
|
'intake_findings',
|
||||||
|
'finding_exceptions',
|
||||||
'stale_operations',
|
'stale_operations',
|
||||||
'alert_delivery_failures',
|
'alert_delivery_failures',
|
||||||
'review_follow_up',
|
'review_follow_up',
|
||||||
@ -424,6 +448,7 @@ private function inboxPayload(): array
|
|||||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
visibleFindingTenants: $this->visibleFindingTenants(),
|
||||||
reviewTenants: $this->reviewTenants(),
|
reviewTenants: $this->reviewTenants(),
|
||||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
||||||
|
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
|
||||||
selectedTenant: $this->selectedTenant(),
|
selectedTenant: $this->selectedTenant(),
|
||||||
selectedFamily: $this->family,
|
selectedFamily: $this->family,
|
||||||
navigationContext: $this->navigationContext(),
|
navigationContext: $this->navigationContext(),
|
||||||
@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array
|
|||||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
visibleFindingTenants: $this->visibleFindingTenants(),
|
||||||
reviewTenants: $this->reviewTenants(),
|
reviewTenants: $this->reviewTenants(),
|
||||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
||||||
|
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
|
||||||
selectedTenant: null,
|
selectedTenant: null,
|
||||||
selectedFamily: null,
|
selectedFamily: null,
|
||||||
navigationContext: $this->navigationContext(),
|
navigationContext: $this->navigationContext(),
|
||||||
|
|||||||
@ -208,6 +208,16 @@ protected function getHeaderActions(): array
|
|||||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
returnActionName: 'operate_hub_return_finding_exceptions',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$governanceContext = $this->incomingGovernanceContext();
|
||||||
|
|
||||||
|
if ($governanceContext?->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('return_to_governance_inbox')
|
||||||
|
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($governanceContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
$actions[] = Action::make('clear_filters')
|
$actions[] = Action::make('clear_filters')
|
||||||
->label('Clear filters')
|
->label('Clear filters')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
return $this->appendQuery(
|
||||||
|
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
|
||||||
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function selectedFindingUrl(): ?string
|
public function selectedFindingUrl(): ?string
|
||||||
@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
return $this->appendQuery(
|
||||||
|
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||||
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearSelectedException(): void
|
public function clearSelectedException(): void
|
||||||
@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext
|
|||||||
return CanonicalNavigationContext::fromRequest(request());
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
$context = $this->navigationContext();
|
||||||
|
|
||||||
|
return $context?->sourceSurface === 'governance.inbox'
|
||||||
|
? $context
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeSelectedFindingExceptionId(): void
|
private function normalizeSelectedFindingExceptionId(): void
|
||||||
{
|
{
|
||||||
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
|
||||||
@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string
|
|||||||
|
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
@ -49,6 +50,7 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void
|
|||||||
private function requestSupportAction(): Action
|
private function requestSupportAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('requestSupport')
|
$action = Action::make('requestSupport')
|
||||||
->label('Request support')
|
->label(__('localization.dashboard.request_support'))
|
||||||
->icon('heroicon-o-paper-airplane')
|
->icon('heroicon-o-paper-airplane')
|
||||||
->record($this->run)
|
->record($this->run)
|
||||||
->slideOver()
|
->slideOver()
|
||||||
->stickyModalHeader()
|
->stickyModalHeader()
|
||||||
->modalHeading('Request support')
|
->modalHeading(__('localization.dashboard.support_request_heading'))
|
||||||
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
|
->modalDescription(__('localization.dashboard.support_request_run_description'))
|
||||||
->modalSubmitActionLabel('Submit support request')
|
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
|
||||||
->form([
|
->form([
|
||||||
Placeholder::make('primary_context')
|
Placeholder::make('primary_context')
|
||||||
->label('Primary context')
|
->label(__('localization.dashboard.primary_context'))
|
||||||
->content(fn (): string => OperationRunLinks::identifier($this->run))
|
->content(fn (): string => OperationRunLinks::identifier($this->run))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Placeholder::make('included_context')
|
Placeholder::make('included_context')
|
||||||
->label('Included context')
|
->label(__('localization.dashboard.included_context'))
|
||||||
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
|
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('latest_external_handoff')
|
||||||
|
->label(__('localization.dashboard.latest_external_handoff'))
|
||||||
|
->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary())
|
||||||
|
->columnSpanFull(),
|
||||||
|
Select::make('external_handoff_mode')
|
||||||
|
->label(__('localization.dashboard.external_handoff_mode'))
|
||||||
|
->options(fn (): array => $this->supportHandoffModeOptions())
|
||||||
|
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
|
||||||
|
->helperText(fn (): string => $this->supportDeskTargetAvailable()
|
||||||
|
? __('localization.dashboard.external_handoff_mode_helper_available')
|
||||||
|
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->native(false),
|
||||||
|
Placeholder::make('handoff_mutation_scope')
|
||||||
|
->label(__('localization.dashboard.handoff_mutation_scope'))
|
||||||
|
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('external_ticket_reference')
|
||||||
|
->label(__('localization.dashboard.external_ticket_reference'))
|
||||||
|
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
|
||||||
|
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||||
|
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
|
||||||
|
TextInput::make('external_ticket_url')
|
||||||
|
->label(__('localization.dashboard.external_ticket_url'))
|
||||||
|
->helperText(__('localization.dashboard.external_ticket_url_helper'))
|
||||||
|
->url()
|
||||||
|
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||||
|
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->columnSpanFull(),
|
||||||
Select::make('severity')
|
Select::make('severity')
|
||||||
->label('Severity')
|
->label(__('localization.dashboard.severity'))
|
||||||
->options(SupportRequest::severityOptions())
|
->options(SupportRequest::severityOptions())
|
||||||
->default(SupportRequest::SEVERITY_NORMAL)
|
->default(SupportRequest::SEVERITY_NORMAL)
|
||||||
->required()
|
->required()
|
||||||
->native(false),
|
->native(false),
|
||||||
TextInput::make('summary')
|
TextInput::make('summary')
|
||||||
->label('Summary')
|
->label(__('localization.dashboard.summary'))
|
||||||
->required()
|
->required()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Textarea::make('reproduction_notes')
|
Textarea::make('reproduction_notes')
|
||||||
->label('Reproduction notes')
|
->label(__('localization.dashboard.reproduction_notes'))
|
||||||
->rows(4)
|
->rows(4)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
TextInput::make('contact_name')
|
TextInput::make('contact_name')
|
||||||
->label('Contact name')
|
->label(__('localization.dashboard.contact_name'))
|
||||||
->default(fn (): ?string => $this->resolveViewerActor()->name),
|
->default(fn (): ?string => $this->resolveViewerActor()->name),
|
||||||
TextInput::make('contact_email')
|
TextInput::make('contact_email')
|
||||||
->label('Contact email')
|
->label(__('localization.dashboard.contact_email'))
|
||||||
->email()
|
->email()
|
||||||
->default(fn (): ?string => $this->resolveViewerActor()->email),
|
->default(fn (): ?string => $this->resolveViewerActor()->email),
|
||||||
])
|
])
|
||||||
@ -312,9 +345,21 @@ private function requestSupportAction(): Action
|
|||||||
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
|
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Support request submitted')
|
->title(__('localization.dashboard.support_request_submitted'))
|
||||||
->body('Reference '.$supportRequest->internal_reference)
|
->body($this->supportRequestNotificationBody($supportRequest))
|
||||||
->success()
|
->when(
|
||||||
|
$supportRequest->hasExternalHandoffFailure(),
|
||||||
|
fn (Notification $notification): Notification => $notification->warning(),
|
||||||
|
fn (Notification $notification): Notification => $notification->success(),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$supportRequest->external_ticket_url !== null,
|
||||||
|
fn (Notification $notification): Notification => $notification->actions([
|
||||||
|
Action::make('openExternalTicket')
|
||||||
|
->label(__('localization.dashboard.open_external_ticket'))
|
||||||
|
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
|
||||||
|
]),
|
||||||
|
)
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string
|
|||||||
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
|
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function operationLatestSupportRequestHandoffSummary(): string
|
||||||
|
{
|
||||||
|
$user = $this->resolveViewerActor();
|
||||||
|
|
||||||
|
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user);
|
||||||
|
|
||||||
|
return $this->formatLatestHandoffSummary($summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function supportHandoffModeOptions(): array
|
||||||
|
{
|
||||||
|
if (! $this->supportDeskTargetAvailable()) {
|
||||||
|
return [
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportDeskTargetAvailable(): bool
|
||||||
|
{
|
||||||
|
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function externalHandoffMutationScope(mixed $mode): string
|
||||||
|
{
|
||||||
|
return match ($mode) {
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
|
||||||
|
default => __('localization.dashboard.mutation_scope_internal_only'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $summary
|
||||||
|
*/
|
||||||
|
private function formatLatestHandoffSummary(?array $summary): string
|
||||||
|
{
|
||||||
|
if ($summary === null) {
|
||||||
|
return __('localization.dashboard.latest_external_handoff_none');
|
||||||
|
}
|
||||||
|
|
||||||
|
$internalReference = (string) $summary['internal_reference'];
|
||||||
|
|
||||||
|
if (($summary['has_failure'] ?? false) === true) {
|
||||||
|
return __('localization.dashboard.latest_external_handoff_failed', [
|
||||||
|
'reference' => $internalReference,
|
||||||
|
'failure' => (string) $summary['external_handoff_failure_summary'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($summary['has_external_link'] ?? false) === true) {
|
||||||
|
return __('localization.dashboard.latest_external_handoff_linked', [
|
||||||
|
'reference' => $internalReference,
|
||||||
|
'external' => (string) $summary['external_ticket_reference'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return __('localization.dashboard.latest_external_handoff_internal_only', [
|
||||||
|
'reference' => $internalReference,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
|
||||||
|
{
|
||||||
|
return match ($supportRequest->externalHandoffOutcome()) {
|
||||||
|
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
'external' => $supportRequest->external_ticket_reference,
|
||||||
|
]),
|
||||||
|
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
'external' => $supportRequest->external_ticket_reference,
|
||||||
|
]),
|
||||||
|
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
'failure' => $supportRequest->external_handoff_failure_summary,
|
||||||
|
]),
|
||||||
|
default => __('localization.dashboard.support_request_submitted_internal_only', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $bundle
|
* @param array<string, mixed> $bundle
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
@ -112,16 +113,28 @@ public function mount(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$actions = [];
|
||||||
Action::make('clear_filters')
|
|
||||||
|
$governanceContext = $this->incomingGovernanceContext();
|
||||||
|
|
||||||
|
if ($governanceContext?->backLinkUrl !== null) {
|
||||||
|
$actions[] = Action::make('return_to_governance_inbox')
|
||||||
|
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
|
||||||
|
->icon('heroicon-o-arrow-left')
|
||||||
|
->color('gray')
|
||||||
|
->url($governanceContext->backLinkUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make('clear_filters')
|
||||||
->label(__('localization.review.clear_filters'))
|
->label(__('localization.review.clear_filters'))
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
->visible(fn (): bool => $this->hasActiveFilters())
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$this->clearWorkspaceFilters();
|
$this->clearWorkspaceFilters();
|
||||||
}),
|
});
|
||||||
];
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -348,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
|
return $this->appendQuery(
|
||||||
self::DETAIL_CONTEXT_QUERY_KEY => 1,
|
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
||||||
]);
|
array_replace(
|
||||||
|
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||||
|
$this->navigationContext()?->toQuery() ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
||||||
@ -527,4 +544,30 @@ private function reviewPackAvailability(Tenant $tenant): string
|
|||||||
|
|
||||||
return __('localization.review.available');
|
return __('localization.review.available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function navigationContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
return CanonicalNavigationContext::fromRequest(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function incomingGovernanceContext(): ?CanonicalNavigationContext
|
||||||
|
{
|
||||||
|
$context = $this->navigationContext();
|
||||||
|
|
||||||
|
return $context?->sourceSurface === 'governance.inbox'
|
||||||
|
? $context
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
*/
|
||||||
|
private function appendQuery(string $url, array $query): string
|
||||||
|
{
|
||||||
|
if ($query === []) {
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||||
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -30,6 +31,7 @@
|
|||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
@ -108,6 +110,37 @@ private function requestSupportAction(): Action
|
|||||||
->label(__('localization.dashboard.included_context'))
|
->label(__('localization.dashboard.included_context'))
|
||||||
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('latest_external_handoff')
|
||||||
|
->label(__('localization.dashboard.latest_external_handoff'))
|
||||||
|
->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary())
|
||||||
|
->columnSpanFull(),
|
||||||
|
Select::make('external_handoff_mode')
|
||||||
|
->label(__('localization.dashboard.external_handoff_mode'))
|
||||||
|
->options(fn (): array => $this->supportHandoffModeOptions())
|
||||||
|
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
|
||||||
|
->helperText(fn (): string => $this->supportDeskTargetAvailable()
|
||||||
|
? __('localization.dashboard.external_handoff_mode_helper_available')
|
||||||
|
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->native(false),
|
||||||
|
Placeholder::make('handoff_mutation_scope')
|
||||||
|
->label(__('localization.dashboard.handoff_mutation_scope'))
|
||||||
|
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('external_ticket_reference')
|
||||||
|
->label(__('localization.dashboard.external_ticket_reference'))
|
||||||
|
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
|
||||||
|
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||||
|
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
|
||||||
|
TextInput::make('external_ticket_url')
|
||||||
|
->label(__('localization.dashboard.external_ticket_url'))
|
||||||
|
->helperText(__('localization.dashboard.external_ticket_url_helper'))
|
||||||
|
->url()
|
||||||
|
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
|
||||||
|
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->columnSpanFull(),
|
||||||
Select::make('severity')
|
Select::make('severity')
|
||||||
->label(__('localization.dashboard.severity'))
|
->label(__('localization.dashboard.severity'))
|
||||||
->options(SupportRequest::severityOptions())
|
->options(SupportRequest::severityOptions())
|
||||||
@ -138,8 +171,20 @@ private function requestSupportAction(): Action
|
|||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('localization.dashboard.support_request_submitted'))
|
->title(__('localization.dashboard.support_request_submitted'))
|
||||||
->body('Reference '.$supportRequest->internal_reference)
|
->body($this->supportRequestNotificationBody($supportRequest))
|
||||||
->success()
|
->when(
|
||||||
|
$supportRequest->hasExternalHandoffFailure(),
|
||||||
|
fn (Notification $notification): Notification => $notification->warning(),
|
||||||
|
fn (Notification $notification): Notification => $notification->success(),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$supportRequest->external_ticket_url !== null,
|
||||||
|
fn (Notification $notification): Notification => $notification->actions([
|
||||||
|
Action::make('openExternalTicket')
|
||||||
|
->label(__('localization.dashboard.open_external_ticket'))
|
||||||
|
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
|
||||||
|
]),
|
||||||
|
)
|
||||||
->send();
|
->send();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -281,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string
|
|||||||
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
|
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
|
||||||
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
|
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function tenantLatestSupportRequestHandoffSummary(): string
|
||||||
|
{
|
||||||
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
$user = $this->resolveDashboardActor();
|
||||||
|
|
||||||
|
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
|
||||||
|
|
||||||
|
return $this->formatLatestHandoffSummary($summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function supportHandoffModeOptions(): array
|
||||||
|
{
|
||||||
|
if (! $this->supportDeskTargetAvailable()) {
|
||||||
|
return [
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportDeskTargetAvailable(): bool
|
||||||
|
{
|
||||||
|
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function externalHandoffMutationScope(mixed $mode): string
|
||||||
|
{
|
||||||
|
return match ($mode) {
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
|
||||||
|
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
|
||||||
|
default => __('localization.dashboard.mutation_scope_internal_only'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $summary
|
||||||
|
*/
|
||||||
|
private function formatLatestHandoffSummary(?array $summary): string
|
||||||
|
{
|
||||||
|
if ($summary === null) {
|
||||||
|
return __('localization.dashboard.latest_external_handoff_none');
|
||||||
|
}
|
||||||
|
|
||||||
|
$internalReference = (string) $summary['internal_reference'];
|
||||||
|
|
||||||
|
if (($summary['has_failure'] ?? false) === true) {
|
||||||
|
return __('localization.dashboard.latest_external_handoff_failed', [
|
||||||
|
'reference' => $internalReference,
|
||||||
|
'failure' => (string) $summary['external_handoff_failure_summary'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($summary['has_external_link'] ?? false) === true) {
|
||||||
|
return __('localization.dashboard.latest_external_handoff_linked', [
|
||||||
|
'reference' => $internalReference,
|
||||||
|
'external' => (string) $summary['external_ticket_reference'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return __('localization.dashboard.latest_external_handoff_internal_only', [
|
||||||
|
'reference' => $internalReference,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
|
||||||
|
{
|
||||||
|
return match ($supportRequest->externalHandoffOutcome()) {
|
||||||
|
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
'external' => $supportRequest->external_ticket_reference,
|
||||||
|
]),
|
||||||
|
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
'external' => $supportRequest->external_ticket_reference,
|
||||||
|
]),
|
||||||
|
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
'failure' => $supportRequest->external_handoff_failure_summary,
|
||||||
|
]),
|
||||||
|
default => __('localization.dashboard.support_request_submitted_internal_only', [
|
||||||
|
'reference' => $supportRequest->internal_reference,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1871,8 +1871,11 @@ private function upsertFindings(
|
|||||||
} else {
|
} else {
|
||||||
$this->observeFinding(
|
$this->observeFinding(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
|
tenant: $tenant,
|
||||||
observedAt: $observedAt,
|
observedAt: $observedAt,
|
||||||
currentOperationRunId: (int) $this->operationRun->getKey(),
|
currentOperationRunId: (int) $this->operationRun->getKey(),
|
||||||
|
severity: (string) $driftItem['severity'],
|
||||||
|
slaPolicy: $slaPolicy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1947,12 +1950,21 @@ private function upsertFindings(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
|
private function observeFinding(
|
||||||
|
Finding $finding,
|
||||||
|
Tenant $tenant,
|
||||||
|
CarbonImmutable $observedAt,
|
||||||
|
int $currentOperationRunId,
|
||||||
|
string $severity,
|
||||||
|
FindingSlaPolicy $slaPolicy,
|
||||||
|
): void
|
||||||
{
|
{
|
||||||
if ($finding->first_seen_at === null) {
|
if ($finding->first_seen_at === null) {
|
||||||
$finding->first_seen_at = $observedAt;
|
$finding->first_seen_at = $observedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
|
||||||
|
|
||||||
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
||||||
$finding->last_seen_at = $observedAt;
|
$finding->last_seen_at = $observedAt;
|
||||||
}
|
}
|
||||||
@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
|
|||||||
} elseif ($timesSeen < 1) {
|
} elseif ($timesSeen < 1) {
|
||||||
$finding->times_seen = 1;
|
$finding->times_seen = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($finding->sla_days === null) {
|
||||||
|
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at === null) {
|
||||||
|
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -32,6 +32,20 @@ class SupportRequest extends Model
|
|||||||
|
|
||||||
public const string SEVERITY_BLOCKING = 'blocking';
|
public const string SEVERITY_BLOCKING = 'blocking';
|
||||||
|
|
||||||
|
public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only';
|
||||||
|
|
||||||
|
public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket';
|
||||||
|
|
||||||
|
public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket';
|
||||||
|
|
||||||
|
public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only';
|
||||||
|
|
||||||
|
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created';
|
||||||
|
|
||||||
|
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked';
|
||||||
|
|
||||||
|
public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed';
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -65,6 +79,53 @@ public static function severityValues(): array
|
|||||||
return array_keys(self::severityOptions());
|
return array_keys(self::severityOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function externalHandoffModeOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only',
|
||||||
|
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket',
|
||||||
|
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function externalHandoffModeValues(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::externalHandoffModeOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasExternalTicket(): bool
|
||||||
|
{
|
||||||
|
return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasExternalHandoffFailure(): bool
|
||||||
|
{
|
||||||
|
return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function externalHandoffOutcome(): string
|
||||||
|
{
|
||||||
|
if ($this->hasExternalHandoffFailure()) {
|
||||||
|
return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->hasExternalTicket()) {
|
||||||
|
return self::HANDOFF_OUTCOME_INTERNAL_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->external_handoff_mode) {
|
||||||
|
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED,
|
||||||
|
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED,
|
||||||
|
default => self::HANDOFF_OUTCOME_INTERNAL_ONLY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,
|
||||||
@ -173,4 +210,87 @@ public function logSupportRequestCreated(
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function logSupportRequestExternalTicketCreated(
|
||||||
|
SupportRequest $supportRequest,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
return $this->logSupportRequestExternalHandoff(
|
||||||
|
supportRequest: $supportRequest,
|
||||||
|
actor: $actor,
|
||||||
|
action: AuditActionId::SupportRequestExternalTicketCreated,
|
||||||
|
status: 'success',
|
||||||
|
summaryPrefix: 'External ticket created for support request ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logSupportRequestExternalTicketLinked(
|
||||||
|
SupportRequest $supportRequest,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
return $this->logSupportRequestExternalHandoff(
|
||||||
|
supportRequest: $supportRequest,
|
||||||
|
actor: $actor,
|
||||||
|
action: AuditActionId::SupportRequestExternalTicketLinked,
|
||||||
|
status: 'success',
|
||||||
|
summaryPrefix: 'External ticket linked for support request ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logSupportRequestExternalHandoffFailed(
|
||||||
|
SupportRequest $supportRequest,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
return $this->logSupportRequestExternalHandoff(
|
||||||
|
supportRequest: $supportRequest,
|
||||||
|
actor: $actor,
|
||||||
|
action: AuditActionId::SupportRequestExternalHandoffFailed,
|
||||||
|
status: 'failed',
|
||||||
|
summaryPrefix: 'External handoff failed for support request ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logSupportRequestExternalHandoff(
|
||||||
|
SupportRequest $supportRequest,
|
||||||
|
User|PlatformUser|null $actor,
|
||||||
|
AuditActionId $action,
|
||||||
|
string $status,
|
||||||
|
string $summaryPrefix,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$supportRequest->loadMissing(['tenant.workspace']);
|
||||||
|
|
||||||
|
$tenant = $supportRequest->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new InvalidArgumentException('Support requests must belong to a tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadata = [
|
||||||
|
'internal_reference' => $supportRequest->internal_reference,
|
||||||
|
'primary_context_type' => $supportRequest->primary_context_type,
|
||||||
|
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||||
|
? (string) $supportRequest->operation_run_id
|
||||||
|
: (string) $tenant->getKey(),
|
||||||
|
'external_handoff_mode' => $supportRequest->external_handoff_mode,
|
||||||
|
'external_ticket_reference' => $supportRequest->external_ticket_reference,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($supportRequest->external_handoff_failure_summary !== null) {
|
||||||
|
$metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: $action,
|
||||||
|
context: $metadata,
|
||||||
|
actor: $actor,
|
||||||
|
status: $status,
|
||||||
|
resourceType: 'support_request',
|
||||||
|
resourceId: (string) $supportRequest->getKey(),
|
||||||
|
targetLabel: $supportRequest->internal_reference,
|
||||||
|
summary: $summaryPrefix.$supportRequest->internal_reference,
|
||||||
|
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,7 +163,7 @@ private function upsertFinding(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof Finding) {
|
if ($existing instanceof Finding) {
|
||||||
$this->observeFinding($existing, $observedAt);
|
$this->observeFinding($existing, $tenant, $observedAt, $severity);
|
||||||
|
|
||||||
$existing->forceFill([
|
$existing->forceFill([
|
||||||
'severity' => $severity,
|
'severity' => $severity,
|
||||||
@ -253,7 +253,7 @@ private function handleGaAggregate(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof Finding) {
|
if ($existing instanceof Finding) {
|
||||||
$this->observeFinding($existing, $observedAt);
|
$this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
|
||||||
|
|
||||||
$existing->forceFill([
|
$existing->forceFill([
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy
|
|||||||
return $this->slaPolicy ?? app(FindingSlaPolicy::class);
|
return $this->slaPolicy ?? app(FindingSlaPolicy::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
|
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
|
||||||
{
|
{
|
||||||
if ($finding->first_seen_at === null) {
|
if ($finding->first_seen_at === null) {
|
||||||
$finding->first_seen_at = $observedAt;
|
$finding->first_seen_at = $observedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
|
||||||
|
|
||||||
$lastSeenAt = $finding->last_seen_at;
|
$lastSeenAt = $finding->last_seen_at;
|
||||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||||
|
|
||||||
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
|
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
|
||||||
$finding->last_seen_at = $observedAt;
|
$finding->last_seen_at = $observedAt;
|
||||||
$finding->times_seen = max(0, $timesSeen) + 1;
|
$finding->times_seen = max(0, $timesSeen) + 1;
|
||||||
|
} elseif ($timesSeen < 1) {
|
||||||
return;
|
$finding->times_seen = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timesSeen < 1) {
|
$slaPolicy = $this->resolveSlaPolicy();
|
||||||
$finding->times_seen = 1;
|
|
||||||
|
if ($finding->sla_days === null) {
|
||||||
|
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at === null) {
|
||||||
|
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,7 +140,7 @@ private function handleMissingPermission(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($finding instanceof Finding) {
|
if ($finding instanceof Finding) {
|
||||||
$this->observeFinding($finding, $observedAt);
|
$this->observeFinding($finding, $tenant, $observedAt, $severity);
|
||||||
|
|
||||||
$finding->forceFill([
|
$finding->forceFill([
|
||||||
'severity' => $severity,
|
'severity' => $severity,
|
||||||
@ -216,7 +216,7 @@ private function handleErrorPermission(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof Finding) {
|
if ($existing instanceof Finding) {
|
||||||
$this->observeFinding($existing, $observedAt);
|
$this->observeFinding($existing, $tenant, $observedAt, $severity);
|
||||||
|
|
||||||
$existing->forceFill([
|
$existing->forceFill([
|
||||||
'severity' => $severity,
|
'severity' => $severity,
|
||||||
@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
|
|||||||
return CarbonImmutable::now();
|
return CarbonImmutable::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
|
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
|
||||||
{
|
{
|
||||||
if ($finding->first_seen_at === null) {
|
if ($finding->first_seen_at === null) {
|
||||||
$finding->first_seen_at = $observedAt;
|
$finding->first_seen_at = $observedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
|
||||||
|
|
||||||
$lastSeenAt = $finding->last_seen_at;
|
$lastSeenAt = $finding->last_seen_at;
|
||||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||||
|
|
||||||
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
|
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
|
||||||
$finding->last_seen_at = $observedAt;
|
$finding->last_seen_at = $observedAt;
|
||||||
$finding->times_seen = max(0, $timesSeen) + 1;
|
$finding->times_seen = max(0, $timesSeen) + 1;
|
||||||
|
} elseif ($timesSeen < 1) {
|
||||||
return;
|
$finding->times_seen = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($timesSeen < 1) {
|
if ($finding->sla_days === null) {
|
||||||
$finding->times_seen = 1;
|
$finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at === null) {
|
||||||
|
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
@ -103,6 +104,9 @@ enum AuditActionId: string
|
|||||||
|
|
||||||
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
||||||
case SupportRequestCreated = 'support_request.created';
|
case SupportRequestCreated = 'support_request.created';
|
||||||
|
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
|
||||||
|
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
|
||||||
|
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
|
||||||
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
||||||
case OperationalControlPaused = 'operational_control.paused';
|
case OperationalControlPaused = 'operational_control.paused';
|
||||||
case OperationalControlUpdated = 'operational_control.updated';
|
case OperationalControlUpdated = 'operational_control.updated';
|
||||||
@ -215,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',
|
||||||
@ -248,6 +253,9 @@ private static function labels(): array
|
|||||||
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
self::SupportRequestCreated->value => 'Support request created',
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
|
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
|
||||||
|
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
|
||||||
|
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
|
||||||
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
@ -306,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',
|
||||||
@ -338,6 +347,9 @@ private static function summaries(): array
|
|||||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
self::SupportRequestCreated->value => 'Support request created',
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
|
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
|
||||||
|
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
|
||||||
|
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
|
||||||
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
|
|||||||
@ -6,14 +6,15 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
use App\Filament\Resources\AlertDeliveryResource;
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantTriageReview;
|
use App\Models\TenantTriageReview;
|
||||||
@ -21,14 +22,12 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
final readonly class GovernanceInboxSectionBuilder
|
final readonly class GovernanceInboxSectionBuilder
|
||||||
@ -41,6 +40,7 @@
|
|||||||
private const FAMILY_ORDER = [
|
private const FAMILY_ORDER = [
|
||||||
'assigned_findings',
|
'assigned_findings',
|
||||||
'intake_findings',
|
'intake_findings',
|
||||||
|
'finding_exceptions',
|
||||||
'stale_operations',
|
'stale_operations',
|
||||||
'alert_delivery_failures',
|
'alert_delivery_failures',
|
||||||
'review_follow_up',
|
'review_follow_up',
|
||||||
@ -71,6 +71,7 @@ public function build(
|
|||||||
array $visibleFindingTenants,
|
array $visibleFindingTenants,
|
||||||
array $reviewTenants,
|
array $reviewTenants,
|
||||||
bool $canViewAlerts,
|
bool $canViewAlerts,
|
||||||
|
bool $canViewFindingExceptions = false,
|
||||||
?Tenant $selectedTenant = null,
|
?Tenant $selectedTenant = null,
|
||||||
?string $selectedFamily = null,
|
?string $selectedFamily = null,
|
||||||
?CanonicalNavigationContext $navigationContext = null,
|
?CanonicalNavigationContext $navigationContext = null,
|
||||||
@ -113,6 +114,22 @@ public function build(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($authorizedTenantsById !== []) {
|
if ($authorizedTenantsById !== []) {
|
||||||
|
if ($canViewFindingExceptions) {
|
||||||
|
$findingExceptionsSection = $this->findingExceptionsSection(
|
||||||
|
workspace: $workspace,
|
||||||
|
authorizedTenants: $authorizedTenantsById,
|
||||||
|
selectedTenant: $selectedTenant,
|
||||||
|
navigationContext: $navigationContext,
|
||||||
|
);
|
||||||
|
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
|
||||||
|
$availableFamilies[] = [
|
||||||
|
'key' => $findingExceptionsSection['key'],
|
||||||
|
'label' => $findingExceptionsSection['label'],
|
||||||
|
'count' => $findingExceptionsSection['count'],
|
||||||
|
];
|
||||||
|
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
|
||||||
|
}
|
||||||
|
|
||||||
$operationsSection = $this->operationsSection(
|
$operationsSection = $this->operationsSection(
|
||||||
workspace: $workspace,
|
workspace: $workspace,
|
||||||
authorizedTenants: $authorizedTenantsById,
|
authorizedTenants: $authorizedTenantsById,
|
||||||
@ -191,6 +208,59 @@ public function build(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Tenant> $authorizedTenants
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function findingExceptionsSection(
|
||||||
|
Workspace $workspace,
|
||||||
|
array $authorizedTenants,
|
||||||
|
?Tenant $selectedTenant,
|
||||||
|
?CanonicalNavigationContext $navigationContext,
|
||||||
|
): array {
|
||||||
|
$baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant);
|
||||||
|
$count = (clone $baseQuery)->count();
|
||||||
|
$pendingCount = (clone $baseQuery)
|
||||||
|
->where('status', FindingException::STATUS_PENDING)
|
||||||
|
->count();
|
||||||
|
$expiringCount = (clone $baseQuery)
|
||||||
|
->where('current_validity_state', FindingException::VALIDITY_EXPIRING)
|
||||||
|
->count();
|
||||||
|
$lapsedCount = (clone $baseQuery)
|
||||||
|
->where('status', '!=', FindingException::STATUS_PENDING)
|
||||||
|
->whereIn('current_validity_state', [
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
])
|
||||||
|
->count();
|
||||||
|
$entries = $this->orderedFindingExceptionsQuery(clone $baseQuery)
|
||||||
|
->limit(self::PREVIEW_LIMIT)
|
||||||
|
->get()
|
||||||
|
->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => 'finding_exceptions',
|
||||||
|
'label' => 'Finding exceptions',
|
||||||
|
'count' => $count,
|
||||||
|
'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount),
|
||||||
|
'dominant_action_label' => 'Open finding exceptions',
|
||||||
|
'dominant_action_url' => $this->appendQuery(
|
||||||
|
FindingExceptionsQueue::getUrl(
|
||||||
|
panel: 'admin',
|
||||||
|
parameters: array_filter([
|
||||||
|
'tenant' => $selectedTenant?->external_id,
|
||||||
|
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
|
||||||
|
),
|
||||||
|
$navigationContext?->toQuery() ?? [],
|
||||||
|
),
|
||||||
|
'entries' => $entries,
|
||||||
|
'empty_state' => $selectedTenant instanceof Tenant
|
||||||
|
? 'No finding exceptions match this tenant filter right now.'
|
||||||
|
: 'No finding exceptions need review right now.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, Tenant> $tenants
|
* @param array<int, Tenant> $tenants
|
||||||
* @return array<int, Tenant>
|
* @return array<int, Tenant>
|
||||||
@ -477,28 +547,10 @@ private function reviewFollowUpSection(
|
|||||||
'label' => 'Review follow-up',
|
'label' => 'Review follow-up',
|
||||||
'count' => count($rawEntries),
|
'count' => count($rawEntries),
|
||||||
'summary' => $this->reviewSummary($followUpCount, $changedCount),
|
'summary' => $this->reviewSummary($followUpCount, $changedCount),
|
||||||
'dominant_action_label' => 'Open review follow-up',
|
'dominant_action_label' => 'Open customer review workspace',
|
||||||
'dominant_action_url' => $selectedTenant instanceof Tenant
|
'dominant_action_url' => $selectedTenant instanceof Tenant
|
||||||
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
|
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
|
||||||
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
|
: $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
[
|
|
||||||
'backup_posture' => [
|
|
||||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
|
||||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
|
||||||
],
|
|
||||||
'recovery_evidence' => [
|
|
||||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
|
||||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
|
||||||
],
|
|
||||||
'review_state' => [
|
|
||||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
||||||
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
|
||||||
],
|
|
||||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
||||||
],
|
|
||||||
)),
|
|
||||||
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
|
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
|
||||||
'empty_state' => $selectedTenant instanceof Tenant
|
'empty_state' => $selectedTenant instanceof Tenant
|
||||||
? 'No review follow-up is visible for this tenant filter right now.'
|
? 'No review follow-up is visible for this tenant filter right now.'
|
||||||
@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Tenant> $authorizedTenants
|
||||||
|
*/
|
||||||
|
private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
||||||
|
{
|
||||||
|
$tenantIds = $selectedTenant instanceof Tenant
|
||||||
|
? [(int) $selectedTenant->getKey()]
|
||||||
|
: array_keys($authorizedTenants);
|
||||||
|
|
||||||
|
return FindingException::query()
|
||||||
|
->with([
|
||||||
|
'tenant',
|
||||||
|
'requester:id,name',
|
||||||
|
'owner:id,name',
|
||||||
|
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
||||||
|
])
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query
|
||||||
|
->where('status', FindingException::STATUS_PENDING)
|
||||||
|
->orWhereIn('status', [
|
||||||
|
FindingException::STATUS_EXPIRING,
|
||||||
|
FindingException::STATUS_EXPIRED,
|
||||||
|
])
|
||||||
|
->orWhereIn('current_validity_state', [
|
||||||
|
FindingException::VALIDITY_EXPIRING,
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->orderByRaw(
|
||||||
|
"case
|
||||||
|
when status = ? then 0
|
||||||
|
when current_validity_state = ? then 1
|
||||||
|
when current_validity_state = ? then 2
|
||||||
|
when current_validity_state = ? then 3
|
||||||
|
else 4
|
||||||
|
end asc",
|
||||||
|
[
|
||||||
|
FindingException::STATUS_PENDING,
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
FindingException::VALIDITY_EXPIRING,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
->orderByRaw('case when review_due_at is null then 1 else 0 end asc')
|
||||||
|
->orderBy('review_due_at')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array
|
||||||
|
{
|
||||||
|
$findingLabel = $exception->finding?->resolvedSubjectDisplayName()
|
||||||
|
?? 'Finding #'.$exception->finding_id;
|
||||||
|
$sublineParts = array_values(array_filter([
|
||||||
|
$exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null,
|
||||||
|
FindingExceptionResource::relativeTimeDescription($exception->review_due_at)
|
||||||
|
?? FindingExceptionResource::relativeTimeDescription($exception->expires_at),
|
||||||
|
is_string($exception->request_reason) && $exception->request_reason !== ''
|
||||||
|
? $exception->request_reason
|
||||||
|
: null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'family_key' => 'finding_exceptions',
|
||||||
|
'source_model' => FindingException::class,
|
||||||
|
'source_key' => (string) $exception->getKey(),
|
||||||
|
'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null,
|
||||||
|
'tenant_label' => $exception->tenant?->name,
|
||||||
|
'headline' => $findingLabel,
|
||||||
|
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
||||||
|
'urgency_rank' => match (true) {
|
||||||
|
(string) $exception->status === FindingException::STATUS_PENDING => 0,
|
||||||
|
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1,
|
||||||
|
(string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2,
|
||||||
|
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3,
|
||||||
|
default => 4,
|
||||||
|
},
|
||||||
|
'status_label' => $this->findingExceptionStatusLabel($exception),
|
||||||
|
'destination_url' => $this->appendQuery(
|
||||||
|
FindingExceptionsQueue::getUrl(
|
||||||
|
panel: 'admin',
|
||||||
|
parameters: array_filter([
|
||||||
|
'tenant' => $exception->tenant?->external_id,
|
||||||
|
'exception' => (int) $exception->getKey(),
|
||||||
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||||
|
),
|
||||||
|
$navigationContext?->toQuery() ?? [],
|
||||||
|
),
|
||||||
|
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $row
|
* @param array<string, mixed> $row
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string
|
||||||
|
{
|
||||||
|
if ($count === 0) {
|
||||||
|
return 'No finding exceptions need review in the current scope.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.',
|
||||||
|
$count,
|
||||||
|
$count === 1 ? '' : 's',
|
||||||
|
$pendingCount,
|
||||||
|
$expiringCount,
|
||||||
|
$lapsedCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingExceptionStatusLabel(FindingException $exception): string
|
||||||
|
{
|
||||||
|
if ((string) $exception->status === FindingException::STATUS_PENDING) {
|
||||||
|
return 'Pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array((string) $exception->current_validity_state, [
|
||||||
|
FindingException::VALIDITY_EXPIRING,
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
], true)) {
|
||||||
|
return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
|
||||||
|
}
|
||||||
|
|
||||||
private function reviewSummary(int $followUpCount, int $changedCount): string
|
private function reviewSummary(int $followUpCount, int $changedCount): string
|
||||||
{
|
{
|
||||||
$total = $followUpCount + $changedCount;
|
$total = $followUpCount + $changedCount;
|
||||||
|
|||||||
@ -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
|
||||||
@ -18,6 +20,7 @@ public function __construct(
|
|||||||
public string $sourceSurface,
|
public string $sourceSurface,
|
||||||
public string $canonicalRouteName,
|
public string $canonicalRouteName,
|
||||||
public ?int $tenantId = null,
|
public ?int $tenantId = null,
|
||||||
|
public ?string $familyKey = null,
|
||||||
public ?string $backLinkLabel = null,
|
public ?string $backLinkLabel = null,
|
||||||
public ?string $backLinkUrl = null,
|
public ?string $backLinkUrl = null,
|
||||||
public array $filterPayload = [],
|
public array $filterPayload = [],
|
||||||
@ -56,12 +59,42 @@ public static function fromRequest(Request $request): ?self
|
|||||||
sourceSurface: $sourceSurface,
|
sourceSurface: $sourceSurface,
|
||||||
canonicalRouteName: $canonicalRouteName,
|
canonicalRouteName: $canonicalRouteName,
|
||||||
tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
|
tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
|
||||||
|
familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== ''
|
||||||
|
? (string) $payload['family_key']
|
||||||
|
: null,
|
||||||
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
|
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
|
||||||
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
|
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
|
||||||
filterPayload: [],
|
filterPayload: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function forGovernanceInbox(
|
||||||
|
string $canonicalRouteName,
|
||||||
|
?int $tenantId,
|
||||||
|
?string $familyKey,
|
||||||
|
string $backLinkUrl,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
sourceSurface: 'governance.inbox',
|
||||||
|
canonicalRouteName: $canonicalRouteName,
|
||||||
|
tenantId: $tenantId,
|
||||||
|
familyKey: $familyKey,
|
||||||
|
backLinkLabel: 'Back to governance inbox',
|
||||||
|
backLinkUrl: $backLinkUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
*/
|
*/
|
||||||
@ -117,6 +150,7 @@ private function navPayload(): array
|
|||||||
'source_surface' => $this->sourceSurface,
|
'source_surface' => $this->sourceSurface,
|
||||||
'canonical_route_name' => $this->canonicalRouteName,
|
'canonical_route_name' => $this->canonicalRouteName,
|
||||||
'tenant_id' => $this->tenantId,
|
'tenant_id' => $this->tenantId,
|
||||||
|
'family_key' => $this->familyKey,
|
||||||
'back_label' => $this->backLinkLabel,
|
'back_label' => $this->backLinkLabel,
|
||||||
'back_url' => $this->backLinkUrl,
|
'back_url' => $this->backLinkUrl,
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
|||||||
@ -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,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class ExternalSupportDeskHandoffService
|
||||||
|
{
|
||||||
|
private const int MAX_TIMEOUT_SECONDS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* successful: bool,
|
||||||
|
* external_ticket_reference: ?string,
|
||||||
|
* external_ticket_url: ?string,
|
||||||
|
* failure_summary: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function createTicket(SupportRequest $supportRequest): array
|
||||||
|
{
|
||||||
|
if (! $this->targetIsConfigured()) {
|
||||||
|
return $this->failed('External support desk target is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout($this->timeoutSeconds())
|
||||||
|
->acceptJson()
|
||||||
|
->asJson()
|
||||||
|
->withHeaders($this->headers())
|
||||||
|
->post($this->createUrl(), $this->payloadFor($supportRequest));
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
return $this->failed('External support desk did not respond before the configured timeout.');
|
||||||
|
} catch (RequestException $exception) {
|
||||||
|
return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').');
|
||||||
|
}
|
||||||
|
|
||||||
|
$responsePayload = $response->json();
|
||||||
|
$responsePayload = is_array($responsePayload) ? $responsePayload : [];
|
||||||
|
|
||||||
|
$reference = $this->normalizeReference(
|
||||||
|
data_get($responsePayload, 'ticket_reference')
|
||||||
|
?? data_get($responsePayload, 'external_ticket_reference')
|
||||||
|
?? data_get($responsePayload, 'reference')
|
||||||
|
?? data_get($responsePayload, 'key')
|
||||||
|
?? data_get($responsePayload, 'id'),
|
||||||
|
throwOnInvalid: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($reference === null) {
|
||||||
|
return $this->failed('External support desk did not return a ticket reference.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->normalizeUrl(
|
||||||
|
data_get($responsePayload, 'ticket_url')
|
||||||
|
?? data_get($responsePayload, 'external_ticket_url')
|
||||||
|
?? data_get($responsePayload, 'url')
|
||||||
|
?? data_get($responsePayload, 'web_url')
|
||||||
|
?? data_get($responsePayload, 'html_url'),
|
||||||
|
throwOnInvalid: false,
|
||||||
|
) ?? $this->urlFromTemplate($reference);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'successful' => true,
|
||||||
|
'external_ticket_reference' => $reference,
|
||||||
|
'external_ticket_url' => $url,
|
||||||
|
'failure_summary' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{external_ticket_reference: string, external_ticket_url: ?string}
|
||||||
|
*/
|
||||||
|
public function normalizeLinkedTicket(mixed $reference, mixed $url): array
|
||||||
|
{
|
||||||
|
$normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true);
|
||||||
|
|
||||||
|
if ($normalizedReference === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'external_ticket_reference' => 'The external ticket reference field is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'external_ticket_reference' => $normalizedReference,
|
||||||
|
'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetIsConfigured(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('support_desk.target.enabled', false)
|
||||||
|
&& $this->createUrl() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetName(): string
|
||||||
|
{
|
||||||
|
$name = config('support_desk.target.name', 'External support desk');
|
||||||
|
|
||||||
|
return is_string($name) && trim($name) !== ''
|
||||||
|
? trim($name)
|
||||||
|
: 'External support desk';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function timeoutSeconds(): int
|
||||||
|
{
|
||||||
|
$configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS);
|
||||||
|
|
||||||
|
$seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS;
|
||||||
|
|
||||||
|
return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string}
|
||||||
|
*/
|
||||||
|
private function failed(string $summary): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'successful' => false,
|
||||||
|
'external_ticket_reference' => null,
|
||||||
|
'external_ticket_url' => null,
|
||||||
|
'failure_summary' => $this->boundedFailureSummary($summary),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function headers(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
$token = config('support_desk.target.api_token');
|
||||||
|
|
||||||
|
if (is_string($token) && trim($token) !== '') {
|
||||||
|
$headers['Authorization'] = 'Bearer '.trim($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function payloadFor(SupportRequest $supportRequest): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'support_request' => [
|
||||||
|
'internal_reference' => $supportRequest->internal_reference,
|
||||||
|
'severity' => $supportRequest->severity,
|
||||||
|
'summary' => $supportRequest->summary,
|
||||||
|
'reproduction_notes' => $supportRequest->reproduction_notes,
|
||||||
|
'contact_name' => $supportRequest->contact_name,
|
||||||
|
'contact_email' => $supportRequest->contact_email,
|
||||||
|
'primary_context_type' => $supportRequest->primary_context_type,
|
||||||
|
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||||
|
? $supportRequest->operation_run_id
|
||||||
|
: $supportRequest->tenant_id,
|
||||||
|
'workspace_id' => $supportRequest->workspace_id,
|
||||||
|
'tenant_id' => $supportRequest->tenant_id,
|
||||||
|
'operation_run_id' => $supportRequest->operation_run_id,
|
||||||
|
],
|
||||||
|
'context_envelope' => $supportRequest->context_envelope,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value) && ! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reference = trim((string) $value);
|
||||||
|
|
||||||
|
if ($reference === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) {
|
||||||
|
if ($throwOnInvalid) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'external_ticket_reference' => 'The external ticket reference format is invalid.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = trim($value);
|
||||||
|
|
||||||
|
if ($url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = parse_url($url, PHP_URL_SCHEME);
|
||||||
|
|
||||||
|
if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) {
|
||||||
|
if ($throwOnInvalid) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function urlFromTemplate(string $reference): ?string
|
||||||
|
{
|
||||||
|
$template = config('support_desk.target.ticket_url_template');
|
||||||
|
|
||||||
|
if (! is_string($template) || trim($template) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = str_replace(
|
||||||
|
['{reference}', '{ticket}'],
|
||||||
|
rawurlencode($reference),
|
||||||
|
trim($template),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->normalizeUrl($url, throwOnInvalid: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function boundedFailureSummary(string $summary): string
|
||||||
|
{
|
||||||
|
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
|
||||||
|
|
||||||
|
return mb_substr($summary, 0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ public function __construct(
|
|||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
|
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
|
||||||
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
|
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
|
||||||
|
private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService,
|
||||||
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -95,7 +96,7 @@ private function submit(
|
|||||||
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
|
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
|
||||||
$connection = SupportRequest::query()->getModel()->getConnection();
|
$connection = SupportRequest::query()->getModel()->getConnection();
|
||||||
|
|
||||||
return $connection->transaction(function () use (
|
$supportRequest = $connection->transaction(function () use (
|
||||||
$actor,
|
$actor,
|
||||||
$contactEmail,
|
$contactEmail,
|
||||||
$contactName,
|
$contactName,
|
||||||
@ -127,6 +128,181 @@ private function submit(
|
|||||||
|
|
||||||
return $supportRequest;
|
return $supportRequest;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return $this->finalizeExternalHandoff($supportRequest, $actor, $validated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{
|
||||||
|
* external_handoff_mode: string,
|
||||||
|
* external_ticket_reference: ?string,
|
||||||
|
* external_ticket_url: ?string
|
||||||
|
* } $validated
|
||||||
|
*/
|
||||||
|
private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest
|
||||||
|
{
|
||||||
|
$mode = $validated['external_handoff_mode'];
|
||||||
|
|
||||||
|
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) {
|
||||||
|
$supportRequest->forceFill([
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
|
||||||
|
'external_ticket_reference' => null,
|
||||||
|
'external_ticket_url' => null,
|
||||||
|
'external_handoff_failure_summary' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $supportRequest->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
|
||||||
|
$linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket(
|
||||||
|
$validated['external_ticket_reference'],
|
||||||
|
$validated['external_ticket_url'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$supportRequest->forceFill([
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => $linkedTicket['external_ticket_reference'],
|
||||||
|
'external_ticket_url' => $linkedTicket['external_ticket_url'],
|
||||||
|
'external_handoff_failure_summary' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$supportRequest = $supportRequest->refresh();
|
||||||
|
$this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor);
|
||||||
|
|
||||||
|
return $supportRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest);
|
||||||
|
|
||||||
|
if ($createdTicket['successful']) {
|
||||||
|
$supportRequest->forceFill([
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
'external_ticket_reference' => $createdTicket['external_ticket_reference'],
|
||||||
|
'external_ticket_url' => $createdTicket['external_ticket_url'],
|
||||||
|
'external_handoff_failure_summary' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$supportRequest = $supportRequest->refresh();
|
||||||
|
$this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor);
|
||||||
|
|
||||||
|
return $supportRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportRequest->forceFill([
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
'external_ticket_reference' => null,
|
||||||
|
'external_ticket_url' => null,
|
||||||
|
'external_handoff_failure_summary' => $createdTicket['failure_summary'],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$supportRequest = $supportRequest->refresh();
|
||||||
|
$this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor);
|
||||||
|
|
||||||
|
return $supportRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* internal_reference: string,
|
||||||
|
* primary_context_type: string,
|
||||||
|
* primary_context_id: int|null,
|
||||||
|
* submitted_at: ?string,
|
||||||
|
* external_handoff_mode: string,
|
||||||
|
* external_ticket_reference: ?string,
|
||||||
|
* external_ticket_url: ?string,
|
||||||
|
* external_handoff_failure_summary: ?string,
|
||||||
|
* has_external_link: bool,
|
||||||
|
* has_failure: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array
|
||||||
|
{
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT)
|
||||||
|
->latest('created_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $supportRequest instanceof SupportRequest
|
||||||
|
? $this->summaryFor($supportRequest)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* internal_reference: string,
|
||||||
|
* primary_context_type: string,
|
||||||
|
* primary_context_id: int|null,
|
||||||
|
* submitted_at: ?string,
|
||||||
|
* external_handoff_mode: string,
|
||||||
|
* external_ticket_reference: ?string,
|
||||||
|
* external_ticket_url: ?string,
|
||||||
|
* external_handoff_failure_summary: ?string,
|
||||||
|
* has_external_link: bool,
|
||||||
|
* has_failure: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array
|
||||||
|
{
|
||||||
|
$run->loadMissing('tenant.workspace');
|
||||||
|
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()
|
||||||
|
->where('workspace_id', (int) $run->workspace_id)
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->where('operation_run_id', (int) $run->getKey())
|
||||||
|
->latest('created_at')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $supportRequest instanceof SupportRequest
|
||||||
|
? $this->summaryFor($supportRequest)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* internal_reference: string,
|
||||||
|
* primary_context_type: string,
|
||||||
|
* primary_context_id: int|null,
|
||||||
|
* submitted_at: ?string,
|
||||||
|
* external_handoff_mode: string,
|
||||||
|
* external_ticket_reference: ?string,
|
||||||
|
* external_ticket_url: ?string,
|
||||||
|
* external_handoff_failure_summary: ?string,
|
||||||
|
* has_external_link: bool,
|
||||||
|
* has_failure: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function summaryFor(SupportRequest $supportRequest): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'internal_reference' => (string) $supportRequest->internal_reference,
|
||||||
|
'primary_context_type' => (string) $supportRequest->primary_context_type,
|
||||||
|
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||||
|
? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null)
|
||||||
|
: (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null),
|
||||||
|
'submitted_at' => $supportRequest->created_at?->toIso8601String(),
|
||||||
|
'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY),
|
||||||
|
'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference),
|
||||||
|
'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url),
|
||||||
|
'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary),
|
||||||
|
'has_external_link' => $supportRequest->hasExternalTicket(),
|
||||||
|
'has_failure' => $supportRequest->hasExternalHandoffFailure(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -137,10 +313,20 @@ private function submit(
|
|||||||
* reproduction_notes: ?string,
|
* reproduction_notes: ?string,
|
||||||
* contact_name: ?string,
|
* contact_name: ?string,
|
||||||
* contact_email: ?string,
|
* contact_email: ?string,
|
||||||
|
* external_handoff_mode: string,
|
||||||
|
* external_ticket_reference: ?string,
|
||||||
|
* external_ticket_url: ?string,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function validate(array $data): array
|
private function validate(array $data): array
|
||||||
{
|
{
|
||||||
|
$requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null)
|
||||||
|
?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
|
||||||
|
|
||||||
|
if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) {
|
||||||
|
$requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = validator(
|
$validated = validator(
|
||||||
[
|
[
|
||||||
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
|
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
|
||||||
@ -148,6 +334,9 @@ private function validate(array $data): array
|
|||||||
'reproduction_notes' => $data['reproduction_notes'] ?? null,
|
'reproduction_notes' => $data['reproduction_notes'] ?? null,
|
||||||
'contact_name' => $data['contact_name'] ?? null,
|
'contact_name' => $data['contact_name'] ?? null,
|
||||||
'contact_email' => $data['contact_email'] ?? null,
|
'contact_email' => $data['contact_email'] ?? null,
|
||||||
|
'external_handoff_mode' => $requestedHandoffMode,
|
||||||
|
'external_ticket_reference' => $data['external_ticket_reference'] ?? null,
|
||||||
|
'external_ticket_url' => $data['external_ticket_url'] ?? null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
|
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
|
||||||
@ -155,6 +344,9 @@ private function validate(array $data): array
|
|||||||
'reproduction_notes' => ['nullable', 'string'],
|
'reproduction_notes' => ['nullable', 'string'],
|
||||||
'contact_name' => ['nullable', 'string'],
|
'contact_name' => ['nullable', 'string'],
|
||||||
'contact_email' => ['nullable', 'email'],
|
'contact_email' => ['nullable', 'email'],
|
||||||
|
'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())],
|
||||||
|
'external_ticket_reference' => ['nullable', 'string', 'max:255'],
|
||||||
|
'external_ticket_url' => ['nullable', 'url', 'max:2048'],
|
||||||
],
|
],
|
||||||
)->validate();
|
)->validate();
|
||||||
|
|
||||||
@ -169,6 +361,27 @@ private function validate(array $data): array
|
|||||||
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
|
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
|
||||||
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
|
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
|
||||||
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
|
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
|
||||||
|
$validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null);
|
||||||
|
$validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null);
|
||||||
|
|
||||||
|
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET
|
||||||
|
&& $validated['external_ticket_reference'] === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'external_ticket_reference' => 'The external ticket reference field is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
|
||||||
|
$this->externalSupportDeskHandoffService->normalizeLinkedTicket(
|
||||||
|
$validated['external_ticket_reference'],
|
||||||
|
$validated['external_ticket_url'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
|
||||||
|
$validated['external_ticket_reference'] = null;
|
||||||
|
$validated['external_ticket_url'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
return $validated;
|
return $validated;
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/platform/config/support_desk.php
Normal file
14
apps/platform/config/support_desk.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'target' => [
|
||||||
|
'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false),
|
||||||
|
'name' => env('SUPPORT_DESK_NAME', 'External support desk'),
|
||||||
|
'create_url' => env('SUPPORT_DESK_CREATE_URL'),
|
||||||
|
'api_token' => env('SUPPORT_DESK_API_TOKEN'),
|
||||||
|
'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'),
|
||||||
|
'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5),
|
||||||
|
],
|
||||||
|
];
|
||||||
@ -51,6 +51,10 @@ public function definition(): array
|
|||||||
],
|
],
|
||||||
'omissions' => [],
|
'omissions' => [],
|
||||||
],
|
],
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
|
||||||
|
'external_ticket_reference' => null,
|
||||||
|
'external_ticket_url' => null,
|
||||||
|
'external_handoff_failure_summary' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('support_requests', function (Blueprint $table): void {
|
||||||
|
$table->string('external_handoff_mode')
|
||||||
|
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
|
||||||
|
->after('context_envelope');
|
||||||
|
$table->string('external_ticket_reference')->nullable()->after('external_handoff_mode');
|
||||||
|
$table->text('external_ticket_url')->nullable()->after('external_ticket_reference');
|
||||||
|
$table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('support_requests', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'external_handoff_mode',
|
||||||
|
'external_ticket_reference',
|
||||||
|
'external_ticket_url',
|
||||||
|
'external_handoff_failure_summary',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -80,14 +80,40 @@
|
|||||||
'request_support' => 'Support anfragen',
|
'request_support' => 'Support anfragen',
|
||||||
'support_request_heading' => 'Support anfragen',
|
'support_request_heading' => 'Support anfragen',
|
||||||
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
|
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
|
||||||
'submit_request' => 'Anfrage senden',
|
'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.',
|
||||||
|
'submit_request' => 'Supportanfrage senden',
|
||||||
|
'primary_context' => 'Primärer Kontext',
|
||||||
'included_context' => 'Enthaltener Kontext',
|
'included_context' => 'Enthaltener Kontext',
|
||||||
|
'latest_external_handoff' => 'Letzte externe Übergabe',
|
||||||
|
'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.',
|
||||||
|
'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.',
|
||||||
|
'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.',
|
||||||
|
'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure',
|
||||||
|
'external_handoff_mode' => 'Externe Übergabe',
|
||||||
|
'handoff_mode_internal_only' => 'Nur TenantPilot',
|
||||||
|
'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen',
|
||||||
|
'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen',
|
||||||
|
'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.',
|
||||||
|
'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.',
|
||||||
|
'handoff_mutation_scope' => 'Änderungsumfang',
|
||||||
|
'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.',
|
||||||
|
'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.',
|
||||||
|
'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.',
|
||||||
|
'external_ticket_reference' => 'Externe Ticketreferenz',
|
||||||
|
'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.',
|
||||||
|
'external_ticket_url' => 'Externe Ticket-URL',
|
||||||
|
'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.',
|
||||||
'severity' => 'Schweregrad',
|
'severity' => 'Schweregrad',
|
||||||
'summary' => 'Zusammenfassung',
|
'summary' => 'Zusammenfassung',
|
||||||
'reproduction_notes' => 'Reproduktionshinweise',
|
'reproduction_notes' => 'Reproduktionshinweise',
|
||||||
'contact_name' => 'Kontaktname',
|
'contact_name' => 'Kontaktname',
|
||||||
'contact_email' => 'Kontakt-E-Mail',
|
'contact_email' => 'Kontakt-E-Mail',
|
||||||
'support_request_submitted' => 'Supportanfrage gesendet',
|
'support_request_submitted' => 'Supportanfrage gesendet',
|
||||||
|
'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.',
|
||||||
|
'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.',
|
||||||
|
'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.',
|
||||||
|
'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure',
|
||||||
|
'open_external_ticket' => 'Externes Ticket öffnen',
|
||||||
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
|
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
|
||||||
'support_diagnostics' => 'Supportdiagnosen',
|
'support_diagnostics' => 'Supportdiagnosen',
|
||||||
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',
|
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',
|
||||||
|
|||||||
@ -80,14 +80,40 @@
|
|||||||
'request_support' => 'Request support',
|
'request_support' => 'Request support',
|
||||||
'support_request_heading' => 'Request support',
|
'support_request_heading' => 'Request support',
|
||||||
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
|
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
|
||||||
'submit_request' => 'Submit request',
|
'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.',
|
||||||
|
'submit_request' => 'Submit support request',
|
||||||
|
'primary_context' => 'Primary context',
|
||||||
'included_context' => 'Included context',
|
'included_context' => 'Included context',
|
||||||
|
'latest_external_handoff' => 'Latest external handoff',
|
||||||
|
'latest_external_handoff_none' => 'No support request has been submitted for this context yet.',
|
||||||
|
'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.',
|
||||||
|
'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.',
|
||||||
|
'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure',
|
||||||
|
'external_handoff_mode' => 'External handoff',
|
||||||
|
'handoff_mode_internal_only' => 'TenantPilot only',
|
||||||
|
'handoff_mode_create_external_ticket' => 'Create external ticket',
|
||||||
|
'handoff_mode_link_existing_ticket' => 'Link existing ticket',
|
||||||
|
'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.',
|
||||||
|
'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.',
|
||||||
|
'handoff_mutation_scope' => 'Mutation scope',
|
||||||
|
'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.',
|
||||||
|
'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.',
|
||||||
|
'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.',
|
||||||
|
'external_ticket_reference' => 'External ticket reference',
|
||||||
|
'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.',
|
||||||
|
'external_ticket_url' => 'External ticket URL',
|
||||||
|
'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.',
|
||||||
'severity' => 'Severity',
|
'severity' => 'Severity',
|
||||||
'summary' => 'Summary',
|
'summary' => 'Summary',
|
||||||
'reproduction_notes' => 'Reproduction notes',
|
'reproduction_notes' => 'Reproduction notes',
|
||||||
'contact_name' => 'Contact name',
|
'contact_name' => 'Contact name',
|
||||||
'contact_email' => 'Contact email',
|
'contact_email' => 'Contact email',
|
||||||
'support_request_submitted' => 'Support request submitted',
|
'support_request_submitted' => 'Support request submitted',
|
||||||
|
'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.',
|
||||||
|
'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.',
|
||||||
|
'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.',
|
||||||
|
'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure',
|
||||||
|
'open_external_ticket' => 'Open external ticket',
|
||||||
'open_support_diagnostics' => 'Open support diagnostics',
|
'open_support_diagnostics' => 'Open support diagnostics',
|
||||||
'support_diagnostics' => 'Support diagnostics',
|
'support_diagnostics' => 'Support diagnostics',
|
||||||
'support_diagnostics_description' => 'Redacted tenant context from existing records.',
|
'support_diagnostics_description' => 'Redacted tenant context from existing records.',
|
||||||
|
|||||||
@ -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>
|
||||||
@ -18,7 +18,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
|
This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -528,6 +528,133 @@
|
|||||||
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
|
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
|
||||||
|
tenant: $tenant,
|
||||||
|
statusByType: ['deviceConfiguration' => 'succeeded'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$builder = app(InventoryMetaContract::class);
|
||||||
|
$hasher = app(DriftHasher::class);
|
||||||
|
|
||||||
|
$baselineContract = $builder->build(
|
||||||
|
policyType: 'deviceConfiguration',
|
||||||
|
subjectExternalId: 'policy-x-uuid',
|
||||||
|
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$displayName = 'Policy X';
|
||||||
|
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
|
||||||
|
expect($subjectKey)->not->toBeNull();
|
||||||
|
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => $workspaceSafeExternalId,
|
||||||
|
'subject_key' => (string) $subjectKey,
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'baseline_hash' => $hasher->hashNormalized($baselineContract),
|
||||||
|
'meta_jsonb' => ['display_name' => $displayName],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'external_id' => 'policy-x-uuid',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run1 = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: OperationRunType::BaselineCompare->value,
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
(new CompareBaselineToTenantJob($run1))->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
|
$finding = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('source', 'baseline.compare')
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->sole();
|
||||||
|
|
||||||
|
$expectedSlaDays = (int) $finding->sla_days;
|
||||||
|
$expectedDueAt = $finding->due_at?->toIso8601String();
|
||||||
|
|
||||||
|
expect($expectedSlaDays)->toBeGreaterThan(0)
|
||||||
|
->and($expectedDueAt)->not->toBeNull();
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'sla_days' => null,
|
||||||
|
'due_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z'));
|
||||||
|
|
||||||
|
$run2 = $opService->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: OperationRunType::BaselineCompare->value,
|
||||||
|
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||||
|
context: [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
(new CompareBaselineToTenantJob($run2))->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
$opService,
|
||||||
|
);
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
|
||||||
|
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
|
||||||
|
->and($finding->times_seen)->toBe(2)
|
||||||
|
->and($finding->sla_days)->toBe($expectedSlaDays)
|
||||||
|
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
|
||||||
|
|
||||||
|
\Carbon\CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not create new finding identities when a new snapshot is captured', function () {
|
it('does not create new finding identities when a new snapshot is captured', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -195,6 +195,54 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
|
|||||||
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00');
|
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
|
||||||
|
[$user, $tenant] = createMinimalUserWithTenant();
|
||||||
|
|
||||||
|
$generator = makeGenerator();
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||||
|
$generator->generate($tenant, buildPayload(
|
||||||
|
[gaRoleDef()],
|
||||||
|
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
|
||||||
|
'2026-02-24T10:00:00Z',
|
||||||
|
));
|
||||||
|
|
||||||
|
$finding = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$expectedDueAt = $finding->due_at?->toIso8601String();
|
||||||
|
|
||||||
|
expect($finding->sla_days)->toBe(3)
|
||||||
|
->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00');
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'sla_days' => null,
|
||||||
|
'due_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
|
||||||
|
$result = $generator->generate($tenant, buildPayload(
|
||||||
|
[gaRoleDef()],
|
||||||
|
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
|
||||||
|
'2026-02-24T11:00:00Z',
|
||||||
|
));
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($result->created)->toBe(0)
|
||||||
|
->and($result->unchanged)->toBe(1)
|
||||||
|
->and($finding->status)->toBe(Finding::STATUS_NEW)
|
||||||
|
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
|
||||||
|
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
|
||||||
|
->and($finding->times_seen)->toBe(2)
|
||||||
|
->and($finding->sla_days)->toBe(3)
|
||||||
|
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
it('auto-resolves when assignment is removed', function (): void {
|
it('auto-resolves when assignment is removed', function (): void {
|
||||||
[$user, $tenant] = createMinimalUserWithTenant();
|
[$user, $tenant] = createMinimalUserWithTenant();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('keeps findings intake secondary when opened from the governance inbox', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'Alpha Tenant',
|
||||||
|
'external_id' => 'alpha-tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'assignee_user_id' => null,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'subject_external_id' => 'intake-from-governance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$context = CanonicalNavigationContext::forGovernanceInbox(
|
||||||
|
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
familyKey: 'intake_findings',
|
||||||
|
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||||
|
'tenant_id' => (string) $tenant->getKey(),
|
||||||
|
'family' => 'intake_findings',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Livewire::withQueryParams(array_replace($context->toQuery(), [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
'view' => 'needs_triage',
|
||||||
|
]))
|
||||||
|
->actingAs($user)
|
||||||
|
->test(FindingsIntakeQueue::class)
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
|
||||||
|
->assertActionVisible('return_to_governance_inbox')
|
||||||
|
->assertCanSeeTableRecords([$finding])
|
||||||
|
->assertSee('Shared unassigned work')
|
||||||
|
->assertDontSee('This workspace decision surface routes you');
|
||||||
|
});
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('keeps my findings secondary when opened from the governance inbox', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'Alpha Tenant',
|
||||||
|
'external_id' => 'alpha-tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||||
|
|
||||||
|
Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->assignedTo((int) $user->getKey())
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'subject_external_id' => 'assigned-from-governance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$context = CanonicalNavigationContext::forGovernanceInbox(
|
||||||
|
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
familyKey: 'assigned_findings',
|
||||||
|
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||||
|
'tenant_id' => (string) $tenant->getKey(),
|
||||||
|
'family' => 'assigned_findings',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Livewire::withQueryParams(array_replace($context->toQuery(), [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
]))
|
||||||
|
->actingAs($user)
|
||||||
|
->test(MyFindingsInbox::class)
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
|
||||||
|
->assertActionVisible('return_to_governance_inbox')
|
||||||
|
->assertCanSeeTableRecords([Finding::query()->where('subject_external_id', 'assigned-from-governance')->firstOrFail()])
|
||||||
|
->assertSee('Assigned to me only')
|
||||||
|
->assertDontSee('This workspace decision surface routes you');
|
||||||
|
});
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
it('launches the finding exceptions lane with tenant and family return context', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'Alpha Tenant',
|
||||||
|
'external_id' => 'alpha-tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->riskAccepted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'subject_external_id' => 'governance-exception-lane',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Governance convergence request',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertSee('Finding exceptions')
|
||||||
|
->assertSee('Open finding exceptions')
|
||||||
|
->assertSee('Governance convergence request')
|
||||||
|
->assertSee('nav%5Bfamily_key%5D=finding_exceptions', false)
|
||||||
|
->assertSee('nav%5Bback_url%5D='.urlencode(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $tenant->getKey().'&family=finding_exceptions'), false)
|
||||||
|
->assertSee('exception='.(string) $exception->getKey(), false)
|
||||||
|
->assertDontSee('Open my findings')
|
||||||
|
->assertDontSee('Open findings intake');
|
||||||
|
});
|
||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantTriageReview;
|
use App\Models\TenantTriageReview;
|
||||||
@ -46,6 +47,28 @@
|
|||||||
->reopened()
|
->reopened()
|
||||||
->create();
|
->create();
|
||||||
|
|
||||||
|
$exceptionFinding = Finding::factory()
|
||||||
|
->for($alphaTenant)
|
||||||
|
->riskAccepted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $alphaTenant->workspace_id,
|
||||||
|
'subject_external_id' => 'exception-governance-home',
|
||||||
|
]);
|
||||||
|
|
||||||
|
FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $alphaTenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $alphaTenant->getKey(),
|
||||||
|
'finding_id' => (int) $exceptionFinding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Governance home exception review',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
OperationRun::factory()
|
OperationRun::factory()
|
||||||
->forTenant($alphaTenant)
|
->forTenant($alphaTenant)
|
||||||
->create([
|
->create([
|
||||||
@ -87,13 +110,15 @@
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Assigned findings')
|
->assertSee('Assigned findings')
|
||||||
->assertSee('Findings intake')
|
->assertSee('Findings intake')
|
||||||
|
->assertSee('Finding exceptions')
|
||||||
->assertSee('Operations follow-up')
|
->assertSee('Operations follow-up')
|
||||||
->assertSee('Alert delivery failures')
|
->assertSee('Alert delivery failures')
|
||||||
->assertSee('Review follow-up')
|
->assertSee('Review follow-up')
|
||||||
->assertSee('Open my findings')
|
->assertSee('Open my findings')
|
||||||
|
->assertSee('Open finding exceptions')
|
||||||
->assertSee('Open terminal follow-up')
|
->assertSee('Open terminal follow-up')
|
||||||
->assertSee('Open alert deliveries')
|
->assertSee('Open alert deliveries')
|
||||||
->assertSee('Open review follow-up');
|
->assertSee('Open customer review workspace');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
|
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
|
||||||
@ -141,3 +166,47 @@
|
|||||||
->assertSee('No failed alert deliveries match this tenant filter right now.')
|
->assertSee('No failed alert deliveries match this tenant filter right now.')
|
||||||
->assertDontSee('Open my findings');
|
->assertDontSee('Open my findings');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits the finding exceptions lane when the workspace capability is not visible', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'Alpha Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'readonly');
|
||||||
|
|
||||||
|
Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->assignedTo((int) $user->getKey())
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exceptionFinding = Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->riskAccepted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $exceptionFinding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Hidden exception lane',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Assigned findings')
|
||||||
|
->assertDontSee('Finding exceptions')
|
||||||
|
->assertDontSee('Hidden exception lane');
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('keeps the finding exceptions queue secondary when opened from the governance inbox', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'Alpha Tenant',
|
||||||
|
'external_id' => 'alpha-tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->riskAccepted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'subject_external_id' => 'exception-secondary-finding',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Exception queue return context',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$context = CanonicalNavigationContext::forGovernanceInbox(
|
||||||
|
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
familyKey: 'finding_exceptions',
|
||||||
|
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||||
|
'tenant_id' => (string) $tenant->getKey(),
|
||||||
|
'family' => 'finding_exceptions',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$component = Livewire::withQueryParams(array_replace($context->toQuery(), [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
'exception' => (int) $exception->getKey(),
|
||||||
|
]))
|
||||||
|
->actingAs($user)
|
||||||
|
->test(FindingExceptionsQueue::class)
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
|
||||||
|
->assertSet('selectedFindingExceptionId', (int) $exception->getKey())
|
||||||
|
->assertActionVisible('return_to_governance_inbox')
|
||||||
|
->assertActionVisible('open_selected_exception')
|
||||||
|
->assertActionVisible('open_selected_finding')
|
||||||
|
->assertSee('Exception queue return context')
|
||||||
|
->assertSee('Focused review lane')
|
||||||
|
->assertDontSee('This workspace decision surface routes you');
|
||||||
|
|
||||||
|
expect($component->instance()->selectedExceptionUrl())
|
||||||
|
->toContain('nav%5Bsource_surface%5D=governance.inbox')
|
||||||
|
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
|
||||||
|
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
|
||||||
|
|
||||||
|
expect($component->instance()->selectedFindingUrl())
|
||||||
|
->toContain('nav%5Bsource_surface%5D=governance.inbox')
|
||||||
|
->toContain('nav%5Bfamily_key%5D=finding_exceptions')
|
||||||
|
->toContain('nav%5Btenant_id%5D='.(string) $tenant->getKey());
|
||||||
|
});
|
||||||
@ -149,6 +149,45 @@ function errorPermission(string $key, array $features = []): array
|
|||||||
CarbonImmutable::setTestNow();
|
CarbonImmutable::setTestNow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
$generator = app(PermissionPostureFindingGenerator::class);
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
|
||||||
|
$generator->generate($tenant, buildComparison([
|
||||||
|
missingPermission('Perm.A', ['policy-sync', 'backup']),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->firstOrFail();
|
||||||
|
$expectedDueAt = $finding->due_at?->toIso8601String();
|
||||||
|
|
||||||
|
expect($finding->sla_days)->toBe(7)
|
||||||
|
->and($expectedDueAt)->toBe('2026-03-03T10:00:00+00:00');
|
||||||
|
|
||||||
|
$finding->forceFill([
|
||||||
|
'sla_days' => null,
|
||||||
|
'due_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
|
||||||
|
$result = $generator->generate($tenant, buildComparison([
|
||||||
|
missingPermission('Perm.A', ['policy-sync', 'backup']),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($result->findingsCreated)->toBe(0)
|
||||||
|
->and($result->findingsUnchanged)->toBe(1)
|
||||||
|
->and($finding->status)->toBe(Finding::STATUS_NEW)
|
||||||
|
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
|
||||||
|
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
|
||||||
|
->and($finding->times_seen)->toBe(2)
|
||||||
|
->and($finding->sla_days)->toBe(7)
|
||||||
|
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
// (5) Re-opens resolved finding when permission revoked again
|
// (5) Re-opens resolved finding when permission revoked again
|
||||||
it('re-opens resolved finding when permission is revoked again', function (): void {
|
it('re-opens resolved finding when permission is revoked again', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
|
|||||||
@ -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,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||||
|
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('keeps the customer review workspace secondary when opened from the governance inbox', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'Alpha Tenant',
|
||||||
|
'external_id' => 'alpha-tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
$snapshot = seedTenantReviewEvidence($tenant);
|
||||||
|
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||||
|
$review->forceFill([
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'generated_at' => now(),
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
Filament::bootCurrentPanel();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$context = CanonicalNavigationContext::forGovernanceInbox(
|
||||||
|
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
familyKey: 'review_follow_up',
|
||||||
|
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin', parameters: [
|
||||||
|
'tenant_id' => (string) $tenant->getKey(),
|
||||||
|
'family' => 'review_follow_up',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Livewire::withQueryParams(array_replace($context->toQuery(), [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
]))
|
||||||
|
->actingAs($user)
|
||||||
|
->test(CustomerReviewWorkspace::class)
|
||||||
|
->assertSet('tableFilters.tenant_id.value', (string) $tenant->getKey())
|
||||||
|
->assertActionVisible('return_to_governance_inbox')
|
||||||
|
->assertCanSeeTableRecords([$tenant->fresh()])
|
||||||
|
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $review->fresh()], $tenant), false)
|
||||||
|
->assertSee(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY.'=1', false)
|
||||||
|
->assertSee('nav%5Bsource_surface%5D=governance.inbox', false)
|
||||||
|
->assertSee('nav%5Bfamily_key%5D=review_follow_up', false)
|
||||||
|
->assertDontSee('This workspace decision surface routes you');
|
||||||
|
});
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function spec256ConfigureRunSupportDesk(array $overrides = []): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'support_desk.target' => array_merge([
|
||||||
|
'enabled' => true,
|
||||||
|
'name' => 'Spec 256 Desk',
|
||||||
|
'create_url' => 'https://desk.example.test/api/tickets',
|
||||||
|
'api_token' => null,
|
||||||
|
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
|
||||||
|
'timeout_seconds' => 5,
|
||||||
|
], $overrides),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256RunHandoffComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256OperationRun(Tenant $tenant): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates an external ticket from the operation-run support action', function (): void {
|
||||||
|
spec256ConfigureRunSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
$run = spec256OperationRun($tenant);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::response([
|
||||||
|
'ticket_reference' => 'PSA-RUN-256',
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec256RunHandoffComponent($user, $run)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Run create external ticket handoff.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-256')
|
||||||
|
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-256');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links an existing external ticket from the operation-run support action without outbound create', function (): void {
|
||||||
|
spec256ConfigureRunSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
$run = spec256OperationRun($tenant);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
spec256RunHandoffComponent($user, $run)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Run link existing external ticket.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-RUN-LINK',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBe('PSA-RUN-LINK')
|
||||||
|
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-RUN-LINK');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the internal run support request when external create fails', function (): void {
|
||||||
|
spec256ConfigureRunSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
$run = spec256OperationRun($tenant);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::failedConnection(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec256RunHandoffComponent($user, $run)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Run external handoff failure should keep internal support request.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBeNull()
|
||||||
|
->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout')
|
||||||
|
->and(OperationRun::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function spec256ConfigureAuditSupportDesk(array $overrides = []): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'support_desk.target' => array_merge([
|
||||||
|
'enabled' => true,
|
||||||
|
'name' => 'Spec 256 Desk',
|
||||||
|
'create_url' => 'https://desk.example.test/api/tickets',
|
||||||
|
'api_token' => null,
|
||||||
|
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
|
||||||
|
'timeout_seconds' => 5,
|
||||||
|
], $overrides),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256AuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves support request created audit and records external ticket created audit', function (): void {
|
||||||
|
spec256ConfigureAuditSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::response([
|
||||||
|
'ticket_reference' => 'PSA-AUDIT-CREATED',
|
||||||
|
'raw_secret' => 'must-not-be-copied',
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec256AuditTenantComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Audit external ticket created.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
$createdAudit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->sole();
|
||||||
|
|
||||||
|
$externalAudit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestExternalTicketCreated->value)
|
||||||
|
->sole();
|
||||||
|
|
||||||
|
expect($createdAudit->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($externalAudit->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($externalAudit->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($externalAudit->status)->toBe('success')
|
||||||
|
->and(data_get($externalAudit->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
|
||||||
|
->and(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
|
||||||
|
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-CREATED')
|
||||||
|
->and((string) json_encode($externalAudit->metadata))->not->toContain('must-not-be-copied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records external ticket linked audit without issuing outbound create', function (): void {
|
||||||
|
spec256ConfigureAuditSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
spec256AuditTenantComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Audit external ticket linked.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-AUDIT-LINKED',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$externalAudit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestExternalTicketLinked->value)
|
||||||
|
->sole();
|
||||||
|
|
||||||
|
expect(data_get($externalAudit->metadata, 'external_handoff_mode'))->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBe('PSA-AUDIT-LINKED')
|
||||||
|
->and($externalAudit->status)->toBe('success');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records external handoff failed audit with bounded failure metadata', function (): void {
|
||||||
|
spec256ConfigureAuditSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::failedConnection(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec256AuditTenantComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Audit external ticket failure.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
$externalAudit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestExternalHandoffFailed->value)
|
||||||
|
->sole();
|
||||||
|
|
||||||
|
expect($externalAudit->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($externalAudit->status)->toBe('failed')
|
||||||
|
->and(data_get($externalAudit->metadata, 'external_ticket_reference'))->toBeNull()
|
||||||
|
->and(data_get($externalAudit->metadata, 'external_handoff_failure_summary'))->toContain('configured timeout');
|
||||||
|
});
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function spec256AuthorizationTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256AuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256AuthorizationRun(Tenant $tenant): OperationRun
|
||||||
|
{
|
||||||
|
return OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps external handoff actions forbidden for entitled tenant members without support-create capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
spec256AuthorizationTenantComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeTenantSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps external handoff actions forbidden for entitled run viewers without support-create capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
$run = spec256AuthorizationRun($tenant);
|
||||||
|
|
||||||
|
spec256AuthorizationOperationComponent($user, $run)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeOperationRunSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reveal latest tenant handoff summaries to workspace members without tenant entitlement', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'operator',
|
||||||
|
]);
|
||||||
|
|
||||||
|
SupportRequest::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-HIDDEN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
|
||||||
|
$this->fail('Expected latest handoff summary to deny as not found.');
|
||||||
|
} catch (HttpExceptionInterface $exception) {
|
||||||
|
expect($exception->getStatusCode())->toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reveal latest run handoff summaries outside the run tenant entitlement', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = spec256AuthorizationRun($tenant);
|
||||||
|
|
||||||
|
SupportRequest::factory()
|
||||||
|
->forOperationRun($run)
|
||||||
|
->create([
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-RUN-HIDDEN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($run, $user);
|
||||||
|
$this->fail('Expected latest run handoff summary to deny as not found.');
|
||||||
|
} catch (HttpExceptionInterface $exception) {
|
||||||
|
expect($exception->getStatusCode())->toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function spec256ConfigureTenantSupportDesk(array $overrides = []): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'support_desk.target' => array_merge([
|
||||||
|
'enabled' => true,
|
||||||
|
'name' => 'Spec 256 Desk',
|
||||||
|
'create_url' => 'https://desk.example.test/api/tickets',
|
||||||
|
'api_token' => null,
|
||||||
|
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
|
||||||
|
'timeout_seconds' => 5,
|
||||||
|
], $overrides),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256TenantHandoffComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates an external ticket from the tenant dashboard support action', function (): void {
|
||||||
|
spec256ConfigureTenantSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Spec 256 Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::response([
|
||||||
|
'ticket_reference' => 'PSA-2561',
|
||||||
|
'ticket_url' => 'https://desk.example.test/tickets/PSA-2561',
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec256TenantHandoffComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Tenant create external ticket handoff.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
||||||
|
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBe('PSA-2561')
|
||||||
|
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-2561')
|
||||||
|
->and($supportRequest->external_handoff_failure_summary)->toBeNull()
|
||||||
|
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED);
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets'
|
||||||
|
&& data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links an existing external ticket from the tenant dashboard without creating a duplicate external ticket', function (): void {
|
||||||
|
spec256ConfigureTenantSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
spec256TenantHandoffComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => 'Tenant link existing external ticket.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-256-LINK',
|
||||||
|
'external_ticket_url' => 'https://desk.example.test/tickets/PSA-256-LINK',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBe('PSA-256-LINK')
|
||||||
|
->and($supportRequest->external_ticket_url)->toBe('https://desk.example.test/tickets/PSA-256-LINK')
|
||||||
|
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED);
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid linked external ticket input before storing a tenant support request', function (): void {
|
||||||
|
spec256ConfigureTenantSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
spec256TenantHandoffComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => 'Tenant invalid link should not create support truth.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'not a ticket',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasErrors(['external_ticket_reference']);
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the internal tenant support request when external create fails', function (): void {
|
||||||
|
spec256ConfigureTenantSupportDesk();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::failedConnection(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
spec256TenantHandoffComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_BLOCKING,
|
||||||
|
'summary' => 'Tenant external desk timeout should keep internal support request.',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->internal_reference)->toMatch('/^SR-/')
|
||||||
|
->and($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET)
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBeNull()
|
||||||
|
->and($supportRequest->external_ticket_url)->toBeNull()
|
||||||
|
->and($supportRequest->external_handoff_failure_summary)->toContain('configured timeout')
|
||||||
|
->and($supportRequest->externalHandoffOutcome())->toBe(SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces tenant support requests to internal only when no external target is configured', function (): void {
|
||||||
|
spec256ConfigureTenantSupportDesk([
|
||||||
|
'enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
spec256TenantHandoffComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Tenant support stays internal when no support desk target exists.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->external_handoff_mode)->toBe(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
|
||||||
|
->and($supportRequest->external_ticket_reference)->toBeNull()
|
||||||
|
->and($supportRequest->external_handoff_failure_summary)->toBeNull();
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
@ -52,3 +52,26 @@
|
|||||||
->and($context?->backLinkLabel)->toBe('Back to backup set')
|
->and($context?->backLinkLabel)->toBe('Back to backup set')
|
||||||
->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8');
|
->and($context?->backLinkUrl)->toBe('/admin/backup-sets/8');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes governance inbox family context for secondary surface return links', function (): void {
|
||||||
|
$context = CanonicalNavigationContext::forGovernanceInbox(
|
||||||
|
canonicalRouteName: 'filament.admin.pages.governance.inbox',
|
||||||
|
tenantId: 12,
|
||||||
|
familyKey: 'finding_exceptions',
|
||||||
|
backLinkUrl: '/admin/governance/inbox?tenant_id=12&family=finding_exceptions',
|
||||||
|
);
|
||||||
|
|
||||||
|
$roundTrip = CanonicalNavigationContext::fromRequest(Request::create('/admin/finding-exceptions/queue', 'GET', $context->toQuery()));
|
||||||
|
|
||||||
|
expect($context->toQuery()['nav'])
|
||||||
|
->toMatchArray([
|
||||||
|
'source_surface' => 'governance.inbox',
|
||||||
|
'tenant_id' => 12,
|
||||||
|
'family_key' => 'finding_exceptions',
|
||||||
|
'back_label' => 'Back to governance inbox',
|
||||||
|
])
|
||||||
|
->and($roundTrip?->sourceSurface)->toBe('governance.inbox')
|
||||||
|
->and($roundTrip?->tenantId)->toBe(12)
|
||||||
|
->and($roundTrip?->familyKey)->toBe('finding_exceptions')
|
||||||
|
->and($roundTrip?->backLinkUrl)->toBe('/admin/governance/inbox?tenant_id=12&family=finding_exceptions');
|
||||||
|
});
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\AlertDelivery;
|
use App\Models\AlertDelivery;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\FindingException;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantTriageReview;
|
use App\Models\TenantTriageReview;
|
||||||
@ -54,6 +55,28 @@
|
|||||||
'subject_external_id' => 'intake-finding',
|
'subject_external_id' => 'intake-finding',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$exceptionFinding = Finding::factory()
|
||||||
|
->for($alphaTenant)
|
||||||
|
->riskAccepted()
|
||||||
|
->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'subject_external_id' => 'exception-finding',
|
||||||
|
]);
|
||||||
|
|
||||||
|
FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $alphaTenant->getKey(),
|
||||||
|
'finding_id' => (int) $exceptionFinding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Needs approval',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
OperationRun::factory()
|
OperationRun::factory()
|
||||||
->forTenant($alphaTenant)
|
->forTenant($alphaTenant)
|
||||||
->create([
|
->create([
|
||||||
@ -129,6 +152,7 @@
|
|||||||
visibleFindingTenants: [$alphaTenant, $bravoTenant],
|
visibleFindingTenants: [$alphaTenant, $bravoTenant],
|
||||||
reviewTenants: [$alphaTenant, $bravoTenant],
|
reviewTenants: [$alphaTenant, $bravoTenant],
|
||||||
canViewAlerts: true,
|
canViewAlerts: true,
|
||||||
|
canViewFindingExceptions: true,
|
||||||
navigationContext: $context,
|
navigationContext: $context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -136,6 +160,7 @@
|
|||||||
->toBe([
|
->toBe([
|
||||||
'assigned_findings',
|
'assigned_findings',
|
||||||
'intake_findings',
|
'intake_findings',
|
||||||
|
'finding_exceptions',
|
||||||
'stale_operations',
|
'stale_operations',
|
||||||
'alert_delivery_failures',
|
'alert_delivery_failures',
|
||||||
'review_follow_up',
|
'review_follow_up',
|
||||||
@ -143,6 +168,7 @@
|
|||||||
->and($payload['family_counts'])->toMatchArray([
|
->and($payload['family_counts'])->toMatchArray([
|
||||||
'assigned_findings' => 1,
|
'assigned_findings' => 1,
|
||||||
'intake_findings' => 1,
|
'intake_findings' => 1,
|
||||||
|
'finding_exceptions' => 1,
|
||||||
'stale_operations' => 2,
|
'stale_operations' => 2,
|
||||||
'alert_delivery_failures' => 1,
|
'alert_delivery_failures' => 1,
|
||||||
'review_follow_up' => 2,
|
'review_follow_up' => 2,
|
||||||
@ -153,6 +179,9 @@
|
|||||||
expect($sections['assigned_findings']['dominant_action_url'])
|
expect($sections['assigned_findings']['dominant_action_url'])
|
||||||
->toContain('/admin/findings/my-work')
|
->toContain('/admin/findings/my-work')
|
||||||
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
|
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
|
||||||
|
->and($sections['finding_exceptions']['dominant_action_label'])->toBe('Open finding exceptions')
|
||||||
|
->and($sections['finding_exceptions']['dominant_action_url'])->toContain('/admin/finding-exceptions/queue')
|
||||||
|
->and($sections['finding_exceptions']['entries'][0]['destination_url'])->toContain('exception='.(string) $sections['finding_exceptions']['entries'][0]['source_key'])
|
||||||
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
|
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
|
||||||
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
|
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
|
||||||
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
|
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
|
||||||
@ -197,3 +226,63 @@
|
|||||||
->and($payload['sections'][0]['count'])->toBe(0)
|
->and($payload['sections'][0]['count'])->toBe(0)
|
||||||
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
|
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits finding exceptions when the exception family is hidden or tenant scope is inaccessible', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$visibleTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Visible Tenant',
|
||||||
|
]);
|
||||||
|
$hiddenTenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Hidden Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()
|
||||||
|
->for($hiddenTenant)
|
||||||
|
->riskAccepted()
|
||||||
|
->create(['workspace_id' => (int) $workspace->getKey()]);
|
||||||
|
|
||||||
|
FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $hiddenTenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $user->getKey(),
|
||||||
|
'owner_user_id' => (int) $user->getKey(),
|
||||||
|
'status' => FindingException::STATUS_PENDING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
'request_reason' => 'Hidden request',
|
||||||
|
'requested_at' => now()->subDay(),
|
||||||
|
'review_due_at' => now()->addDay(),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$builder = app(GovernanceInboxSectionBuilder::class);
|
||||||
|
|
||||||
|
$payloadWithoutCapability = $builder->build(
|
||||||
|
user: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
authorizedTenants: [$visibleTenant, $hiddenTenant],
|
||||||
|
visibleFindingTenants: [],
|
||||||
|
reviewTenants: [],
|
||||||
|
canViewAlerts: false,
|
||||||
|
canViewFindingExceptions: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$payloadWithHiddenTenantOnly = $builder->build(
|
||||||
|
user: $user,
|
||||||
|
workspace: $workspace,
|
||||||
|
authorizedTenants: [$visibleTenant],
|
||||||
|
visibleFindingTenants: [],
|
||||||
|
reviewTenants: [],
|
||||||
|
canViewAlerts: false,
|
||||||
|
canViewFindingExceptions: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collect($payloadWithoutCapability['available_families'])->pluck('key')->all())
|
||||||
|
->not->toContain('finding_exceptions')
|
||||||
|
->and($payloadWithHiddenTenantOnly['family_counts']['finding_exceptions'] ?? null)->toBe(0)
|
||||||
|
->and($payloadWithHiddenTenantOnly['sections'])->toBe([]);
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function configureSpec256SupportDesk(array $overrides = []): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'support_desk.target' => array_merge([
|
||||||
|
'enabled' => true,
|
||||||
|
'name' => 'Spec 256 Desk',
|
||||||
|
'create_url' => 'https://desk.example.test/api/tickets',
|
||||||
|
'api_token' => null,
|
||||||
|
'ticket_url_template' => 'https://desk.example.test/tickets/{reference}',
|
||||||
|
'timeout_seconds' => 5,
|
||||||
|
], $overrides),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spec256SupportRequest(array $attributes = []): SupportRequest
|
||||||
|
{
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
return SupportRequest::factory()->create(array_merge([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'summary' => 'Need external support desk handoff.',
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates an external ticket through the configured target and normalizes the returned reference', function (): void {
|
||||||
|
configureSpec256SupportDesk([
|
||||||
|
'api_token' => 'secret-token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$supportRequest = spec256SupportRequest();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::response([
|
||||||
|
'ticket_reference' => 'PSA-12345',
|
||||||
|
'ticket_url' => 'https://desk.example.test/tickets/PSA-12345',
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(ExternalSupportDeskHandoffService::class)->createTicket($supportRequest);
|
||||||
|
|
||||||
|
expect($result['successful'])->toBeTrue()
|
||||||
|
->and($result['external_ticket_reference'])->toBe('PSA-12345')
|
||||||
|
->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-12345')
|
||||||
|
->and($result['failure_summary'])->toBeNull();
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://desk.example.test/api/tickets'
|
||||||
|
&& data_get($request->data(), 'support_request.internal_reference') === $supportRequest->internal_reference
|
||||||
|
&& data_get($request->data(), 'support_request.summary') === 'Need external support desk handoff.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the five second timeout budget and normalizes connection failures', function (): void {
|
||||||
|
configureSpec256SupportDesk([
|
||||||
|
'timeout_seconds' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$supportRequest = spec256SupportRequest();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'desk.example.test/*' => Http::failedConnection(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(ExternalSupportDeskHandoffService::class);
|
||||||
|
$result = $service->createTicket($supportRequest);
|
||||||
|
|
||||||
|
expect($service->timeoutSeconds())->toBe(5)
|
||||||
|
->and($result['successful'])->toBeFalse()
|
||||||
|
->and($result['external_ticket_reference'])->toBeNull()
|
||||||
|
->and($result['failure_summary'])->toContain('configured timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to unavailable when the single configured target is disabled', function (): void {
|
||||||
|
configureSpec256SupportDesk([
|
||||||
|
'enabled' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$service = app(ExternalSupportDeskHandoffService::class);
|
||||||
|
$result = $service->createTicket(spec256SupportRequest());
|
||||||
|
|
||||||
|
expect($service->targetIsConfigured())->toBeFalse()
|
||||||
|
->and($result['successful'])->toBeFalse()
|
||||||
|
->and($result['failure_summary'])->toBe('External support desk target is not configured.');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes linked tickets without issuing an outbound create call', function (): void {
|
||||||
|
configureSpec256SupportDesk();
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$result = app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket(' PSA-900 ', null);
|
||||||
|
|
||||||
|
expect($result['external_ticket_reference'])->toBe('PSA-900')
|
||||||
|
->and($result['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-900');
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid linked ticket input before storing external truth', function (): void {
|
||||||
|
configureSpec256SupportDesk();
|
||||||
|
|
||||||
|
expect(fn (): array => app(ExternalSupportDeskHandoffService::class)->normalizeLinkedTicket('not a ticket', 'javascript:alert(1)'))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns the latest tenant-scoped handoff summary without using run-scoped requests', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
SupportRequest::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
'internal_reference' => 'SR-OLDTENANT0000000000000001',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-OLD',
|
||||||
|
'created_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
SupportRequest::factory()
|
||||||
|
->forOperationRun($run)
|
||||||
|
->create([
|
||||||
|
'internal_reference' => 'SR-RUN000000000000000000001',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-RUN',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
SupportRequest::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
'internal_reference' => 'SR-NEWTENANT0000000000000001',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
|
||||||
|
'external_handoff_failure_summary' => 'External support desk did not respond before the configured timeout.',
|
||||||
|
'created_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
|
||||||
|
|
||||||
|
expect($summary)->not->toBeNull()
|
||||||
|
->and($summary['internal_reference'])->toBe('SR-NEWTENANT0000000000000001')
|
||||||
|
->and($summary['has_failure'])->toBeTrue()
|
||||||
|
->and($summary['has_external_link'])->toBeFalse()
|
||||||
|
->and($summary['external_handoff_failure_summary'])->toContain('configured timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the latest run-scoped handoff summary for the current run only', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$firstRun = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$secondRun = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SupportRequest::factory()
|
||||||
|
->forOperationRun($secondRun)
|
||||||
|
->create([
|
||||||
|
'internal_reference' => 'SR-OTHERRUN0000000000000001',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-OTHER',
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
SupportRequest::factory()
|
||||||
|
->forOperationRun($firstRun)
|
||||||
|
->create([
|
||||||
|
'internal_reference' => 'SR-CURRENTRUN0000000000001',
|
||||||
|
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
|
||||||
|
'external_ticket_reference' => 'PSA-CURRENT',
|
||||||
|
'external_ticket_url' => 'https://desk.example.test/tickets/PSA-CURRENT',
|
||||||
|
'created_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($firstRun, $user);
|
||||||
|
|
||||||
|
expect($summary)->not->toBeNull()
|
||||||
|
->and($summary['internal_reference'])->toBe('SR-CURRENTRUN0000000000001')
|
||||||
|
->and($summary['external_ticket_reference'])->toBe('PSA-CURRENT')
|
||||||
|
->and($summary['external_ticket_url'])->toBe('https://desk.example.test/tickets/PSA-CURRENT')
|
||||||
|
->and($summary['has_external_link'])->toBeTrue();
|
||||||
|
});
|
||||||
@ -15,7 +15,7 @@ ## Purpose
|
|||||||
|
|
||||||
## Current Product Position
|
## Current Product Position
|
||||||
|
|
||||||
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
|
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls und inzwischen repo-real umgesetzten Customer-safe Review Consumption, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance bleiben unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
|
||||||
|
|
||||||
## Status Model
|
## Status Model
|
||||||
|
|
||||||
@ -41,24 +41,24 @@ ## Roadmap Coverage Summary
|
|||||||
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|
||||||
|---|---|---:|---|---|---|---|
|
|---|---|---:|---|---|---|---|
|
||||||
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
|
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
|
||||||
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. |
|
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | yes | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen. |
|
||||||
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
|
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
|
||||||
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
|
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
|
||||||
| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. |
|
| UI & Product Maturity Polish | implemented_partial | strong | partial | partial repo tests, not run | no | Empty States, Navigation, Localization und read-only Review-Polish sind real, aber kein geschlossenes Theme-Completion-Signal. |
|
||||||
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
|
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
|
||||||
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
|
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
|
||||||
| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. |
|
| R1.9 Platform Localization v1 | implemented_verified | strong | yes | repo tests, not run | foundation-only | Locale-Resolver, Override/Praeferenz, Workspace-Default, Fallback und lokalisierte Notifications sind repo-real. |
|
||||||
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
|
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
|
||||||
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
|
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
|
||||||
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
|
| R2 Completion: customer review, support, help | adopted | strong | yes | repo tests, not run | yes | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real. |
|
||||||
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. |
|
| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. |
|
||||||
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
|
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
|
||||||
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
|
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
|
||||||
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
|
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
|
||||||
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
|
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
|
||||||
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
|
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
|
||||||
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. |
|
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow jenseits des jetzigen Exception-/Review-Layers. |
|
||||||
| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. |
|
| Drift & Change Governance | implemented_partial | strong | yes | repo tests, not run | almost | Drift review, accepted-risk governance, exception validity und Governance-Inbox-Surfaces sind repo-real; portfolio-weite Eskalation bleibt offen. |
|
||||||
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
|
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
|
||||||
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
|
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
|
||||||
|
|
||||||
@ -69,10 +69,13 @@ ## Implemented Capabilities
|
|||||||
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
|
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
|
||||||
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
|
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
|
||||||
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
|
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
|
||||||
|
| Findings inboxes and governance inbox | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Findings/MyFindingsInbox.php`; `app/Filament/Pages/Findings/FindingsIntakeQueue.php`; `app/Filament/Pages/Governance/GovernanceInbox.php`; `tests/Feature/Findings/MyWorkInboxTest.php`; `tests/Feature/Governance/*` |
|
||||||
|
| Finding exceptions and risk acceptance workflow | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/FindingException.php`; `app/Services/Findings/FindingExceptionService.php`; `app/Filament/Resources/FindingExceptionResource.php`; `tests/Feature/Findings/FindingExceptionWorkflowTest.php` |
|
||||||
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
|
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
|
||||||
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
|
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
|
||||||
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
|
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
|
||||||
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
|
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
|
||||||
|
| Customer review workspace | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` |
|
||||||
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
|
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
|
||||||
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
|
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
|
||||||
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
|
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
|
||||||
@ -81,6 +84,7 @@ ## Implemented Capabilities
|
|||||||
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
|
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
|
||||||
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
|
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
|
||||||
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
|
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
|
||||||
|
| Localization foundation | implemented_verified | yes | yes | repo tests, not run | partial | foundation-only | `app/Services/Localization/LocaleResolver.php`; `app/Http/Controllers/LocalizationController.php`; `tests/Feature/Localization/*` |
|
||||||
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
|
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
|
||||||
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
|
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
|
||||||
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
|
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
|
||||||
@ -99,14 +103,15 @@ ## Foundation-Only Capabilities
|
|||||||
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
|
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
|
||||||
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
|
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
|
||||||
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
|
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
|
||||||
|
- Localization foundation: resolved locale precedence, Workspace-Default, User-Praeferenz/Override und Notification-Formatting sind real, aber Enablement statt eigener Produkt-Surface.
|
||||||
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
|
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
|
||||||
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
|
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
|
||||||
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
|
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
|
||||||
|
|
||||||
## Partial Capabilities
|
## Partial Capabilities
|
||||||
|
|
||||||
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
|
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber portfolio-weite Consumption- und Sharing-Patterns bleiben offen.
|
||||||
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten.
|
- Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen.
|
||||||
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
|
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
|
||||||
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
|
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
|
||||||
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
|
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
|
||||||
@ -114,13 +119,12 @@ ## Partial Capabilities
|
|||||||
|
|
||||||
## Planned But Not Implemented
|
## Planned But Not Implemented
|
||||||
|
|
||||||
- Platform Localization v1
|
|
||||||
- Private AI Execution & Usage Governance Foundation
|
- Private AI Execution & Usage Governance Foundation
|
||||||
- Human-in-the-Loop Autonomous Governance
|
- Human-in-the-Loop Autonomous Governance
|
||||||
- Standardization & Policy Quality / Intune Linting
|
- Standardization & Policy Quality / Intune Linting
|
||||||
- PSA / Ticketing Handoff
|
- PSA / Ticketing Handoff
|
||||||
- Customer Review Workspace v1
|
|
||||||
- Cross-Tenant Compare and Promotion v1
|
- Cross-Tenant Compare and Promotion v1
|
||||||
|
- Policy Lifecycle / Ghost Policies
|
||||||
- Later compliance overlays beyond the current control/evidence foundation
|
- Later compliance overlays beyond the current control/evidence foundation
|
||||||
|
|
||||||
## Release Readiness
|
## Release Readiness
|
||||||
@ -128,8 +132,8 @@ ## Release Readiness
|
|||||||
| Release / Theme | Readiness | Notes |
|
| Release / Theme | Readiness | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
|
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
|
||||||
| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. |
|
| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; breitere Commercial-Polish-Themen bleiben separat. |
|
||||||
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. |
|
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. |
|
||||||
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
|
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
|
||||||
|
|
||||||
## Commercial Readiness
|
## Commercial Readiness
|
||||||
@ -138,14 +142,16 @@ ### Demo-ready
|
|||||||
|
|
||||||
- Baseline compare and drift walkthroughs
|
- Baseline compare and drift walkthroughs
|
||||||
- Review pack generation and export
|
- Review pack generation and export
|
||||||
|
- Customer-safe review workspace walkthroughs
|
||||||
- Provider health, onboarding readiness and required permissions
|
- Provider health, onboarding readiness and required permissions
|
||||||
- Support diagnostics
|
- Support diagnostics
|
||||||
- Permission posture and Entra admin roles reporting
|
- Permission posture and Entra admin roles reporting
|
||||||
|
|
||||||
### Almost sellable
|
### Almost sellable
|
||||||
|
|
||||||
- Review-driven governance workflow around tenant reviews and review packs
|
- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs
|
||||||
- Baseline drift and restore governance
|
- Baseline drift and restore governance
|
||||||
|
- Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling
|
||||||
- Alerting and run visibility for governance operations
|
- Alerting and run visibility for governance operations
|
||||||
- Support requests with contextual diagnostics
|
- Support requests with contextual diagnostics
|
||||||
- Provider readiness and permission posture reporting
|
- Provider readiness and permission posture reporting
|
||||||
@ -159,6 +165,7 @@ ### Foundation-only
|
|||||||
- Canonical control catalog
|
- Canonical control catalog
|
||||||
- Stored reports substrate
|
- Stored reports substrate
|
||||||
- Evidence snapshot substrate
|
- Evidence snapshot substrate
|
||||||
|
- Localization foundation
|
||||||
- Product telemetry
|
- Product telemetry
|
||||||
- Customer health scoring
|
- Customer health scoring
|
||||||
- Operational controls
|
- Operational controls
|
||||||
@ -166,9 +173,7 @@ ### Foundation-only
|
|||||||
|
|
||||||
### Not sellable yet
|
### Not sellable yet
|
||||||
|
|
||||||
- Customer Review Workspace v1
|
|
||||||
- Cross-Tenant Compare and Promotion v1
|
- Cross-Tenant Compare and Promotion v1
|
||||||
- Localization v1
|
|
||||||
- Private AI Execution Governance Foundation
|
- Private AI Execution Governance Foundation
|
||||||
- External Support Desk / PSA Handoff
|
- External Support Desk / PSA Handoff
|
||||||
- Compliance Light product layer
|
- Compliance Light product layer
|
||||||
@ -177,40 +182,39 @@ ## Open Gaps & Blockers
|
|||||||
|
|
||||||
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
|
| Decisioning still spans multiple repo-real inboxes | UX blocker | My Findings, Intake, Governance Inbox und Exception Queue sind real, aber Operators springen weiter zwischen mehreren Spezial-Surfaces und es gibt noch keinen portfolio-weiten Action-Layer | Findings Workflow / MSP Portfolio | P1 Governance Decision Surface Convergence |
|
||||||
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
|
|
||||||
| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces |
|
| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces |
|
||||||
| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility |
|
| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility |
|
||||||
| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants |
|
| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants |
|
||||||
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
|
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
|
||||||
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
|
|
||||||
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
|
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
|
||||||
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
|
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
|
||||||
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
|
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
|
||||||
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment |
|
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs still lag neuere Review-, Findings- und Localization-Surfaces | Product planning / roadmap maintenance | none - docs alignment |
|
||||||
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
|
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
|
||||||
|
|
||||||
## Recommended Next Specs
|
## Recommended Next Specs
|
||||||
|
|
||||||
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
|
- `P1 Governance Decision Surface Convergence`: verbindet My Findings, Intake, Governance Inbox, Customer Review Workspace und Exception Queue zu weniger Operator-Journeys und bereitet die Portfolio-Ebene vor.
|
||||||
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
|
|
||||||
- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks.
|
- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks.
|
||||||
- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases.
|
- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases.
|
||||||
- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later.
|
- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later.
|
||||||
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
|
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
|
||||||
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
|
|
||||||
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
|
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
|
||||||
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
|
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
|
||||||
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
|
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
|
||||||
|
|
||||||
## Roadmap Drift Notes
|
## Roadmap Drift Notes
|
||||||
|
|
||||||
|
- `roadmap.md` understates current R2 completion. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind bereits repo-real.
|
||||||
|
- `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo.
|
||||||
|
- `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert.
|
||||||
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
|
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
|
||||||
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
|
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
|
||||||
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
|
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
|
||||||
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
|
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
|
||||||
- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented.
|
- The roadmap is now better at describing still-missing portfolio- und commercial-Layer than the current state of review/findings/localization implementation. Cross-Tenant Compare and Promotion, full billing-state maturity, external PSA handoff and AI Governance still look genuinely unimplemented.
|
||||||
- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace.
|
- The main drift pattern is underestimation, not overestimation. Customer-facing review consumption is no longer the clearest missing piece; portfolio action and commercial lifecycle are.
|
||||||
|
|
||||||
## Evidence Sources
|
## Evidence Sources
|
||||||
|
|
||||||
@ -227,12 +231,19 @@ ## Evidence Sources
|
|||||||
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||||
- `apps/platform/app/Filament/System/Pages/Dashboard.php`
|
- `apps/platform/app/Filament/System/Pages/Dashboard.php`
|
||||||
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
|
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||||
|
|
||||||
Wichtige Models:
|
Wichtige Models:
|
||||||
|
|
||||||
- `apps/platform/app/Models/OperationRun.php`
|
- `apps/platform/app/Models/OperationRun.php`
|
||||||
- `apps/platform/app/Models/Finding.php`
|
- `apps/platform/app/Models/Finding.php`
|
||||||
- `apps/platform/app/Models/FindingException.php`
|
- `apps/platform/app/Models/FindingException.php`
|
||||||
|
- `apps/platform/app/Models/FindingExceptionDecision.php`
|
||||||
|
- `apps/platform/app/Models/FindingExceptionEvidenceReference.php`
|
||||||
- `apps/platform/app/Models/BaselineProfile.php`
|
- `apps/platform/app/Models/BaselineProfile.php`
|
||||||
- `apps/platform/app/Models/BaselineSnapshot.php`
|
- `apps/platform/app/Models/BaselineSnapshot.php`
|
||||||
- `apps/platform/app/Models/EvidenceSnapshot.php`
|
- `apps/platform/app/Models/EvidenceSnapshot.php`
|
||||||
@ -251,6 +262,7 @@ ## Evidence Sources
|
|||||||
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
||||||
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
||||||
- `apps/platform/app/Services/Alerts/AlertDispatchService.php`
|
- `apps/platform/app/Services/Alerts/AlertDispatchService.php`
|
||||||
|
- `apps/platform/app/Services/Findings/FindingExceptionService.php`
|
||||||
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
|
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
|
||||||
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
|
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
|
||||||
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
|
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
|
||||||
@ -258,6 +270,7 @@ ## Evidence Sources
|
|||||||
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
|
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
|
||||||
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||||
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
|
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
|
||||||
|
- `apps/platform/app/Services/Localization/LocaleResolver.php`
|
||||||
|
|
||||||
Wichtige Test-Anker im Repo:
|
Wichtige Test-Anker im Repo:
|
||||||
|
|
||||||
@ -276,4 +289,4 @@ ## Evidence Sources
|
|||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
|
|
||||||
2026-04-27 on branch `248-private-ai-policy-foundation`
|
2026-04-29 on branch `platform-dev`
|
||||||
|
|||||||
@ -55,3 +55,5 @@ ## 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,6 +9,29 @@ ## 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
|
||||||
@ -24,7 +47,7 @@ ## Technical Context
|
|||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@ -2,10 +2,20 @@ # 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
|
||||||
|
|||||||
@ -17,20 +17,20 @@ # Tasks: Cross-Tenant Compare Preview and Promotion Preflight
|
|||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
# Specification Quality Checklist: Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-29
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] Repo-specific classes, routes, file paths, and validation commands appear only where they are required to keep the three active writer families and proof obligations unambiguous
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for product and review stakeholders, with repo-grounded detail only where the bounded invariant target would otherwise stay ambiguous
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria stay outcome-oriented even though the package names concrete writer families and proof files needed to bound the slice
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No unbounded implementation plan leaks into the specification; repo-specific commands and paths stay limited to selection, dependency, and validation context
|
||||||
|
|
||||||
|
## Test Governance Review
|
||||||
|
|
||||||
|
- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, with the three writer suites as the primary proof and only bounded recurrence, consumer, and trigger-authorization regressions where FR-255-005, FR-255-006, FR-255-009, and FR-255-011 require them.
|
||||||
|
- [x] No new browser or heavy-governance family is introduced; adjacent proof remains inside existing feature suites only.
|
||||||
|
- [x] Suite-cost outcome stays bounded and reviewable: the package reuses existing writer, recurrence, consumer, and auth suites without adding a new default-heavy harness.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- [x] Review outcome class: `acceptable-special-case`
|
||||||
|
- [x] Workflow outcome: `keep`
|
||||||
|
- [x] Review-note location is explicit: guardrail, lane-fit, and bounded-proof notes live in `spec.md`, `plan.md`, `tasks.md`, and this checklist.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Repo-surface names, validation commands, and current writer/test anchors are intentionally present because this prep package must distinguish the three active finding writers from already-completed adjacent cleanup specs.
|
||||||
|
- The spec remains behavior-first: write-time lifecycle readiness, recurrence identity, reopen truth, and unchanged RBAC/tenant isolation are the product outcomes; repo details only keep the package reviewable and bounded.
|
||||||
|
- No blocking open question remains for safe planning.
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
version: 1
|
||||||
|
kind: finding-creation-invariants
|
||||||
|
|
||||||
|
scope:
|
||||||
|
goal: enforce lifecycle-ready finding creation and recurrence or reopen semantics across the active finding writers only
|
||||||
|
non_goals:
|
||||||
|
- repair tooling or backfill runtime surfaces
|
||||||
|
- new workflow states or new findings lifecycle families
|
||||||
|
- customer-facing workflow expansion
|
||||||
|
- compare refresh work
|
||||||
|
- external support handoff
|
||||||
|
- broader findings redesign
|
||||||
|
- silent database-constraint rollout
|
||||||
|
stop_conditions:
|
||||||
|
- another shipped finding writer is discovered outside the three confirmed paths
|
||||||
|
- application-level write enforcement proves insufficient without a migration or DB constraint
|
||||||
|
- the only available implementation shape is a new generic invariant framework
|
||||||
|
|
||||||
|
active_writer_families:
|
||||||
|
baseline_compare:
|
||||||
|
owner_files:
|
||||||
|
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||||
|
identity:
|
||||||
|
canonical_key: recurrence_key
|
||||||
|
fingerprint_contract: fingerprint equals recurrence_key
|
||||||
|
observation_boundary:
|
||||||
|
duplicate_guard: current_operation_run_id prevents double counting the same compare run
|
||||||
|
entra_admin_roles:
|
||||||
|
owner_files:
|
||||||
|
- apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
|
||||||
|
identity:
|
||||||
|
canonical_key: existing role-assignment or aggregate fingerprint
|
||||||
|
observation_boundary:
|
||||||
|
duplicate_guard: later observedAt advances seen history
|
||||||
|
permission_posture:
|
||||||
|
owner_files:
|
||||||
|
- apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
|
||||||
|
identity:
|
||||||
|
canonical_key: existing permission or error fingerprint
|
||||||
|
observation_boundary:
|
||||||
|
duplicate_guard: later observedAt advances seen history
|
||||||
|
|
||||||
|
shared_lifecycle_contract:
|
||||||
|
model:
|
||||||
|
owner_file: apps/platform/app/Models/Finding.php
|
||||||
|
invariants:
|
||||||
|
- workspace_id and tenant_id remain required ownership anchors
|
||||||
|
- no new status or reason-code family is introduced
|
||||||
|
reopen_service:
|
||||||
|
owner_file: apps/platform/app/Services/Findings/FindingWorkflowService.php
|
||||||
|
requirement:
|
||||||
|
- terminal findings reopen only through reopenBySystem
|
||||||
|
- reopened_at is set
|
||||||
|
- resolved and closed markers clear according to current service behavior
|
||||||
|
- sla_days and due_at are recalculated from reopenedAt
|
||||||
|
- existing audit and alert side effects are preserved
|
||||||
|
|
||||||
|
lifecycle_invariants:
|
||||||
|
create:
|
||||||
|
required_fields:
|
||||||
|
- status is new
|
||||||
|
- first_seen_at equals observedAt
|
||||||
|
- last_seen_at equals observedAt
|
||||||
|
- times_seen equals 1
|
||||||
|
- sla_days is initialized when the current severity policy returns a value
|
||||||
|
- due_at is initialized when the current severity policy requires due-state truth
|
||||||
|
contextual_fields:
|
||||||
|
- current_operation_run_id remains populated where the current writer already sets it
|
||||||
|
refresh_existing:
|
||||||
|
required_behavior:
|
||||||
|
- the same canonical finding identity is reused
|
||||||
|
- missing first_seen_at, last_seen_at, and times_seen are repaired inline
|
||||||
|
- missing sla_days or due_at covered by this slice are repaired inline without a second-pass repair tool
|
||||||
|
- already-valid lifecycle fields are not reset unnecessarily
|
||||||
|
reopen:
|
||||||
|
required_behavior:
|
||||||
|
- the same canonical finding identity is reopened, not duplicated
|
||||||
|
- resolved_at and resolved_reason clear on reopen
|
||||||
|
- first_seen_at is retained
|
||||||
|
- last_seen_at and times_seen advance according to the family observation rule
|
||||||
|
|
||||||
|
downstream_regression_consumers:
|
||||||
|
findings_surfaces:
|
||||||
|
owner_files:
|
||||||
|
- apps/platform/app/Filament/Resources/FindingResource.php
|
||||||
|
- apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
|
||||||
|
- apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
|
||||||
|
- apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php
|
||||||
|
expectation:
|
||||||
|
- no design change is required; these surfaces should continue to read truthful due_at and reopened_at data from the same Finding records
|
||||||
|
|
||||||
|
validation_expectations:
|
||||||
|
required_feature_proof:
|
||||||
|
- baseline compare proves create readiness, same-run retry protection, reopened reuse, and inline repair of incomplete lifecycle fields
|
||||||
|
- Entra admin roles proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields
|
||||||
|
- permission posture proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields
|
||||||
|
excluded_lanes:
|
||||||
|
- browser
|
||||||
|
- heavy-governance
|
||||||
|
migration_posture:
|
||||||
|
- no new migration or schema artifact is allowed in this slice
|
||||||
130
specs/255-enforce-finding-creation-invariants/data-model.md
Normal file
130
specs/255-enforce-finding-creation-invariants/data-model.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# Data Model — Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
**Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
This feature introduces no new persisted truth. The data-model impact is to make the existing `Finding` lifecycle contract explicit at create, refresh, and reopen time across the three active writer families.
|
||||||
|
|
||||||
|
## Existing Canonical Entities Reused
|
||||||
|
|
||||||
|
### Finding (`findings`)
|
||||||
|
|
||||||
|
**Purpose**: Tenant-owned findings workflow truth.
|
||||||
|
|
||||||
|
**Key fields already in use**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `finding_type`
|
||||||
|
- `source`
|
||||||
|
- `scope_key`
|
||||||
|
- `fingerprint`
|
||||||
|
- `recurrence_key`
|
||||||
|
- `severity`
|
||||||
|
- `status`
|
||||||
|
- `first_seen_at`
|
||||||
|
- `last_seen_at`
|
||||||
|
- `times_seen`
|
||||||
|
- `sla_days`
|
||||||
|
- `due_at`
|
||||||
|
- `reopened_at`
|
||||||
|
- `resolved_at`
|
||||||
|
- `resolved_reason`
|
||||||
|
- `closed_at`
|
||||||
|
- `closed_reason`
|
||||||
|
- `current_operation_run_id`
|
||||||
|
- `baseline_operation_run_id`
|
||||||
|
|
||||||
|
**Feature use**:
|
||||||
|
- Remains the single persisted source of truth for active findings lifecycle state.
|
||||||
|
- Continues to require both `workspace_id` and `tenant_id` anchors.
|
||||||
|
- Keeps the current status families unchanged.
|
||||||
|
- Carries the lifecycle-ready fields that this feature hardens at write time.
|
||||||
|
|
||||||
|
### OperationRun (`operation_runs`)
|
||||||
|
|
||||||
|
**Purpose**: Existing execution context for baseline compare and other operational flows.
|
||||||
|
|
||||||
|
**Feature use**:
|
||||||
|
- Remains contextual only.
|
||||||
|
- `current_operation_run_id` continues to identify the current writer run where the family already sets it.
|
||||||
|
- No new operation type or new run-tracking artifact is introduced.
|
||||||
|
|
||||||
|
### StoredReport (`stored_reports`)
|
||||||
|
|
||||||
|
**Purpose**: Existing stored reporting artifact for permission posture output.
|
||||||
|
|
||||||
|
**Feature use**:
|
||||||
|
- Unchanged.
|
||||||
|
- Mentioned only because permission posture finding generation already correlates lifecycle-ready findings with an existing report artifact.
|
||||||
|
|
||||||
|
## Derived Non-Persisted Contracts
|
||||||
|
|
||||||
|
### LifecycleReadyFinding (derived contract)
|
||||||
|
|
||||||
|
**Definition**: A `Finding` record that is immediately usable by the existing workflow the moment the active writer persists or refreshes it.
|
||||||
|
|
||||||
|
**Required fields**:
|
||||||
|
- active canonical status on first create (`new`)
|
||||||
|
- `first_seen_at`
|
||||||
|
- `last_seen_at`
|
||||||
|
- `times_seen >= 1`
|
||||||
|
- `sla_days` when the current severity policy returns a value
|
||||||
|
- `due_at` when the current severity policy requires due-date truth
|
||||||
|
- existing run correlation fields preserved where the writer already populates them
|
||||||
|
|
||||||
|
**Removal rule**:
|
||||||
|
- no later repair surface may be required for these fields on active writers
|
||||||
|
|
||||||
|
### RecurrenceIdentity (derived contract)
|
||||||
|
|
||||||
|
**Definition**: The family-owned identity that decides whether a repeated observation refreshes one canonical finding or incorrectly creates a duplicate.
|
||||||
|
|
||||||
|
**Family-specific variants**:
|
||||||
|
- baseline compare: `recurrence_key` and `fingerprint` derived from tenant, baseline profile, policy type, subject key, and change type
|
||||||
|
- Entra admin roles: existing role-assignment and aggregate fingerprints
|
||||||
|
- permission posture: existing permission and error fingerprints
|
||||||
|
|
||||||
|
**Guarantee**:
|
||||||
|
- repeated observation of the same canonical issue reuses one finding identity
|
||||||
|
|
||||||
|
### ObservationBoundary (derived contract)
|
||||||
|
|
||||||
|
**Definition**: The family-specific rule that decides whether `times_seen` should advance.
|
||||||
|
|
||||||
|
**Family-specific variants**:
|
||||||
|
- baseline compare: same `current_operation_run_id` must not increment `times_seen` twice for the same observation
|
||||||
|
- Entra admin roles: later `observedAt` advances seen history
|
||||||
|
- permission posture: later `observedAt` advances seen history
|
||||||
|
|
||||||
|
**Guarantee**:
|
||||||
|
- retries and repeated processing do not double count the same observation
|
||||||
|
|
||||||
|
## State Transitions Reused
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
- missing canonical finding identity -> create one `Finding`
|
||||||
|
- resulting state remains `new`
|
||||||
|
- lifecycle-ready fields are populated in the same write path
|
||||||
|
|
||||||
|
### Refresh Existing Open Finding
|
||||||
|
|
||||||
|
- existing open finding remains in its current active workflow state
|
||||||
|
- evidence or severity may refresh according to the writer family
|
||||||
|
- missing lifecycle-ready fields covered by this feature are repaired inline
|
||||||
|
- valid existing lifecycle fields should not be needlessly reset
|
||||||
|
|
||||||
|
### Reopen Existing Terminal Finding
|
||||||
|
|
||||||
|
- existing terminal finding transitions through `FindingWorkflowService::reopenBySystem()`
|
||||||
|
- resulting state becomes `reopened`
|
||||||
|
- `resolved_*` and `closed_*` markers clear according to the current service behavior
|
||||||
|
- SLA and due-state truth are recalculated from the later re-observation moment
|
||||||
|
|
||||||
|
## Invariant Rules
|
||||||
|
|
||||||
|
- No new persisted entity, table, or compatibility artifact may be introduced.
|
||||||
|
- No new workflow status, reopen reason family, or lifecycle label may be introduced.
|
||||||
|
- Active writers must repair incomplete lifecycle-ready fields inline rather than relying on CLI repair commands, tenant maintenance actions, or deploy-time hooks.
|
||||||
|
- Due-state repair should fill missing truth or refresh terminal-to-reopened truth only; it must not silently redesign current due-date semantics for already-healthy open findings.
|
||||||
|
- A later database constraint is a separate follow-up candidate only if application-level write-path enforcement proves insufficient.
|
||||||
295
specs/255-enforce-finding-creation-invariants/plan.md
Normal file
295
specs/255-enforce-finding-creation-invariants/plan.md
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
# Implementation Plan: Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
**Branch**: `255-enforce-finding-creation-invariants` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan is prep-only. It updates only spec-package artifacts for implementation readiness and does not change application code, runtime behavior, migrations, assets, or repo files outside this spec directory.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Make lifecycle-ready finding creation and recurrence semantics explicit across the only three active finding writers currently persisting `Finding` records: baseline compare drift, Entra admin roles, and permission posture.
|
||||||
|
- Keep the slice narrow and repo-grounded: reuse existing `Finding` fields, existing recurrence identities, existing `FindingWorkflowService::reopenBySystem()`, and existing `FindingSlaPolicy` behavior; do not add repair tooling, workflow states, migrations, or a broader findings framework.
|
||||||
|
- Tighten validation where repo proof is already strongest: extend the three focused feature suites so they explicitly cover new creation, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active write paths.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `FindingWorkflowService`, `FindingSlaPolicy`, baseline compare job, Entra admin roles finding generator, and permission posture finding generator
|
||||||
|
**Storage**: PostgreSQL existing `findings`, `operation_runs`, `stored_reports`, and `audit_logs` only; no new persistence or migration is planned
|
||||||
|
**Testing**: Pest feature tests in the existing generator and compare suites
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and existing background jobs or services that already generate findings
|
||||||
|
**Project Type**: web
|
||||||
|
**Performance Goals**: lifecycle invariants must be satisfied in the same write path that creates or refreshes the finding; no second-pass repair job, no extra operator step, and no widened query surface should be required
|
||||||
|
**Constraints**: LEAN-001 replacement over compatibility shims; no new persistence; no new workflow states; no compare refresh or repair-tooling scope; preserve existing `404` vs `403` behavior; no new Filament assets, panel work, or provider registration changes
|
||||||
|
**Scale/Scope**: 3 active finding writer families, 1 shared workflow service, 1 shared SLA policy, 1 existing `Finding` model, and 3 established feature-test families plus downstream findings surfaces as regression consumers only
|
||||||
|
|
||||||
|
## Likely Affected Repo Surfaces
|
||||||
|
|
||||||
|
- Active write paths and their local recurrence or observation logic:
|
||||||
|
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||||
|
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
- Shared lifecycle and due-date seams already reused by those paths:
|
||||||
|
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||||
|
- `apps/platform/app/Services/Findings/FindingSlaPolicy.php`
|
||||||
|
- `apps/platform/app/Models/Finding.php`
|
||||||
|
- Downstream operator-facing regression consumers that should not need design changes but do rely on `due_at`, `reopened_at`, and canonical open-status truth:
|
||||||
|
- `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- Current focused proof surfaces that already cover part of the invariant and should remain the primary validation entry points:
|
||||||
|
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
|
||||||
|
## Domain / Model Fit
|
||||||
|
|
||||||
|
- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, table, compatibility projection, or lifecycle wrapper is introduced.
|
||||||
|
- The slice does not change the canonical findings status set. `new`, `triaged`, `in_progress`, and `reopened` remain the active statuses; `resolved`, `closed`, and `risk_accepted` remain terminal statuses.
|
||||||
|
- Lifecycle-ready creation in this feature means that the first persisted or inline-repaired record is already safe for existing downstream workflow use: canonical active status, `first_seen_at`, `last_seen_at`, `times_seen >= 1`, and existing SLA or `due_at` truth when the current severity policy requires them.
|
||||||
|
- Recurrence identity stays family-owned and explicit rather than being normalized into a new shared engine:
|
||||||
|
- baseline compare uses `recurrence_key` plus `fingerprint`, with `current_operation_run_id` preventing double counting for the same compare run
|
||||||
|
- Entra admin roles uses its existing role-assignment and aggregate fingerprints
|
||||||
|
- permission posture uses its existing missing-permission and error fingerprints
|
||||||
|
- `OperationRun` and `StoredReport` remain contextual references only where current writers already use them. This slice does not introduce a new audit artifact or independent lifecycle store.
|
||||||
|
|
||||||
|
## UI / Filament & Livewire Fit
|
||||||
|
|
||||||
|
- No operator-facing surface change is planned. Existing findings resource, inbox, and intake surfaces are regression consumers of better write-time truth, not redesign targets.
|
||||||
|
- Filament remains v5 on Livewire v4.0+; no Livewire v3 behavior or API is in scope.
|
||||||
|
- `FindingResource` already has a view page, so the hard global-search rule remains satisfied without new work. No new globally searchable resource is added.
|
||||||
|
- No destructive action is introduced or changed. Any touched findings action surface must keep current server-side authorization and existing `->requiresConfirmation()` behavior where destructive-like actions already exist.
|
||||||
|
- No panel/provider work is planned. If provider registration ever became relevant later, Laravel 12 and Filament v5 still require panel providers under `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`.
|
||||||
|
- No asset change is planned. Deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets.
|
||||||
|
|
||||||
|
## RBAC / Policy Fit
|
||||||
|
|
||||||
|
- This slice should not add a new capability, new role mapping, or new policy branch. User-triggered actions that lead to in-scope finding writes keep their current authorization semantics.
|
||||||
|
- Tenant membership and workspace membership remain the isolation boundary: non-members stay `404`, in-scope members missing the current capability stay `403`, and no new write bypass is introduced for background or queued generation.
|
||||||
|
- If implementation appears to require a new capability or policy relaxation just to enforce lifecycle invariants, that is a stop condition and should be split rather than absorbed.
|
||||||
|
|
||||||
|
## Audit / Logging Fit
|
||||||
|
|
||||||
|
- `FindingWorkflowService::reopenBySystem()` remains the authoritative reopen path because it already owns reopened state mutation, audit context, and alert notification dispatch.
|
||||||
|
- No new `AuditActionId`, no new operation type, and no new completion notification path should be introduced.
|
||||||
|
- The feature should preserve existing `current_operation_run_id` and `StoredReport` correlation meaning where current writers already set them. Creation-time hardening must not create a second audit or run-tracking dialect.
|
||||||
|
|
||||||
|
## Data / Migration / Constraint Fit
|
||||||
|
|
||||||
|
- No migration, no historical data backfill, no deploy hook, and no repair command are planned.
|
||||||
|
- Under LEAN-001, stale local data or incomplete fixtures should be handled by fixture replacement or inline repair on active write paths, not by compatibility shims.
|
||||||
|
- A database-level constraint discussion is allowed only as an explicit follow-up or stop condition if planning or implementation proves that application-level write-path enforcement cannot satisfy the invariant safely. It must not be silently folded into this slice.
|
||||||
|
- If due-date initialization for already-open findings would require recomputing correct existing data instead of filling missing lifecycle fields only, stop and split rather than broadening this feature into a data repair rollout.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: no operator-facing surface change
|
||||||
|
- **Native vs custom classification summary**: N/A - existing native Filament findings surfaces remain regression consumers only
|
||||||
|
- **Shared-family relevance**: none; no new notification, header action, dashboard, or evidence viewer family is added
|
||||||
|
- **State layers in scope**: none
|
||||||
|
- **Audience modes in scope**: N/A
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: N/A
|
||||||
|
- **Raw/support gating plan**: N/A
|
||||||
|
- **One-primary-action / duplicate-truth control**: existing findings workflow actions remain unchanged; tighter write-time truth prevents partial lifecycle data from competing with the existing canonical action flow
|
||||||
|
- **Handling modes by drift class or surface**: N/A
|
||||||
|
- **Repository-signal treatment**: review-mandatory for downstream regression only
|
||||||
|
- **Special surface test profiles**: standard-native-filament regression only
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: no
|
||||||
|
- **Systems touched**: N/A for shared operator interaction families; domain reuse stays within existing findings lifecycle services only
|
||||||
|
- **Shared abstractions reused**: existing `FindingWorkflowService` and `FindingSlaPolicy` only
|
||||||
|
- **New abstraction introduced? why?**: none by default; if a shared write-time normalizer is later proposed, it must be a narrow findings-domain replacement for duplicated inline repair across all three concrete writers, not a new registry or framework
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: `reopenBySystem()` is already sufficient for terminal-to-reopened transitions. The current planning gap is open-record lifecycle repair, which is still duplicated and partially covered across the three writers.
|
||||||
|
- **Bounded deviation / spread control**: none; keep any repair logic either local to each writer or in one bounded findings-domain helper only if it replaces real duplication immediately
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Central contract reused**: N/A
|
||||||
|
- **Delegated UX behaviors**: N/A
|
||||||
|
- **Surface-owned behavior kept local**: existing baseline compare and other generation flows keep their current start and completion UX unchanged
|
||||||
|
- **Queued DB-notification policy**: N/A
|
||||||
|
- **Terminal notification path**: N/A
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: no
|
||||||
|
- **Provider-owned seams**: N/A
|
||||||
|
- **Platform-core seams**: existing tenant-owned findings truth only
|
||||||
|
- **Neutral platform terms / contracts preserved**: existing `Finding` lifecycle and tenant/workspace ownership vocabulary remain unchanged
|
||||||
|
- **Retained provider-specific semantics and why**: provider-specific recurrence evidence stays inside the existing writer families that already own it
|
||||||
|
- **Bounded extraction or follow-up path**: N/A
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- LEAN-001: PASS - the slice is explicitly app-code hardening only; no compatibility shim, legacy alias, fallback reader, or migration path is planned.
|
||||||
|
- TEST-GOV-001: PASS - proof stays in the narrowest existing feature suites, with no browser lane and no new heavy-governance family.
|
||||||
|
- RBAC-UX: PASS - no new capability or policy branch is introduced; non-members remain `404`, members lacking the current capability remain `403`, and system generation stays tenant-scoped.
|
||||||
|
- PERSIST-001: PASS - no new persisted truth, table, artifact, or projection is introduced.
|
||||||
|
- STATE-001: PASS - no new state, reason-code family, or lifecycle branch is added; current findings states remain authoritative.
|
||||||
|
- PROP-001 / ABSTR-001: PASS - the narrowest plan is to align the three concrete write paths and reuse the existing reopen service. Any helper beyond that is a stop-and-justify decision, not a default.
|
||||||
|
- XCUT-001 / UI-SEM-001: PASS - no new operator interaction family or presentation framework is introduced.
|
||||||
|
- Filament v5 / Livewire v4 compliance: PASS - existing findings surfaces stay on native Filament v5 with Livewire v4.0+; no legacy API mixing is planned.
|
||||||
|
- Global-search hard rule: PASS - `FindingResource` already has a view page, and no new searchable resource is added.
|
||||||
|
- Panel/provider registration: PASS - no panel/provider work is planned; if needed later, Filament v5 on Laravel 12 still uses `apps/platform/bootstrap/providers.php`.
|
||||||
|
- Destructive confirmation standard: PASS - no new destructive action is added; existing destructive-like actions remain outside this slice.
|
||||||
|
- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged.
|
||||||
|
- Auditability and tenant isolation: PASS - reopen semantics remain on the current audited service path, and every in-scope write remains bound to tenant and workspace context.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Feature for writer-level creation-time lifecycle readiness, shared recurrence/workflow-service behavior, and narrow downstream consumer plus trigger-authorization continuity checks; no new unit, browser, or heavy-governance family is planned
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the feature risk lives in domain write behavior already exercised through the existing compare and generator suites, but FR-255-005, FR-255-006, FR-255-009, and FR-255-011 also require bounded proof of shared recurrence/workflow behavior and unchanged consumer/auth continuity. Focused feature coverage is still sufficient because the adjacent checks stay limited to existing findings and trigger-authorization suites.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: low to moderate but bounded; reuse existing tenant, operation-run, snapshot, generator, and trigger-surface fixtures. Avoid a new umbrella findings harness unless repeated setup clearly becomes the bottleneck.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; the plan explicitly avoids a new generic invariant framework or new default-heavy helper layer.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native relief; no browser smoke is required because no operator-facing interaction changes are planned
|
||||||
|
- **Closing validation and reviewer handoff**: rerun the three writer suites plus the bounded recurrence/workflow and consumer/auth suites, confirm each family now proves missing-field inline repair in addition to existing create/idempotence/reopen behavior, and verify that no migration, no policy branch, and no new UI action was introduced while hardening write paths.
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond routine feature-test maintenance
|
||||||
|
- **Review-stop questions**: did implementation widen into a repair tool, migration, DB constraint rollout, or generic invariant framework; did it silently reset already-valid due dates; did it leave one writer family with only partial invariant proof
|
||||||
|
- **Escalation path**: reject-or-split
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: routine lifecycle-hardening proof belongs in this feature unless a database-level constraint or a broader findings lifecycle redesign is proven necessary later
|
||||||
|
|
||||||
|
## Rollout & Risk Controls
|
||||||
|
|
||||||
|
- Rollout is code-only and bounded. No migration, queue worker sequencing, asset build, or provider registration step is expected.
|
||||||
|
- Recommended implementation order is:
|
||||||
|
1. confirm the shared invariant vocabulary and stop conditions against the three active writers only
|
||||||
|
2. harden baseline compare first because it already carries the strictest observation-boundary rule through `current_operation_run_id`
|
||||||
|
3. align permission posture and Entra admin roles creation and refresh logic around the same lifecycle-ready contract while preserving their family-specific recurrence rules
|
||||||
|
4. extract a shared normalizer only if the concrete code shows immediate duplication across all three paths and the helper replaces duplication instead of adding a new abstraction layer
|
||||||
|
5. extend focused regression tests and verify downstream findings surfaces do not require design changes
|
||||||
|
- Stop conditions for task execution:
|
||||||
|
- another shipped finding writer is discovered outside the three confirmed paths
|
||||||
|
- the invariant cannot be enforced safely without a migration or DB constraint
|
||||||
|
- the only available code shape is a new generic registry, strategy system, or lifecycle framework
|
||||||
|
- user-facing findings workflow affordances would need to change to compensate for missing write-time truth
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/255-enforce-finding-creation-invariants/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── finding-creation-invariants.contract.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Jobs/
|
||||||
|
│ │ └── CompareBaselineToTenantJob.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── Finding.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── EntraAdminRoles/
|
||||||
|
│ │ │ └── EntraAdminRolesFindingGenerator.php
|
||||||
|
│ │ ├── Findings/
|
||||||
|
│ │ │ ├── FindingSlaPolicy.php
|
||||||
|
│ │ │ └── FindingWorkflowService.php
|
||||||
|
│ │ └── PermissionPosture/
|
||||||
|
│ │ └── PermissionPostureFindingGenerator.php
|
||||||
|
│ └── Filament/
|
||||||
|
│ ├── Pages/Findings/
|
||||||
|
│ │ ├── FindingsIntakeQueue.php
|
||||||
|
│ │ └── MyFindingsInbox.php
|
||||||
|
│ └── Resources/
|
||||||
|
│ ├── FindingResource.php
|
||||||
|
│ └── FindingResource/
|
||||||
|
│ └── Pages/ListFindings.php
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Baselines/BaselineCompareFindingsTest.php
|
||||||
|
├── EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
|
||||||
|
├── Findings/FindingRecurrenceTest.php
|
||||||
|
├── Findings/FindingAutomationWorkflowTest.php
|
||||||
|
├── Findings/FindingWorkflowServiceTest.php
|
||||||
|
├── Findings/MyWorkInboxTest.php
|
||||||
|
├── Findings/FindingsIntakeQueueTest.php
|
||||||
|
├── Rbac/BaselineCompareMatrixAuthorizationTest.php
|
||||||
|
├── EntraAdminRoles/AdminRolesSummaryWidgetTest.php
|
||||||
|
└── PermissionPosture/PermissionPostureFindingGeneratorTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel monolith. The implementation should stay inside the existing finding writer services and job, the shared findings lifecycle service and model, and the current focused feature suites rather than creating a new namespace or framework.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violation is expected. If implementation later proposes a new persistence rule, a new lifecycle framework, or a broad helper layer that serves only speculative future writers, stop and split rather than justifying it inside this slice.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
N/A - this feature introduces no new enum, presenter, persisted entity, interface, registry, or taxonomy. Any narrow helper extracted during implementation must replace existing duplicated write-time lifecycle normalization immediately across the three confirmed writers or it should not be introduced.
|
||||||
|
|
||||||
|
## Phase 0 — Research (output: `research.md`)
|
||||||
|
|
||||||
|
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/research.md`
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
- confirm that the three already-named write paths are still the full active finding-writer inventory in app code
|
||||||
|
- confirm where current code already repairs lifecycle fields inline and where `sla_days` or `due_at` normalization is still only implied on create or reopen
|
||||||
|
- document the narrowest shared seam decision: keep repair logic local per writer unless one bounded findings-domain helper clearly replaces real duplication across all three cases
|
||||||
|
- record the explicit stop condition for any database-level constraint or migration-based enforcement proposal
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||||
|
|
||||||
|
See:
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/data-model.md`
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml`
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/quickstart.md`
|
||||||
|
|
||||||
|
Design focus:
|
||||||
|
- capture one lifecycle-ready finding contract that all three active writers must satisfy without introducing a new persistence or workflow layer
|
||||||
|
- keep recurrence identity family-owned while making the create, refresh, and reopen guarantees explicit in one planning contract
|
||||||
|
- keep downstream Filament findings surfaces, inboxes, and intake queues as regression consumers only; no UI redesign is part of this slice
|
||||||
|
- document the no-migration, no-constraint-by-default posture and the explicit stop condition for any future constraint follow-up
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
- Deferred in this prep-only pass because the user explicitly limited edits to this spec directory.
|
||||||
|
- If maintainers later want full Spec Kit propagation outside the spec package, run:
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Outline (tasks created later in `/speckit.tasks`)
|
||||||
|
|
||||||
|
- keep the feature bounded to the three confirmed writer paths and the shared reopen service
|
||||||
|
- align creation-time lifecycle initialization and open-record inline repair in `CompareBaselineToTenantJob`, `EntraAdminRolesFindingGenerator`, and `PermissionPostureFindingGenerator`
|
||||||
|
- preserve family-specific recurrence and observation-boundary behavior while making it explicit in code and tests
|
||||||
|
- preserve `FindingWorkflowService::reopenBySystem()` as the only reopened-state mutation path
|
||||||
|
- extend the three focused feature suites so each family proves creation readiness, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active paths
|
||||||
|
- verify that no migration, no new capability, no new workflow state, no repair surface, and no operator-facing workflow expansion slipped into the implementation slice
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check target: PASS. The post-design shape remains prep-only, introduces no new persistence or state family, keeps Filament on Livewire v4.0+, leaves provider registration unchanged in `apps/platform/bootstrap/providers.php`, keeps global search unchanged through the existing `FindingResource` view page, leaves destructive actions untouched, and keeps the proving burden inside the three existing focused feature suites unless a bounded stop condition forces a split.
|
||||||
|
- **Ownership cost created**: focused ongoing maintenance in the three writer suites plus bounded shared recurrence/workflow and trigger-authorization regressions; no migration, framework, or new persistence cost is added.
|
||||||
|
- **Alternative intentionally rejected**: a generic invariant framework, a new repair or backfill path, and any DB-constraint rollout were rejected because the repo currently has three concrete writers and current-release truth only requires tightening those exact paths.
|
||||||
|
- **Release truth**: current-release truth. This package hardens already-shipped finding writers rather than preparing speculative future families.
|
||||||
39
specs/255-enforce-finding-creation-invariants/quickstart.md
Normal file
39
specs/255-enforce-finding-creation-invariants/quickstart.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Quickstart — Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
- Docker running
|
||||||
|
- Laravel Sail dependencies installed
|
||||||
|
- Existing compare and generator feature fixtures available
|
||||||
|
- Existing tenant/workspace helpers available for targeted findings tests
|
||||||
|
|
||||||
|
## Run locally after implementation
|
||||||
|
|
||||||
|
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
|
||||||
|
- Run the focused validation suites for this slice:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
|
||||||
|
- Format any implementation changes: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
The two additional commands are the only bounded adjacent proof beyond the three writer suites. They cover shared recurrence/workflow semantics plus unchanged downstream consumer and trigger-authorization contracts.
|
||||||
|
|
||||||
|
## Manual smoke after implementation
|
||||||
|
|
||||||
|
1. Trigger one baseline compare drift finding and confirm the newly created record appears immediately usable on `/admin/t/{tenant}/findings`, including due-state and seen-history cues where current UI already renders them.
|
||||||
|
2. Trigger one permission posture and one Entra admin roles finding and confirm the first persisted record has the expected lifecycle-ready fields without any maintenance action.
|
||||||
|
3. Resolve an in-scope finding, re-observe the same issue, and confirm the same finding identity reopens with refreshed due or SLA truth and existing history retained.
|
||||||
|
4. Re-run the same baseline compare operation identity and confirm `times_seen` does not double count on retry.
|
||||||
|
5. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new repair surface, capability, or operator-facing workflow branch was introduced.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Filament v5 remains on Livewire v4.0+ in this repo; this feature does not add or redesign an operator-facing Filament surface.
|
||||||
|
- `FindingResource` already has a view page, so there is no new global-search compliance work.
|
||||||
|
- No new destructive action is planned; existing destructive-like findings actions stay outside this slice and keep their current confirmation and authorization behavior.
|
||||||
|
- No panel or provider change is planned; `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location for Filament work in Laravel 12.
|
||||||
|
- No asset change is expected, so there is no additional `filament:assets` deployment work for this slice.
|
||||||
|
- This prep package intentionally leaves repo-wide agent-context regeneration outside scope so changes stay inside `specs/255-enforce-finding-creation-invariants/` only.
|
||||||
126
specs/255-enforce-finding-creation-invariants/research.md
Normal file
126
specs/255-enforce-finding-creation-invariants/research.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# Research — Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
**Date**: 2026-04-29
|
||||||
|
**Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
This document records the repo-grounded planning decisions for the creation-time findings hardening slice after Specs 253 and 254. All decisions assume the current pre-production LEAN-001 posture.
|
||||||
|
|
||||||
|
## Decision 1 — Scope the feature to the three active finding writers that currently persist `Finding` records
|
||||||
|
|
||||||
|
**Decision**: Treat baseline compare drift, Entra admin roles, and permission posture as the full active writer set for this feature.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Repo search shows only five direct `Finding` creation sites in app code: one `new Finding` path in `CompareBaselineToTenantJob` and four `Finding::create()` sites split between Entra admin roles and permission posture.
|
||||||
|
- No other shipped service or job currently persists `Finding` records directly, so widening the slice would be speculative rather than repo-driven.
|
||||||
|
- This keeps the hardening aligned with the spec's stated bounded scope and avoids inventing a new writer registry.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||||
|
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Widen to every findings consumer or downstream summary surface.
|
||||||
|
- Rejected: those surfaces consume findings truth but do not create it.
|
||||||
|
- Add a speculative "all writers" registry now.
|
||||||
|
- Rejected: violates ABSTR-001 because three concrete paths are already directly visible.
|
||||||
|
|
||||||
|
## Decision 2 — Enforce lifecycle readiness in the same write path, not through a later repair pass
|
||||||
|
|
||||||
|
**Decision**: Require each in-scope writer to create or refresh lifecycle-ready findings inside the same code path that persists or updates the record.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Spec 253 removes runtime backfill surfaces and this feature explicitly exists to prevent reintroducing that repair dependency.
|
||||||
|
- Current code already initializes lifecycle fields on new creates and updates some fields inline on repeated observations; that makes write-path hardening the narrowest correct implementation.
|
||||||
|
- Downstream findings pages, inboxes, and intake queues already assume findings are ready for immediate use.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/FindingResource.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Reintroduce a maintenance action or backfill command.
|
||||||
|
- Rejected: directly conflicts with the cleanup direction from Spec 253.
|
||||||
|
- Add a deploy-time or queue-time repair hook.
|
||||||
|
- Rejected: widens scope and hides invariant ownership.
|
||||||
|
|
||||||
|
## Decision 3 — Preserve `FindingWorkflowService::reopenBySystem()` as the only shared reopen path
|
||||||
|
|
||||||
|
**Decision**: Keep terminal-to-reopened mutation on `FindingWorkflowService::reopenBySystem()` and treat open-record lifecycle normalization as the actual planning gap.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- `reopenBySystem()` already validates terminal-status eligibility, recalculates SLA or due state, clears resolved or closed markers, writes audit context, and dispatches the reopened alert notification.
|
||||||
|
- Bypassing it would create a second reopen dialect and risk inconsistent audit or notification semantics.
|
||||||
|
- The repo gap is not reopened-state ownership; it is that current open-record repair is still distributed across per-family `observeFinding()` logic and currently emphasizes seen-history more than full lifecycle readiness.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
||||||
|
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||||
|
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Reopen findings directly inside each writer.
|
||||||
|
- Rejected: duplicates side effects and weakens audit consistency.
|
||||||
|
- Create a new generic lifecycle orchestration framework.
|
||||||
|
- Rejected: too broad for three known writers.
|
||||||
|
|
||||||
|
## Decision 4 — Keep recurrence identity family-owned and preserve each writer's current double-count boundary
|
||||||
|
|
||||||
|
**Decision**: Keep the existing recurrence identity and observation boundary per family instead of forcing one synthetic cross-domain algorithm.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Baseline compare already uses `recurrence_key` plus `fingerprint` with `current_operation_run_id` to suppress duplicate `times_seen` increments for the same compare run.
|
||||||
|
- Entra admin roles and permission posture use later `observedAt` comparisons to advance seen history.
|
||||||
|
- The operator need is one canonical finding identity per issue family, not one universal recurrence engine.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||||
|
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Normalize all writers onto a single recurrence service.
|
||||||
|
- Rejected: would add abstraction without current-release need.
|
||||||
|
- Count every repeated observation the same way across all writers.
|
||||||
|
- Rejected: risks breaking baseline retry semantics.
|
||||||
|
|
||||||
|
## Decision 5 — The current proof gap is inline repair of incomplete lifecycle fields on existing findings
|
||||||
|
|
||||||
|
**Decision**: Plan for explicit regression proof that existing open findings with missing lifecycle fields are repaired inline on active paths, especially for `sla_days` and `due_at`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Existing tests already prove creation readiness, idempotence, and reopen behavior in all three families.
|
||||||
|
- Repo code also already repairs `first_seen_at`, `last_seen_at`, and `times_seen` inline when existing findings are re-observed.
|
||||||
|
- What is not yet clearly owned as one invariant is the repair of incomplete lifecycle fields such as missing due-state data on existing findings encountered through active writers.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Rely on current creation and reopen tests only.
|
||||||
|
- Rejected: leaves FR-255-007 partially implied.
|
||||||
|
- Add a new browser or broad workflow suite.
|
||||||
|
- Rejected: too expensive for a write-path invariant gap.
|
||||||
|
|
||||||
|
## Decision 6 — Keep schema and DB constraints out of the slice unless they become an explicit stop condition
|
||||||
|
|
||||||
|
**Decision**: Keep the default plan app-code-only. Any database-level constraint or migration-based enforcement is a bounded follow-up candidate or an explicit stop condition, not part of this feature by default.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The repo is pre-production and LEAN-001 favors direct replacement over compatibility layers.
|
||||||
|
- The current code already has the necessary domain seams to harden write-time behavior without changing the schema.
|
||||||
|
- Folding a constraint into this feature would silently broaden it from write-path hardening into data rollout and compatibility review.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- `.specify/memory/constitution.md`
|
||||||
|
- `specs/255-enforce-finding-creation-invariants/spec.md`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add `NOT NULL` or check constraints now.
|
||||||
|
- Rejected: outside the smallest bounded slice.
|
||||||
|
- Keep the option undefined.
|
||||||
|
- Rejected: the plan must name the stop condition explicitly so task generation stays bounded.
|
||||||
280
specs/255-enforce-finding-creation-invariants/spec.md
Normal file
280
specs/255-enforce-finding-creation-invariants/spec.md
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
# Feature Specification: Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
**Feature Branch**: `255-enforce-finding-creation-invariants`
|
||||||
|
**Created**: 2026-04-29
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Prepare only the spec artifacts for `Enforce Creation-Time Finding Invariants` on the existing 255 branch as the next bounded findings data-integrity slice after Specs 253 and 254."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Findings that reach operators through active generators already tend to look lifecycle-ready, but that truth is still implied and distributed. If a new or changed generator path omits status, seen timestamps, seen count, or due/SLA initialization, operators can receive findings that look real before they are workflow-ready.
|
||||||
|
- **Today's failure**: TenantPilot would have to rely on scattered implicit behavior or future repair logic to make a new or recurring finding usable. That weakens trust in due state, recurrence history, and reopen behavior right at the moment an operator is asked to act.
|
||||||
|
- **User-visible improvement**: Newly created or reopened findings arrive already ready for existing workflow use, with stable identity and lifecycle metadata that operators can trust immediately.
|
||||||
|
- **Smallest enterprise-capable version**: Make creation-time and recurrence-time finding invariants explicit for the active generator families and their shared reopen semantics, backed by focused regression proof, while reusing existing finding fields and workflow states only.
|
||||||
|
- **Explicit non-goals**: No backfill runtime surfaces, no acknowledged cleanup, no new customer-facing workflow, no broader findings lifecycle redesign, no new persistence, no new states, no external integration, no owner/assignee mandate, and no schema rollout except a possible future narrow follow-up.
|
||||||
|
- **Permanent complexity imported**: Low and bounded. The feature should add only explicit invariant coverage and possibly a narrow shared write-time guard if planning proves it necessary; no new table, state family, framework, or operator surface is justified.
|
||||||
|
- **Why now**: Specs 253 and 254 remove adjacent repair and compatibility debt. The next bounded unspecced findings candidate is to lock in the post-cleanup target state so active generators cannot drift back into repair-tool dependency.
|
||||||
|
- **Why not local**: Repo truth spans baseline compare, Entra admin roles, permission posture, shared reopen behavior, SLA/due initialization, and recurrence semantics. A local fix in one generator would leave the others as implied behavior and keep the invariant unowned.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this is the final bounded data-integrity hardening slice after surface removal and acknowledged cleanup, and it explicitly avoids bundling broader lifecycle redesign or new infrastructure.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Selection Rationale
|
||||||
|
|
||||||
|
- The source candidate is the active P1 entry `Enforce Creation-Time Finding Invariants` in `docs/product/spec-candidates.md`.
|
||||||
|
- This slice sits in the Findings Workflow / Data Integrity sequence and follows Spec 253 (`Remove Findings Lifecycle Backfill Runtime Surfaces`) and Spec 254 (`Remove Legacy Acknowledged Finding Status Compatibility`).
|
||||||
|
- It is the next bounded unspecced candidate in repo order. Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Platform Localization, Remove Findings Lifecycle Backfill Runtime Surfaces, and Remove Legacy Acknowledged Finding Status Compatibility already have specs.
|
||||||
|
- `External Support Desk / PSA Handoff` remains blocked because the repo still does not name one concrete external desk or PSA target.
|
||||||
|
- `Cross-Tenant Compare and Promotion v1` already has Spec 043 and is a refresh candidate, not the next unspecced preparation target.
|
||||||
|
- The smallest viable slice is to prove that active finding generators and reopen/recurrence paths always create or refresh findings in a lifecycle-ready state at write time, without reintroducing repair tooling, redesigning the lifecycle, adding new persistence, adding new workflow states, or widening into external workflow work.
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant
|
||||||
|
- **Primary Routes**:
|
||||||
|
- No new or changed direct route is the product target.
|
||||||
|
- Existing tenant findings surfaces are downstream regression consumers only: `/admin/t/{tenant}/findings` and `/admin/t/{tenant}/findings/{record}`.
|
||||||
|
- In-scope behavior is reached through existing tenant-scoped finding generation paths, including baseline compare completion, Entra admin role finding generation, and permission posture finding generation.
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned `Finding` records remain the canonical truth and keep required `workspace_id` plus `tenant_id` anchors.
|
||||||
|
- Existing `OperationRun` and `StoredReport` references stay contextual only where the current generators already use them; this feature introduces no new persisted entity, mirror table, or compatibility artifact.
|
||||||
|
- The scope is limited to write-time creation and refresh behavior for existing finding truth.
|
||||||
|
- **RBAC**:
|
||||||
|
- Tenant membership remains the isolation boundary for the downstream findings surfaces that consume these records.
|
||||||
|
- Existing user-triggered paths that lead to in-scope finding creation remain capability-first; non-members stay 404 and members lacking the current capability stay 403.
|
||||||
|
- Background or system-triggered generation must preserve tenant/workspace isolation and must not create a bypass that can write findings outside the current tenant scope.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: no
|
||||||
|
- **Interaction class(es)**: N/A - no shared operator interaction family is added or changed
|
||||||
|
- **Systems touched**: N/A - operator-facing shared interaction patterns stay unchanged
|
||||||
|
- **Existing pattern(s) to extend**: none
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: none
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: This slice hardens tenant-owned finding writes and shared lifecycle semantics, not notifications, action surfaces, or dashboard presentation.
|
||||||
|
- **Allowed deviation and why**: none
|
||||||
|
- **Consistency impact**: downstream findings and review surfaces continue consuming the same finding truth without any new UI branch
|
||||||
|
- **Review focus**: reviewers should verify that the feature stays in write-time lifecycle hardening and does not smuggle in new operator interaction patterns
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: N/A
|
||||||
|
- **Delegated start/completion UX behaviors**: N/A
|
||||||
|
- **Local surface-owned behavior that remains**: existing baseline compare and other current generation flows keep their current launch, completion, and link UX; this slice only hardens the finding writes they already produce
|
||||||
|
- **Queued DB-notification policy**: N/A
|
||||||
|
- **Terminal notification path**: N/A
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: no
|
||||||
|
- **Boundary classification**: N/A
|
||||||
|
- **Seams affected**: N/A
|
||||||
|
- **Neutral platform terms preserved or introduced**: N/A
|
||||||
|
- **Provider-specific semantics retained and why**: N/A
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: The slice hardens existing tenant-owned finding lifecycle truth across already-active generators without introducing a new shared provider seam, taxonomy, or vocabulary.
|
||||||
|
- **Follow-up path**: none
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
N/A - no operator-facing surface change. Existing findings, review, and summary surfaces are regression consumers of better write-time truth, not redesign targets in this feature.
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Operators should not receive a newly created or reopened finding that still depends on an implicit later repair step before due state, seen history, or canonical workflow use is trustworthy.
|
||||||
|
- **Existing structure is insufficient because**: the write-time invariant exists only as distributed repo behavior today. It is partially proven in separate tests, but not yet owned as one explicit product hardening slice across the active generator families and their recurrence semantics.
|
||||||
|
- **Narrowest correct implementation**: make the invariant explicit across the verified active generator families and shared reopen/recurrence behavior, using the existing finding fields, existing workflow states, existing SLA policy, and focused regression proof only.
|
||||||
|
- **Ownership cost**: a small amount of enduring regression coverage and possibly a narrow shared write-time guard if planning proves it necessary. No new table, state family, or general framework is justified.
|
||||||
|
- **Alternative intentionally rejected**: reintroducing lifecycle backfill or repair tooling, adding a new invariant framework or persistence layer, or widening into a broader findings lifecycle redesign.
|
||||||
|
- **Release truth**: current-release truth
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: the behavioral proof stays centered on the three focused writer suites around baseline compare, Entra admin roles, and permission posture, with only bounded adjacent regression in shared recurrence/workflow-service and downstream consumer/auth continuity tests because FR-255-005, FR-255-006, FR-255-009, and FR-255-011 cross the writer boundaries.
|
||||||
|
- **New or expanded test families**: none by default; reuse and tighten the three focused writer suites, plus bounded regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` only where they prove shared recurrence, consumer honesty, or unchanged trigger authorization
|
||||||
|
- **Fixture / helper cost impact**: low and near-neutral. The default path should reuse existing tenant, finding, and operation helpers instead of adding a broader harness.
|
||||||
|
- **Heavy-family visibility / justification**: none. No new heavy-governance or browser family is justified for this slice.
|
||||||
|
- **Special surface test profile**: N/A
|
||||||
|
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient because this slice hardens domain truth behind existing workflows rather than adding a new UI surface
|
||||||
|
- **Reviewer handoff**: reviewers must confirm that the final proof covers new finding creation, repeated observation, resolved-to-reopened transitions, unchanged 404 versus 403 semantics on the existing trigger surfaces, and preserved `current_operation_run_id` meaning without expanding into unrelated workflow or UI coverage
|
||||||
|
- **Budget / baseline / trend impact**: none expected beyond ordinary focused feature-test upkeep
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
|
||||||
|
|
||||||
|
## RBAC / Isolation Considerations
|
||||||
|
|
||||||
|
- Tenant-owned findings remain scoped by `workspace_id` and `tenant_id`. The feature must not create or preserve tenantless finding truth.
|
||||||
|
- Existing user-triggered operations that can lead to the in-scope finding writes keep current capability-first authorization. This slice does not add a new capability or role alias.
|
||||||
|
- Downstream findings and review surfaces keep current deny-as-not-found versus forbidden behavior: non-members remain 404, in-scope members missing the existing capability remain 403 on triggering actions.
|
||||||
|
- Explicit 404 versus 403 continuity proof stays bounded to `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`, because permission posture finding generation is background-triggered rather than launched from a separate tenant UI action.
|
||||||
|
- System-initiated reopen or refresh behavior stays inside the current tenant/workspace context and must not widen read or write visibility across tenants.
|
||||||
|
|
||||||
|
## Auditability
|
||||||
|
|
||||||
|
- Existing workflow-driven reopen semantics remain authoritative for system reopen behavior. The feature must preserve current audit and workflow meaning instead of introducing a silent side path.
|
||||||
|
- Existing `current_operation_run_id` correlations stay in place where the current generators already populate them; this slice does not add a second run-correlation path or new audit artifact.
|
||||||
|
- The hardening must not allow partially initialized findings to look settled or complete on downstream operator surfaces. The audit trail should continue to explain system-created and system-reopened findings through the existing lifecycle paths.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See Ready Findings Immediately (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want a newly detected finding to arrive already ready for the existing findings workflow so I can trust the status, due state, and seen history the first time it appears.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core product-truth outcome. If new findings still depend on implied repair logic, the feature has failed.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by triggering one new finding in each in-scope generator family and verifying that the first persisted record is already lifecycle-ready before any downstream findings page or review surface consumes it.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a baseline compare run detects new drift for a tenant, **When** the finding is first written, **Then** it already carries the canonical open status, first seen and last seen timestamps, seen count, and the due or SLA data required by the existing workflow.
|
||||||
|
2. **Given** an Entra admin roles or permission posture run detects a new issue, **When** the tenant findings register later displays that record, **Then** no backfill, repair action, or second pass is required to make the finding usable in the existing workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want a resolved issue that reappears to reopen the same finding with fresh lifecycle truth so I can continue work with the existing history instead of receiving a duplicate or stale record.
|
||||||
|
|
||||||
|
**Why this priority**: Reopen behavior is the critical recurrence path that keeps findings trustworthy after the cleanup sequence in Specs 253 and 254.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by resolving an in-scope finding, observing the same issue again, and verifying that the existing finding reopens with refreshed lifecycle fields.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a previously resolved baseline drift finding reappears, **When** the same drift is observed again, **Then** the existing finding reopens, resolved markers clear as needed, and the lifecycle fields required for current workflow use are refreshed at write time.
|
||||||
|
2. **Given** a previously resolved Entra admin roles or permission posture finding reappears, **When** the generator sees the same active issue again, **Then** the system reopens the same finding identity and refreshes the due or SLA truth according to the current severity policy already in use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator, I want repeated detection of the same active issue to strengthen the same finding record instead of creating uncontrolled duplicates or inflating seen counts incorrectly.
|
||||||
|
|
||||||
|
**Why this priority**: Stable recurrence semantics protect operator trust in counts, history, and due attention without widening the feature into broader lifecycle redesign.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by retrying or repeating the same observation across the in-scope families and verifying one canonical finding identity with bounded seen-count updates.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the same canonical issue is retried under the same observation identity, **When** the generator processes it again, **Then** the system does not create a duplicate finding and does not double-count the same observation.
|
||||||
|
2. **Given** the same canonical issue is observed again under a later valid observation, **When** the generator refreshes the existing finding, **Then** the same finding identity remains in place and the seen history advances according to that family's existing recurrence semantics.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A retried baseline compare job using the same run identity must not increment `times_seen` twice for the same observation.
|
||||||
|
- An existing finding encountered on a normal active path may still be missing `first_seen_at`, `last_seen_at`, or `times_seen`; the in-scope write path must repair those fields inline instead of depending on a separate repair surface.
|
||||||
|
- A resolved finding should reopen only when the new observation is later than the prior resolution boundary; out-of-order or stale observations must not incorrectly reopen it.
|
||||||
|
- If current SLA policy derives a due date from severity, the reopened or newly created record must be ready for that downstream truth immediately; the feature must not defer due-state initialization to a later process.
|
||||||
|
- The feature must preserve one canonical finding identity even when evidence payloads or current hashes change between observations.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes tenant-owned finding write behavior but does not add Microsoft Graph calls, a new user-facing mutation surface, or a new long-running workflow. It hardens existing write-time semantics in current generator paths, preserves tenant isolation, preserves existing audit meaning plus `current_operation_run_id` correlation on the in-scope write paths, and requires focused regression proof.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice must not introduce new persistence, new abstraction, new state family, or new semantic layer. If planning proposes a shared invariant helper, it must prove why the existing distributed write paths cannot safely stay explicit without creating a new unowned drift point.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** No new cross-cutting operator interaction family is allowed in this slice. Existing findings, review, and summary surfaces remain unchanged consumers of better write-time truth.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in the narrow focused feature tests already closest to the active generator families and their recurrence behavior. The feature must not create a new heavy family or browser dependency.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX / OPS-UX-START-001):** Existing baseline compare and other current generation flows may continue using their current `OperationRun` semantics where already present, but this feature does not add or change operation start UX, queued notification policy, or deep-link behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** Existing triggering authorization stays capability-first and unchanged. The feature must not add a hidden bypass or new capability branch to create or reopen findings.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001 / DECIDE-AUD-001):** Existing operator surfaces must never depend on partially initialized finding truth. The hardening exists so downstream decision surfaces continue to show calm, honest workflow data without false readiness.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-255-001**: The system MUST ensure each in-scope active finding generator family writes a newly created finding in a lifecycle-ready state within the same write path that first persists the record.
|
||||||
|
- **FR-255-002**: The in-scope active generator families for this feature are baseline compare drift, Entra admin roles, and permission posture. The invariant MUST be explicit across all three families, not only one local path.
|
||||||
|
- **FR-255-003**: A newly created in-scope finding MUST carry the canonical initial workflow status plus the lifecycle fields needed by existing downstream workflow surfaces, including first seen and last seen timestamps, a valid seen count, and existing SLA or due-date truth when the current severity policy already requires them.
|
||||||
|
- **FR-255-004**: Repeated observation of the same active condition MUST reuse one canonical finding identity through the existing recurrence key or fingerprint semantics and MUST refresh the existing record instead of creating uncontrolled canonical duplicates.
|
||||||
|
- **FR-255-005**: A retry or repeated processing of the same observation identity MUST NOT double-count the same observation. Each in-scope generator family may keep its current observation semantics, but the feature MUST make those semantics explicit and regression-protected.
|
||||||
|
- **FR-255-006**: When a previously resolved in-scope finding reappears, the system MUST reopen the existing finding through the current workflow path and MUST clear or refresh the lifecycle data required for immediate downstream workflow use.
|
||||||
|
- **FR-255-007**: If an in-scope active path encounters an existing finding with missing lifecycle fields covered by this slice, the normal write path MUST repair those fields inline instead of depending on backfill jobs, tenant repair actions, CLI repair commands, or deploy-time hooks.
|
||||||
|
- **FR-255-008**: The feature MUST preserve current tenant/workspace isolation by keeping every in-scope finding write anchored to the current tenant and workspace and by not widening visibility or write scope across tenants.
|
||||||
|
- **FR-255-009**: The feature MUST preserve capability-first RBAC and existing 404 versus 403 semantics on the current user-triggered entry points that lead to in-scope finding creation or refresh, specifically the baseline compare matrix and admin-roles scan surfaces.
|
||||||
|
- **FR-255-010**: The feature MUST preserve existing finding workflow states, downstream review surfaces, and operator affordances. It MUST NOT add new workflow states, reintroduce repair tooling, re-open acknowledged-status cleanup, require owner or assignee fields, or add external support or PSA workflow scope.
|
||||||
|
- **FR-255-011**: The feature MUST keep existing audit meaning and `current_operation_run_id` correlation intact where the current generators already attach reopened or refreshed findings to system workflow paths.
|
||||||
|
- **FR-255-012**: Regression proof MUST make the invariant explicit across new creation, repeated observation, and resolved-to-reopened behavior for the in-scope generator families.
|
||||||
|
- **FR-255-013**: Any database constraint or migration-based invariant enforcement beyond the existing application write paths is out of scope for this feature and MAY only be considered as a later narrow follow-up if planning proves it is compatibility-safe and materially smaller than a broader redesign.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Lifecycle-ready finding**: A tenant-owned finding record that is immediately usable in the existing workflow because it already has canonical lifecycle status, seen history, recurrence identity, and due/SLA truth where current policy requires it.
|
||||||
|
- **Finding generator family**: One of the active repo-owned write paths that creates or refreshes findings today: baseline compare drift, Entra admin roles, or permission posture.
|
||||||
|
- **Recurrence identity**: The existing recurrence key or fingerprint semantics that decide whether repeated observation refreshes one finding or incorrectly creates a new one.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: During regression validation, 100% of newly created in-scope findings arrive with the lifecycle data needed by the existing downstream workflow in the same observation cycle that first persists them.
|
||||||
|
- **SC-002**: During regression validation, 0 in-scope active finding paths require a separate repair, backfill, or deploy-time step before newly created or reopened findings are safe to show on existing workflow surfaces.
|
||||||
|
- **SC-003**: During regression validation, repeated observation of the same in-scope issue reuses one canonical finding identity instead of creating uncontrolled duplicates across each in-scope generator family.
|
||||||
|
- **SC-004**: During regression validation, previously resolved in-scope findings reopen through the existing workflow path with refreshed lifecycle truth across each in-scope generator family.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- The baseline compare finding creation and recurrence path in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- The Entra admin roles finding creation and reopen path in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||||
|
- The permission posture finding creation and reopen path in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
- Existing shared finding lifecycle behavior such as reopen semantics and SLA/due calculation already used by those paths
|
||||||
|
- Existing focused regression proof in:
|
||||||
|
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 253 removes the visible lifecycle backfill runtime surfaces and Spec 254 removes acknowledged compatibility first, so this slice can focus only on the post-cleanup target state.
|
||||||
|
- The three verified active generator families above are the full bounded scope for this feature unless planning finds another currently active finding writer that is equally first-class and already shipping.
|
||||||
|
- Lifecycle-ready does not make owner, assignee, or additional governance fields mandatory. It only covers the existing lifecycle truth needed for current workflow readiness.
|
||||||
|
- The product remains pre-production, so historical data migration, compatibility shims, and retained repair tooling are not justified.
|
||||||
|
- Downstream findings, review, and summary surfaces should continue working without design changes if write-time truth is hardened correctly.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Another active finding writer may exist outside the three verified families and remain unsafely implicit if planning does not confirm the full set before implementation.
|
||||||
|
- Over-eager implementation could introduce a generic invariant framework or broaden into lifecycle redesign, which would violate the intended slice boundary.
|
||||||
|
- Different generator families already count repeated observation differently; forcing one artificial rule instead of preserving each family's valid observation semantics could create regressions while trying to harden the invariant.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Reintroducing findings lifecycle backfill runtime surfaces, repair commands, deploy hooks, or tenant repair actions
|
||||||
|
- Removing acknowledged compatibility or changing broader findings workflow vocabulary, which is already covered by Spec 254
|
||||||
|
- New customer-facing workflow surfaces, review inbox redesign, customer review workspace work, or localization work
|
||||||
|
- New persistence, new workflow states, new owner/assignee requirements, or broader findings lifecycle redesign
|
||||||
|
- External Support Desk / PSA Handoff work
|
||||||
|
- Cross-Tenant Compare and Promotion refresh work already tracked under Spec 043
|
||||||
|
- Schema changes, migrations, or database constraints except as an explicit later follow-up candidate
|
||||||
|
|
||||||
|
## Follow-up Candidates
|
||||||
|
|
||||||
|
1. A very narrow database-level invariant guard may be considered later only if planning proves it can enforce one of these fields safely without reopening compatibility or widening the feature.
|
||||||
|
2. `External Support Desk / PSA Handoff` remains deferred until the repo names one concrete external desk or PSA target.
|
||||||
|
3. `Cross-Tenant Compare and Promotion v1` remains on the existing Spec 043 track as a refresh candidate rather than being reopened inside this hardening slice.
|
||||||
242
specs/255-enforce-finding-creation-invariants/tasks.md
Normal file
242
specs/255-enforce-finding-creation-invariants/tasks.md
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
# Tasks: Enforce Creation-Time Finding Invariants
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/255-enforce-finding-creation-invariants/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/finding-creation-invariants.contract.yaml`, `checklists/requirements.md`
|
||||||
|
|
||||||
|
**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md`. Keep the three writer suites as the primary proof in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`. Use only bounded adjacent regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` where they prove shared recurrence, consumer honesty, or unchanged trigger authorization without inflating the implementation scope into direct UI rewrites.
|
||||||
|
**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migrations, DB constraints, repair tooling, deploy hooks, or external integrations. Existing `current_operation_run_id` correlations stay contextual only where the current writers already set them, and any report-emission assertions remain bounded to the writer suites that already own them.
|
||||||
|
**RBAC**: Preserve current tenant/workspace isolation, current `404` versus `403` behavior on the baseline compare matrix and admin-roles scan trigger surfaces, and the existing tenant-scoped background/system reopen semantics. Do not add a new capability, bypass, or customer-facing workflow branch.
|
||||||
|
**UI / Surface Guardrails**: This is a `review-mandatory` write-time truth hardening slice with `standard-native-filament` relief. `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` stay regression consumers only unless existing tests prove a shared-truth fix is insufficient.
|
||||||
|
**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, provider, or asset work is introduced. `FindingResource` already has a view page, so global-search compliance stays satisfied without new tasking. No new destructive action is introduced or changed.
|
||||||
|
**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` -> `US2` -> `US3` -> final validation, because creation-readiness must be explicit before reopen and recurrence proofs are tightened.
|
||||||
|
|
||||||
|
**Implementation note**: If creation-time invariants converge through the three writer paths plus `FindingWorkflowService` and `FindingSlaPolicy`, keep downstream findings surfaces untouched and make proof responsibility explicit in their existing test files rather than planning direct edits to every listed consumer file.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for write-time lifecycle hardening.
|
||||||
|
- [x] New or changed tests stay in focused `Feature` files only; no browser or new heavy-governance family is added.
|
||||||
|
- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; any broader setup is isolated to the findings suites that already need it.
|
||||||
|
- [x] Planned validation commands stay limited to the quickstart command set, allowing the three writer-suite commands to be combined into one equivalent Sail invocation plus the shared recurrence, consumer, and trigger-authorization checks below.
|
||||||
|
- [x] The declared surface test profile stays `standard-native-filament`; downstream findings surfaces remain proof consumers only.
|
||||||
|
- [x] Any material residue or follow-up note resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`, not as implicit scope drift.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Invariant Anchors)
|
||||||
|
|
||||||
|
**Purpose**: Lock the bounded writer inventory, shared lifecycle seams, and proving commands before implementation starts.
|
||||||
|
|
||||||
|
- [x] T001 [P] Verify the bounded feature package, stop conditions, and non-goals across `specs/255-enforce-finding-creation-invariants/spec.md`, `specs/255-enforce-finding-creation-invariants/plan.md`, `specs/255-enforce-finding-creation-invariants/research.md`, `specs/255-enforce-finding-creation-invariants/data-model.md`, `specs/255-enforce-finding-creation-invariants/quickstart.md`, and `specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml`
|
||||||
|
- [x] T002 [P] Verify the active finding-writer and shared seam inventory across `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and `apps/platform/app/Models/Finding.php`
|
||||||
|
- [x] T003 [P] Verify the narrow Sail validation commands and manual smoke expectations in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md`
|
||||||
|
- [x] T004 [P] Verify downstream proof-only consumers across `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The bounded invariant target, shared seams, and validation entry points are explicit before any runtime file changes begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Proof Surfaces)
|
||||||
|
|
||||||
|
**Purpose**: Make the intended proof surfaces and adjacent cleanup guardrails explicit before the write paths are changed.
|
||||||
|
|
||||||
|
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T005 [P] Lock the per-family lifecycle-ready and inline-repair proof plan in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- [x] T006 [P] Lock the shared recurrence and reopen proof plan in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
- [x] T007 [P] Audit incomplete-lifecycle fixture and helper anchors across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- [x] T008 [P] Audit adjacent cleanup guardrails in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` so this slice does not reintroduce repair tooling or acknowledged compatibility
|
||||||
|
|
||||||
|
**Checkpoint**: Writer-level proof, shared reopen proof, and adjacent no-regression guardrails are explicit and ready for bounded implementation work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See Ready Findings Immediately (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Newly detected findings arrive lifecycle-ready on first persistence across the three active writer families.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger one new finding per writer family and verify the first persisted record already carries canonical open status, seen history, ownership anchors, and due or SLA truth without any repair or second-pass workflow.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [P] [US1] Extend baseline compare create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- [x] T010 [P] [US1] Extend Entra admin roles create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- [x] T011 [P] [US1] Extend permission posture create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [US1] Align baseline compare finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- [x] T013 [US1] Align Entra admin roles finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
|
||||||
|
- [x] T014 [US1] Align permission posture finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
- [x] T015 [US1] Keep ownership anchors plus due or SLA initialization explicit without introducing a migration or repair surface in `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and the three story tests from `T009` through `T011`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and all three active writers create lifecycle-ready findings in the same write path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Resolved findings reopen through the existing shared workflow path with refreshed lifecycle truth and preserved canonical identity.
|
||||||
|
|
||||||
|
**Independent Test**: Resolve an in-scope finding, re-observe the same issue through each writer family, and verify the same record reopens with cleared terminal markers and refreshed due or SLA truth.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T016 [P] [US2] Add baseline compare resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||||
|
- [x] T017 [P] [US2] Add Entra admin roles resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
|
||||||
|
- [x] T018 [P] [US2] Add permission posture resolved-to-reopened regression plus existing `current_operation_run_id` and stored-report emission continuity coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- [x] T019 [P] [US2] Tighten shared reopen-service proof for `reopenBySystem()` due or SLA recalculation, audit continuity, and terminal eligibility in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T020 [US2] Keep reopened-state mutation on `FindingWorkflowService::reopenBySystem()` and reconcile baseline compare call sites in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||||
|
- [x] T021 [US2] Preserve same-finding reopen identity and family-specific evidence refresh in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
- [x] T022 [US2] Reconcile reopened due-date, SLA, and resolved-marker expectations without adding new workflow states or audit dialects in `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and resolved findings reopen through the existing shared workflow semantics rather than duplicating records or adding a second reopen path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Repeated observation strengthens the same finding record, respects each family's observation boundary, and keeps downstream surfaces truthful without widening the feature into UI redesign.
|
||||||
|
|
||||||
|
**Independent Test**: Re-run the same observation and then a later valid observation across the in-scope families and verify that one canonical finding identity remains in place, same-observation retries do not double count, and downstream findings surfaces still read honest lifecycle truth.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T023 [P] [US3] Extend same-observation idempotence and canonical-identity reuse coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php`
|
||||||
|
- [x] T024 [P] [US3] Extend cross-family recurrence and observation-boundary coverage in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` and `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`
|
||||||
|
- [x] T025 [P] [US3] Tighten downstream consumer and trigger-authorization proof that shared lifecycle truth still renders honestly and that non-members remain `404` while in-scope capability failures remain `403` in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T026 [US3] Preserve family-owned recurrence keys, fingerprints, and observation boundaries while preventing duplicate canonical findings in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
|
||||||
|
- [x] T027 [US3] Keep any shared lifecycle normalization bounded to `apps/platform/app/Services/Findings/` only when it replaces real duplication across all three writers, with proof confined to `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, and `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` rather than widening into new workflow or UI files
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and recurrence keeps one canonical finding identity without double-counting or forcing direct downstream surface rewrites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Keep the slice bounded, run the narrow validation workflow, and check for out-of-scope residue.
|
||||||
|
|
||||||
|
- [x] T028 [P] Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [x] T029 [P] Run the focused writer-suite Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
|
||||||
|
- [x] T030 [P] Run the focused shared-recurrence Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
|
||||||
|
- [x] T031 [P] Run the downstream-consumer and trigger-RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
|
||||||
|
- [ ] T032 [P] Execute quickstart manual smoke steps 1 through 4 from `specs/255-enforce-finding-creation-invariants/quickstart.md` against `/admin/t/{tenant}/findings`, `MyFindingsInbox`, and `FindingsIntakeQueue`, then leave diff/scope review to `T034`
|
||||||
|
- [x] T033 [P] Run residue searches for `backfill`, `repair`, `constraint`, `migration`, and any new `Finding::STATUS_` additions across `apps/platform/app/`, `apps/platform/tests/`, `apps/platform/database/`, and `specs/255-enforce-finding-creation-invariants/`, then classify each remaining match as allowed shared-consumer proof, in-scope cleanup to delete now, or `reject-or-split`
|
||||||
|
- [x] T034 Verify that no file under `apps/platform/database/migrations/` changed, no new repair or rollout entry point appeared under `apps/platform/app/Console/Commands/` or `apps/platform/app/Services/Runbooks/`, and no direct workflow expansion landed in `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, or `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`; if truth-consumer proof from `T025` and `T031` suggests a direct UI edit is necessary, stop and record that as `document-in-feature` or `reject-or-split` instead of treating it as default in-scope work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately and locks the exact scope, writer inventory, and proving commands.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, fixtures, and adjacent cleanup guardrails are explicit.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational and establishes the lifecycle-ready create contract.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on User Story 1 because reopen semantics should refresh the same lifecycle-ready contract established at creation time.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because recurrence and consumer proof only mean the right thing after create and reopen behavior are aligned.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete so final validation and residue checks run on the finished slice.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependencies beyond Foundational.
|
||||||
|
- **US2**: Depends on US1.
|
||||||
|
- **US3**: Depends on US1 and US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Add or update the story tests first and confirm they fail before implementation edits are considered complete.
|
||||||
|
- Keep recurrence identity family-owned instead of introducing a generic invariant framework.
|
||||||
|
- Keep downstream findings surfaces as proof consumers unless shared-truth tests prove a concrete need for direct edits.
|
||||||
|
- Keep migrations, DB constraints, repair tooling, acknowledged cleanup, external support-desk or PSA work, customer-facing workflow changes, and broad findings redesign out of scope.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup.
|
||||||
|
- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work.
|
||||||
|
- `T009`, `T010`, and `T011` can run in parallel for User Story 1 before `T012`, `T013`, `T014`, and `T015`.
|
||||||
|
- `T016`, `T017`, `T018`, and `T019` can run in parallel for User Story 2 before `T020`, `T021`, and `T022`.
|
||||||
|
- `T023`, `T024`, and `T025` can run in parallel for User Story 3 before `T026` and `T027`.
|
||||||
|
- `T029`, `T030`, `T031`, `T032`, and `T033` can run in parallel during final validation after `T028`, followed by `T034` as the final scope-boundary check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 1 tests in parallel
|
||||||
|
T009 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||||
|
T010 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
|
||||||
|
T011 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php
|
||||||
|
|
||||||
|
# User Story 1 implementation after the tests are in place
|
||||||
|
T012 apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||||
|
T013 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
|
||||||
|
T014 apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 2 tests in parallel
|
||||||
|
T016 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||||
|
T017 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
|
||||||
|
T018 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php
|
||||||
|
T019 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php
|
||||||
|
|
||||||
|
# User Story 2 implementation after the tests are in place
|
||||||
|
T020 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||||
|
T021 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 tests in parallel
|
||||||
|
T023 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php
|
||||||
|
T024 apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php + apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php
|
||||||
|
T025 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
|
||||||
|
|
||||||
|
# User Story 3 implementation after the tests are in place
|
||||||
|
T026 apps/platform/app/Jobs/CompareBaselineToTenantJob.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
|
||||||
|
T027 apps/platform/app/Services/Findings/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Stories 1 and 2)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Complete Phase 4: User Story 2.
|
||||||
|
5. Run `T028`, `T029`, and `T030` before widening into recurrence and consumer-proof cleanup.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Lock the bounded writer inventory, proof files, and stop conditions.
|
||||||
|
2. Make create-time lifecycle readiness explicit for baseline compare, Entra admin roles, and permission posture.
|
||||||
|
3. Preserve the shared reopen path and refresh lifecycle truth when resolved findings return.
|
||||||
|
4. Tighten recurrence, idempotence, and downstream proof without widening into UI redesign or repair tooling.
|
||||||
|
5. Finish with focused Sail validation, manual smoke, and residue checks.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One contributor can own the three writer-family tests while another confirms shared recurrence and downstream consumer proof after Phase 2.
|
||||||
|
2. After User Story 1 lands, one contributor can align the reopen path while another prepares the recurrence and consumer proof for User Story 3.
|
||||||
|
3. Finish with one bounded pass for formatting, focused Sail validation, and residue or scope-boundary review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Suggested MVP scope: Phase 1 through Phase 4. Creation readiness without reopen reuse is not sufficient for this feature.
|
||||||
|
- Explicit non-goals remain: runtime backfill surfaces, acknowledged cleanup, new workflow states, broad findings redesign, migrations or DB constraints, repair tooling, external support-desk or PSA work, and customer-facing workflow expansion.
|
||||||
|
- Filament remains on Livewire v4.0+; no panel/provider or asset strategy changes are needed, and `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location if later Filament work is ever required.
|
||||||
|
- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths.
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
# Preparation Review Checklist: External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
**Purpose**: Validate the prepared support-handoff package against the repo's guardrail, support-truth, provider-boundary, scoped-visibility, and close-out workflow requirements before implementation
|
||||||
|
**Created**: 2026-04-29
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Applicability And Low-Impact Gate
|
||||||
|
|
||||||
|
- [x] CHK001 The package explicitly treats this as an operator-facing extension on two existing support-aware actions, so the low-impact `N/A` path is not used.
|
||||||
|
- [x] CHK002 The spec, plan, tasks, and supporting artifacts carry the same bounded slice: existing `SupportRequest` truth stays authoritative, visibility stays on the current tenant or run support contexts only, handoff remains one-way, one configured target is allowed, and the close-out target remains `Guardrail / Exception / Smoke Coverage`.
|
||||||
|
|
||||||
|
## Native, Shared-Family, And State Ownership
|
||||||
|
|
||||||
|
- [x] CHK003 The primary surfaces remain native Filament actions on `TenantDashboard` and `TenantlessOperationRunViewer` instead of a support-request resource, support queue, helpdesk shell, or standalone external-desk page.
|
||||||
|
- [x] CHK004 Shared support families remain shared: the internal `SR-...` support request stays the canonical truth, the latest handoff summary stays attached to the same two support actions, and the package does not invent a parallel support history or ticket-register surface.
|
||||||
|
- [x] CHK005 Page, detail, action-form, and persisted state owners are named once: `support_requests` is the only planned persisted truth, while the tenant and run pages own only current-context presentation and submit-time form state.
|
||||||
|
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: the operator uses the existing `Request support` action, chooses one handoff mode inside that form, and does not branch into a second workflow family.
|
||||||
|
|
||||||
|
## Shared Pattern Reuse
|
||||||
|
|
||||||
|
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-aware action surfaces.
|
||||||
|
- [x] CHK008 The package extends existing shared paths where they are sufficient, and the only allowed deviation is one concrete provider-owned handoff service plus one tiny latest-summary read helper if implementation proves it necessary, not a generic helpdesk registry or page-local HTTP path.
|
||||||
|
- [x] CHK009 The package does not create a parallel operator UX language; `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk` stay consistent across tenant, run, notification, and audit wording.
|
||||||
|
|
||||||
|
## OperationRun Start UX Contract
|
||||||
|
|
||||||
|
- [x] CHK019 The package explicitly states that the run surface uses the current `OperationRun` only as support context and does not create, queue, deduplicate, resume, block, complete, or deep-link to a run workflow as part of the handoff slice.
|
||||||
|
- [x] CHK020 Run-specific workflow contracts stay on the existing canonical run page; queued toast/link/browser-event/dedupe behavior is not reintroduced locally for support handoff.
|
||||||
|
- [x] CHK021 No queued DB notification or terminal-notification path is added because the slice stays synchronous inside the current support-request submit path.
|
||||||
|
- [x] CHK022 No `OperationRun` exception is required in the preparation package; if implementation later adds retries, queueing, or run-orchestration semantics, that must be recorded as out-of-scope drift in the active close-out entry.
|
||||||
|
|
||||||
|
## Provider Boundary And Vocabulary
|
||||||
|
|
||||||
|
- [x] CHK010 Provider-specific semantics stay behind one concrete provider-owned handoff service and one preconfigured target-resolution seam; the planned persisted truth stays neutral on `SupportRequest` with handoff mode, external reference, external URL, and bounded failure summary only.
|
||||||
|
- [x] CHK011 No retained provider-specific shared boundary or second-target abstraction is introduced; multi-provider support, target-management UI, and broader ITSM or helpdesk modeling remain follow-up-spec work only.
|
||||||
|
|
||||||
|
## Signals, Exceptions, And Test Depth
|
||||||
|
|
||||||
|
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`: the package adds a new provider seam and new persisted fields, but it does so on the existing support-request truth without hidden queue, resource, or support-framework drift.
|
||||||
|
- [x] CHK013 One bounded contract exception is explicit in the preparation package: Spec 256 allows exactly one synchronous finalization write on the same `SupportRequest` row after internal creation, limited to external handoff fields only. Any wider mutability, retry orchestration, or support-history spread must still be documented in the active feature close-out entry instead of becoming silent scope growth.
|
||||||
|
- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the tenant dashboard action, `monitoring-state-page` for the run action, and focused `Unit` plus `Feature` proof for handoff branching, scoped summary reuse, authorization, and audit behavior.
|
||||||
|
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused Pest unit plus feature coverage with narrow manual smoke after implementation, and no implicit browser-only, global-search, or new resource coverage obligation is invented.
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure And Decision Hierarchy
|
||||||
|
|
||||||
|
- [x] CHK023 Default-visible content stays decision-first: current support context, one handoff-mode decision, one latest bounded handoff summary, and one submit action remain primary.
|
||||||
|
- [x] CHK024 The package keeps raw/provider-heavy material out of default-visible truth: no raw payloads, credentials, provider response bodies, assignee or SLA fields, retry status, or cross-scope lookup shortcuts are allowed into the support-request row or default UI copy.
|
||||||
|
- [x] CHK025 Exactly one dominant next action remains primary: `Submit support request`; external create or link is modeled as a form choice, not as a competing primary action or second workflow entry point.
|
||||||
|
- [x] CHK026 Duplicate visible truth is avoided by naming one internal support reference and one latest context-scoped handoff summary instead of introducing a ticket history block, queue summary, or separate support register surface.
|
||||||
|
- [x] CHK027 Support or raw detail stays hidden or provider-owned, and latest handoff visibility remains bounded to the same entitled tenant or current run context with the existing `404` versus `403` rules.
|
||||||
|
|
||||||
|
## Review Outcome
|
||||||
|
|
||||||
|
- [x] CHK016 Review outcome class: `acceptable-special-case`
|
||||||
|
- [x] CHK017 Workflow outcome: `keep`
|
||||||
|
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` should record target-prerequisite status, any bounded implementation exception, and the final proof or smoke outcome.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, and the conceptual contract. It does not claim application code exists.
|
||||||
|
- The slice remains bounded to the existing support-request truth and the two existing support-aware actions only. No support-request resource, support queue, helpdesk framework, global-search surface, or `OperationRun` workflow is approved by this package.
|
||||||
|
- Preparation note: the package now makes the single-target resolution seam explicit through `apps/platform/config/support_desk.php` and keeps workspace settings UI, per-workspace target management, second-target support, and retry or relink orchestration as later follow-up scope.
|
||||||
|
- Preparation note: Spec 256 explicitly narrows Spec 246 immutability for one synchronous handoff-finalization write only; no broader edit, reopen, merge, or lifecycle workflow is approved by this package.
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantPilot Admin — External Support Desk Handoff (Conceptual)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Conceptual contract for the first external support desk handoff slice.
|
||||||
|
|
||||||
|
NOTE: These flows are implemented as Filament (Livewire) actions on
|
||||||
|
existing pages. This file captures the expected action payload, outcome
|
||||||
|
semantics, and authorization boundaries rather than a public REST API.
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
paths:
|
||||||
|
/t/{tenant}/support-requests/actions/submit:
|
||||||
|
post:
|
||||||
|
summary: Submit a tenant-context support request with optional external handoff
|
||||||
|
description: |
|
||||||
|
Existing tenant dashboard support action, extended with one-way external
|
||||||
|
handoff behavior.
|
||||||
|
|
||||||
|
Authorization:
|
||||||
|
- Workspace non-member or non-entitled tenant actor: 404
|
||||||
|
- Entitled tenant member without `support_requests.create`: 403
|
||||||
|
- Authorized actor: 200 with one support-request submission result
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Always creates the internal `SR-...` support request first
|
||||||
|
- `internal_only` performs no outbound handoff
|
||||||
|
- `link_existing_ticket` stores the provided external reference and must not call external create
|
||||||
|
- `create_external_ticket` uses one application-configured external target only
|
||||||
|
- `create_external_ticket` applies a maximum 5 second outbound timeout budget
|
||||||
|
- External create failure keeps the internal support request and returns an explicit failed-handoff outcome
|
||||||
|
- No queue, `OperationRun`, retry scheduler, or bidirectional sync is introduced
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Filament tenancy slug (`tenants.external_id`)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SupportRequestHandoffSubmission'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Support request accepted with internal-only, linked, created, or failed-handoff outcome
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SupportRequestHandoffResult'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (entitled tenant member lacks support-request capability)
|
||||||
|
'404':
|
||||||
|
description: Not found (wrong workspace, non-member, or missing tenant entitlement)
|
||||||
|
/operations/{run}/support-requests/actions/submit:
|
||||||
|
post:
|
||||||
|
summary: Submit a run-context support request with optional external handoff
|
||||||
|
description: |
|
||||||
|
Existing canonical run detail support action, extended with one-way
|
||||||
|
external handoff behavior.
|
||||||
|
|
||||||
|
Authorization:
|
||||||
|
- Inaccessible run or run outside entitled tenant scope: 404
|
||||||
|
- Entitled member without `support_requests.create`: 403
|
||||||
|
- Authorized actor: 200 with one support-request submission result
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- The run must resolve to an entitled tenant before any support truth is revealed
|
||||||
|
- Uses the same payload contract and outcome semantics as the tenant-context action
|
||||||
|
- Does not create, resume, or update an `OperationRun`
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Internal `operation_runs.id`
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SupportRequestHandoffSubmission'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Support request accepted with internal-only, linked, created, or failed-handoff outcome
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SupportRequestHandoffResult'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (entitled member lacks support-request capability)
|
||||||
|
'404':
|
||||||
|
description: Not found (run inaccessible under workspace or tenant scope)
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
SupportRequestHandoffSubmission:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- severity
|
||||||
|
- summary
|
||||||
|
- handoff_mode
|
||||||
|
properties:
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
enum: [low, normal, high, blocking]
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
reproduction_notes:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
contact_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
contact_email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
nullable: true
|
||||||
|
handoff_mode:
|
||||||
|
type: string
|
||||||
|
enum: [internal_only, create_external_ticket, link_existing_ticket]
|
||||||
|
external_ticket_reference:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Required when `handoff_mode = link_existing_ticket`
|
||||||
|
external_ticket_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
target_available:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
description: Derived UI hint only; the server remains authoritative
|
||||||
|
SupportRequestHandoffResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- support_request_id
|
||||||
|
- internal_reference
|
||||||
|
- primary_context_type
|
||||||
|
- handoff_mode
|
||||||
|
- handoff_outcome
|
||||||
|
- latest_summary
|
||||||
|
properties:
|
||||||
|
support_request_id:
|
||||||
|
type: integer
|
||||||
|
internal_reference:
|
||||||
|
type: string
|
||||||
|
primary_context_type:
|
||||||
|
type: string
|
||||||
|
enum: [tenant, operation_run]
|
||||||
|
primary_context_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
handoff_mode:
|
||||||
|
type: string
|
||||||
|
enum: [internal_only, create_external_ticket, link_existing_ticket]
|
||||||
|
handoff_outcome:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- internal_only
|
||||||
|
- external_ticket_created
|
||||||
|
- external_ticket_linked
|
||||||
|
- external_handoff_failed
|
||||||
|
external_ticket_reference:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
external_ticket_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
failure_summary:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
latest_summary:
|
||||||
|
$ref: '#/components/schemas/LatestSupportRequestHandoffSummary'
|
||||||
|
LatestSupportRequestHandoffSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- internal_reference
|
||||||
|
- primary_context_type
|
||||||
|
- submitted_at
|
||||||
|
- handoff_mode
|
||||||
|
- has_external_link
|
||||||
|
- has_failure
|
||||||
|
properties:
|
||||||
|
internal_reference:
|
||||||
|
type: string
|
||||||
|
primary_context_type:
|
||||||
|
type: string
|
||||||
|
enum: [tenant, operation_run]
|
||||||
|
primary_context_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
submitted_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
handoff_mode:
|
||||||
|
type: string
|
||||||
|
enum: [internal_only, create_external_ticket, link_existing_ticket]
|
||||||
|
external_ticket_reference:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
external_ticket_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
external_handoff_failure_summary:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
has_external_link:
|
||||||
|
type: boolean
|
||||||
|
has_failure:
|
||||||
|
type: boolean
|
||||||
161
specs/256-external-support-desk-handoff/data-model.md
Normal file
161
specs/256-external-support-desk-handoff/data-model.md
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
# Data Model — External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
**Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
Spec 256 extends the existing support-request truth. No new support-ticket table, resource, or queue artifact is introduced.
|
||||||
|
|
||||||
|
## Existing Canonical Entity Extended
|
||||||
|
|
||||||
|
### SupportRequest (`support_requests`)
|
||||||
|
|
||||||
|
**Purpose**: Canonical tenant-owned support-request truth. Spec 256 extends it so the same row can carry one-way external handoff continuity.
|
||||||
|
|
||||||
|
**Existing key fields (already in repo)**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `operation_run_id`
|
||||||
|
- `initiated_by_user_id`
|
||||||
|
- `internal_reference`
|
||||||
|
- `primary_context_type`
|
||||||
|
- `attachment_mode`
|
||||||
|
- `severity`
|
||||||
|
- `summary`
|
||||||
|
- `reproduction_notes`
|
||||||
|
- `contact_name`
|
||||||
|
- `contact_email`
|
||||||
|
- `context_envelope`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
|
**New fields (planned)**:
|
||||||
|
- `external_handoff_mode`
|
||||||
|
- type: string
|
||||||
|
- required: yes
|
||||||
|
- default: `internal_only`
|
||||||
|
- allowed values:
|
||||||
|
- `internal_only`
|
||||||
|
- `create_external_ticket`
|
||||||
|
- `link_existing_ticket`
|
||||||
|
- `external_ticket_reference`
|
||||||
|
- type: nullable string
|
||||||
|
- stored when an external ticket was created or linked successfully
|
||||||
|
- `external_ticket_url`
|
||||||
|
- type: nullable text
|
||||||
|
- stored only when the target returns or the operator provides a valid URL
|
||||||
|
- `external_handoff_failure_summary`
|
||||||
|
- type: nullable text
|
||||||
|
- bounded human-readable failure summary for the current request only
|
||||||
|
|
||||||
|
**Relationships (unchanged)**:
|
||||||
|
- belongs to `Workspace`
|
||||||
|
- belongs to `Tenant`
|
||||||
|
- optionally belongs to `OperationRun`
|
||||||
|
- optionally belongs to initiator `User`
|
||||||
|
|
||||||
|
**Behavioral rules**:
|
||||||
|
- `internal_reference` remains the canonical TenantPilot support identifier even when an external ticket exists.
|
||||||
|
- `external_handoff_mode` records the operator’s chosen path and replaces the need for a second persisted status family.
|
||||||
|
- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request is created, the same row may receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No later edit, reopen, merge, or status workflow is introduced.
|
||||||
|
- `external_ticket_reference` and `external_ticket_url` remain null for `internal_only` and for failed create attempts.
|
||||||
|
- `external_handoff_failure_summary` remains null on successful create, successful link, and internal-only submissions.
|
||||||
|
- On a failed external create, the row persists with:
|
||||||
|
- `external_handoff_mode = create_external_ticket`
|
||||||
|
- `external_ticket_reference = null`
|
||||||
|
- `external_ticket_url = null`
|
||||||
|
- `external_handoff_failure_summary` populated
|
||||||
|
- When the failed external create was caused by timeout, `external_handoff_failure_summary` stores the same bounded timeout-oriented message that the UI and audit path use. Raw transport detail is never persisted.
|
||||||
|
|
||||||
|
**Latest-summary query rules**:
|
||||||
|
- Tenant dashboard summary queries the latest support request for the current entitled tenant where `primary_context_type = tenant`.
|
||||||
|
- Operation-run summary queries the latest support request for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run.
|
||||||
|
- Existing indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient. No new lookup path by external reference is planned.
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `external_handoff_mode` must be one of the three allowed values.
|
||||||
|
- `external_ticket_reference` is required when `external_handoff_mode = link_existing_ticket`.
|
||||||
|
- `external_ticket_url` is optional but must be a valid URL when present.
|
||||||
|
- When no external target is configured for the application, the form must force or constrain the effective mode to `internal_only`.
|
||||||
|
|
||||||
|
## Application-Configured External Target (Config Contract In Scope, Not New Persisted Truth)
|
||||||
|
|
||||||
|
### External Support Desk Target
|
||||||
|
|
||||||
|
**Purpose**: Supplies the one configured outbound target for create or link normalization.
|
||||||
|
|
||||||
|
**Status in Spec 256**:
|
||||||
|
- minimal application config contract in scope
|
||||||
|
- not a new persisted entity in this slice
|
||||||
|
- not a workspace settings domain or UI surface in this slice
|
||||||
|
|
||||||
|
**Repo-grounded note**:
|
||||||
|
- The repo has no existing `support` settings domain, so Spec 256 makes the target seam explicit through one application config file: `apps/platform/config/support_desk.php` with environment-backed values for the single supported target.
|
||||||
|
- This config contract may define availability, create endpoint settings, reference-link normalization defaults, and the five-second outbound timeout budget.
|
||||||
|
- Per-workspace target selection, settings UI, or a second target remain follow-up scope.
|
||||||
|
|
||||||
|
## Derived Runtime Entities
|
||||||
|
|
||||||
|
### SupportRequestHandoffOutcome (computed, not persisted)
|
||||||
|
|
||||||
|
**Purpose**: Gives the Filament page actions one normalized outcome for notification copy and tests after submission completes.
|
||||||
|
|
||||||
|
**Expected shape**:
|
||||||
|
- `support_request_id`
|
||||||
|
- `internal_reference`
|
||||||
|
- `primary_context_type`
|
||||||
|
- `handoff_mode`
|
||||||
|
- `handoff_outcome`
|
||||||
|
- `internal_only`
|
||||||
|
- `external_ticket_created`
|
||||||
|
- `external_ticket_linked`
|
||||||
|
- `external_handoff_failed`
|
||||||
|
- `external_ticket_reference`
|
||||||
|
- `external_ticket_url`
|
||||||
|
- `failure_summary`
|
||||||
|
|
||||||
|
**Why derived only**:
|
||||||
|
- The outcome is an execution summary for one request cycle.
|
||||||
|
- Persisting it separately would duplicate the support-request truth and audit log.
|
||||||
|
- The bounded synchronous finalization write on `SupportRequest` remains the only allowed post-create mutation for this slice.
|
||||||
|
|
||||||
|
### LatestSupportRequestHandoffSummary (computed, not persisted)
|
||||||
|
|
||||||
|
**Purpose**: Supplies the existing tenant and run support actions with one scoped summary of the latest linkage for the current primary context.
|
||||||
|
|
||||||
|
**Expected shape**:
|
||||||
|
- `internal_reference`
|
||||||
|
- `primary_context_type`
|
||||||
|
- `primary_context_id`
|
||||||
|
- `submitted_at`
|
||||||
|
- `external_handoff_mode`
|
||||||
|
- `external_ticket_reference`
|
||||||
|
- `external_ticket_url`
|
||||||
|
- `external_handoff_failure_summary`
|
||||||
|
- `has_external_link`
|
||||||
|
- `has_failure`
|
||||||
|
|
||||||
|
**Why derived only**:
|
||||||
|
- It is a read model over the latest `support_requests` row for one context.
|
||||||
|
- A separate table or persisted summary would violate `PERSIST-001` without solving a distinct lifecycle problem.
|
||||||
|
|
||||||
|
## Audit Events (Persistent Audit Truth, Not Product Truth)
|
||||||
|
|
||||||
|
The implementation should add these stable audit actions in addition to the existing `support_request.created` event:
|
||||||
|
|
||||||
|
- `support_request.external_ticket_created`
|
||||||
|
- `support_request.external_ticket_linked`
|
||||||
|
- `support_request.external_handoff_failed`
|
||||||
|
|
||||||
|
**Audit context should include**:
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `internal_reference`
|
||||||
|
- `primary_context_type`
|
||||||
|
- `primary_context_id`
|
||||||
|
- `external_handoff_mode`
|
||||||
|
- `external_ticket_reference` when present
|
||||||
|
|
||||||
|
**Audit context should not include**:
|
||||||
|
- raw provider request payloads
|
||||||
|
- secrets or credentials
|
||||||
|
- unrestricted provider response bodies
|
||||||
319
specs/256-external-support-desk-handoff/plan.md
Normal file
319
specs/256-external-support-desk-handoff/plan.md
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
# Implementation Plan: External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
**Branch**: `256-external-support-desk-handoff` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Extend the existing support-request submission flow so the two current support-aware surfaces can keep a request internal-only, create one external desk ticket, or link one existing external ticket without adding a new support product surface.
|
||||||
|
- Persist only the minimal neutral linkage truth on the existing `support_requests` row: `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`.
|
||||||
|
- Keep the flow synchronous and auditable inside the existing support-request path: create the internal `SR-...` request first, allow exactly one bounded synchronous finalization write for external create, link, or failure fields on the same row, enforce a five-second outbound timeout on the create path, and surface the latest linkage summary only in the current tenant or run support context.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 on Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `SupportRequestReferenceGenerator`, `WorkspaceAuditLogger`, `UiEnforcement`, and `CapabilityResolver`
|
||||||
|
**Storage**: PostgreSQL; extend the tenant-owned `support_requests` table, keep `workspace_id` and `tenant_id` required, and do not add a second support-ticket table
|
||||||
|
**Testing**: Pest unit + feature tests
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}`
|
||||||
|
**Project Type**: web
|
||||||
|
**Performance Goals**: keep the submit path synchronous, apply a maximum five-second outbound timeout on the create path, and avoid queue or `OperationRun` overhead
|
||||||
|
**Constraints**: one application-configured external target only, no new support-request resource/list/detail page, no global-search surface, no bidirectional sync, no retry scheduler, no raw provider payload persistence, no provider registration changes, and no runtime asset changes
|
||||||
|
**Scale/Scope**: one additive migration on `support_requests`, one concrete provider-owned handoff service, one small derived latest-summary helper or equivalent shared read path, two Filament action-form extensions, audit additions, and focused unit plus feature coverage only
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Persistence and source of truth
|
||||||
|
|
||||||
|
- `support_requests` is the only persisted truth for this slice. No `support_tickets` table, no queue artifact, and no new support page model is justified.
|
||||||
|
- The plan adds these columns to `support_requests`:
|
||||||
|
- `external_handoff_mode` as a non-null string with default `internal_only`
|
||||||
|
- `external_ticket_reference` as a nullable string
|
||||||
|
- `external_ticket_url` as a nullable text field
|
||||||
|
- `external_handoff_failure_summary` as a nullable text field
|
||||||
|
- The plan does not add `external_handoff_status`, `external_target_type`, `external_target_id`, raw payload JSON, or a dedicated failure timestamp. Those are not needed for the current operator contract because:
|
||||||
|
- the handoff mode already captures operator intent
|
||||||
|
- success is derivable from `external_ticket_reference`
|
||||||
|
- failure visibility only needs a bounded summary on revisit
|
||||||
|
- audit timestamps already provide exact event timing
|
||||||
|
- Spec 256 explicitly narrows Spec 246 immutability in one bounded way: after the internal request row exists, the same row may receive exactly one synchronous finalization write limited to the external handoff fields above. After that finalization step, the row is immutable again.
|
||||||
|
- Existing `support_requests` indexes on `(tenant_id, created_at)` and `(operation_run_id, created_at)` are sufficient for latest-summary lookups. No external-reference index is planned because cross-scope lookup by external ticket reference is explicitly out of scope.
|
||||||
|
|
||||||
|
### Failure truth and auditable outcomes
|
||||||
|
|
||||||
|
- External create failure is not audit-only. A bounded failure summary must be persisted back on the same `support_requests` row so the current support context can show the last failure on revisit.
|
||||||
|
- Timeout is treated as the same failure family as any other create failure. The provider-owned service must enforce the five-second outbound timeout budget and return a normalized bounded failure summary rather than raw transport details.
|
||||||
|
- Detailed provider-specific error payloads remain out of persisted product truth. They stay confined to the provider-owned handoff service, log redaction rules, and audit metadata where appropriate.
|
||||||
|
- The internal support request remains durable even when external create fails. The implementation must therefore split the flow into:
|
||||||
|
1. authorize and validate the existing request
|
||||||
|
2. persist the internal support request and `support_request.created` audit event
|
||||||
|
3. perform link or create handoff work after the internal row exists
|
||||||
|
4. perform the one allowed synchronous finalization write back to the same row and emit the corresponding audit event
|
||||||
|
|
||||||
|
### Visible linkage stays inside existing support contexts only
|
||||||
|
|
||||||
|
- External ticket references do not become a new dashboard card, run section, support history block, global search result, or Filament resource.
|
||||||
|
- The narrowest correct visibility path is:
|
||||||
|
- success or partial-success notification immediately after submit
|
||||||
|
- a latest-handoff summary placeholder inside the existing `Request support` slide-over on `TenantDashboard`
|
||||||
|
- the same latest-handoff summary placeholder inside the grouped `Request support` slide-over on `TenantlessOperationRunViewer`
|
||||||
|
- Tenant context summary scopes to the latest support request where `primary_context_type = tenant` for the current entitled tenant.
|
||||||
|
- Run context summary scopes to the latest support request where `primary_context_type = operation_run` and `operation_run_id` matches the currently opened run.
|
||||||
|
|
||||||
|
### Minimal application config contract is in scope; support settings UI is not
|
||||||
|
|
||||||
|
- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation drift.
|
||||||
|
- This plan therefore brings one minimal application config contract into scope: `apps/platform/config/support_desk.php` backed by environment values for the single supported target.
|
||||||
|
- The implementation may resolve availability, create endpoint configuration, and timeout settings from that config file only.
|
||||||
|
- This spec still forbids workspace settings UI, a new settings domain, per-workspace target management, provider-connection product work, or multi-target support.
|
||||||
|
|
||||||
|
### Timeout and latency rule
|
||||||
|
|
||||||
|
- The one application-configured create path must use a maximum five-second outbound timeout.
|
||||||
|
- A timeout is normalized into the same bounded failure-summary and audit path as any other external create failure.
|
||||||
|
- The timeout budget is part of the feature contract and must be covered by the handoff-service unit tests.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament actions plus shared support primitives
|
||||||
|
- **Shared-family relevance**: header actions, grouped detail actions, support-request slide-overs, success or warning notifications, latest-handoff summaries, and external-link navigation
|
||||||
|
- **State layers in scope**: page, detail, action form
|
||||||
|
- **Audience modes in scope**: operator-MSP, support-platform
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: decision-first support form, diagnostics-second through the existing neighboring diagnostics action, provider/raw evidence third and hidden
|
||||||
|
- **Raw/support gating plan**: provider-specific payloads, secrets, and raw responses stay provider-owned and hidden; only bounded human-readable linkage or failure summary becomes default-visible
|
||||||
|
- **One-primary-action / duplicate-truth control**: the dominant action remains `Submit support request`; handoff choice is a form field, not a second primary action, and the visible summary names one internal support reference so the surface does not become a history register
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract, manual smoke after implementation
|
||||||
|
- **Exception path and spread control**: the tenant dashboard keeps its existing bounded action-surface exception; the run viewer keeps both support actions grouped under `More` and does not add a new top-level support action family
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**:
|
||||||
|
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||||
|
- `apps/platform/app/Models/SupportRequest.php`
|
||||||
|
- `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`
|
||||||
|
- `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`
|
||||||
|
- `apps/platform/app/Support/SupportRequests/SupportRequestReferenceGenerator.php`
|
||||||
|
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||||
|
- `apps/platform/app/Support/Audit/AuditActionId.php`
|
||||||
|
- `apps/platform/database/factories/SupportRequestFactory.php`
|
||||||
|
- `apps/platform/config/support_desk.php`
|
||||||
|
- `apps/platform/lang/en/localization.php`
|
||||||
|
- `apps/platform/lang/de/localization.php`
|
||||||
|
- **Shared abstractions reused**: existing support-request submission path, existing redacted context builder, existing internal reference generator, existing audit logger, and existing `UiEnforcement` capability gating
|
||||||
|
- **New abstraction introduced? why?**: one concrete provider-owned external handoff service is justified because both existing surfaces must call or normalize one real external target without page-local HTTP logic; one tiny shared latest-summary read helper is allowed if needed to avoid duplicating the same context-scoped query and copy twice
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: the existing abstractions already solve context capture, internal request creation, and audit logging, but they stop at internal persistence and cannot yet persist external linkage or explicit handoff failure truth
|
||||||
|
- **Bounded deviation / spread control**: no interface registry, no adapter catalog, no support-desk framework, no second persistence model, and no new support history vocabulary
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Central contract reused**: N/A
|
||||||
|
- **Delegated UX behaviors**: N/A
|
||||||
|
- **Surface-owned behavior kept local**: the run viewer uses the current run only as support context and as the scoping key for its latest-handoff summary; it does not create, resume, or link an `OperationRun`
|
||||||
|
- **Queued DB-notification policy**: N/A
|
||||||
|
- **Terminal notification path**: N/A
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Provider-owned seams**: outbound create payload, authentication, target-specific reference normalization, URL normalization, and remote error parsing
|
||||||
|
- **Platform-core seams**: `SupportRequest`, internal support reference, external ticket reference and URL, handoff mode, latest-handoff summary, and bounded failure summary
|
||||||
|
- **Neutral platform terms / contracts preserved**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and `TenantPilot only` versus `TenantPilot + external support desk`
|
||||||
|
- **Retained provider-specific semantics and why**: provider-specific ticket identifiers, auth requirements, and request payload shape remain inside one concrete provider-owned service because the current release has exactly one real external target
|
||||||
|
- **Bounded extraction or follow-up path**: multi-provider support, target-management UI, and broader ITSM modeling remain follow-up-spec work only
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed against repo truth before artifact write. Re-checked after Phase 1 design artifacts were drafted.*
|
||||||
|
|
||||||
|
- Inventory-first / snapshots-second: PASS. The slice does not alter inventory or snapshot truth.
|
||||||
|
- Read/write separation: PASS. The mutation remains an explicit operator submit action with auditable outcomes and planned tests.
|
||||||
|
- Graph contract path: PASS. No Microsoft Graph calls are introduced.
|
||||||
|
- Deterministic capabilities: PASS. Capability checks stay on `Capabilities::SUPPORT_REQUESTS_CREATE`; no raw capability strings or role-string checks are planned.
|
||||||
|
- RBAC-UX / workspace isolation / tenant isolation: PASS. Non-members or actors outside workspace or tenant scope remain `404`; in-scope members missing the capability remain `403`; latest-handoff visibility uses the same boundary as submit.
|
||||||
|
- Global search safety: PASS. No new Filament resource or globally searchable surface is introduced.
|
||||||
|
- Run observability / Ops UX: PASS. The slice is intentionally synchronous and does not add queue work or `OperationRun` usage.
|
||||||
|
- Proportionality / `PROP-001`, `ABSTR-001`, `PERSIST-001`, `STATE-001`, `BLOAT-001`: PASS. The only new persisted truth is four bounded columns on an existing canonical row, one small handoff mode family, and one concrete provider-owned service for one real target.
|
||||||
|
- Shared pattern reuse / `XCUT-001`: PASS. The plan extends the existing support-request service and existing support-aware action surfaces instead of creating page-local handoff logic.
|
||||||
|
- Provider boundary / `PROV-001`: PASS. Provider semantics stay confined to the concrete handoff service; platform truth remains neutral.
|
||||||
|
- Filament-native UI / `UI-FIL-001`: PASS. The flow stays inside native Filament action forms and notifications.
|
||||||
|
- Livewire v4 / Filament v5 compliance: PASS. The plan stays on the current Filament v5 and Livewire v4 stack.
|
||||||
|
- Provider registration location: PASS. No provider registration changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||||
|
- Destructive action confirmation: PASS. No destructive action is added, so no new `->requiresConfirmation()` path is introduced.
|
||||||
|
- Asset strategy: PASS. No new panel or shared assets are required; deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||||
|
- Test governance / `TEST-GOV-001`: PASS. Proof remains in focused unit plus feature lanes, with manual smoke only as implementation close-out.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Unit for handoff branching, target-unavailable fallback, provider normalization, and latest-summary derivation; Feature for tenant and run action behavior, authorization boundaries, persisted linkage truth, partial-success feedback, and audit events
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the feature is server-driven and synchronous; business truth lives in the submission service, persistence, and authorization boundaries, so browser automation would mostly duplicate what Pest can already prove through Livewire and domain tests
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: reuse existing workspace, tenant, operation run, user membership, and support-request fixtures; add only a small fake for the one external target and a narrow latest-summary assertion helper if needed
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native-filament relief applies on the tenant dashboard action; the run viewer remains under its monitoring-state-page contract and needs the same tenant-entitlement checks as the current support action
|
||||||
|
- **Closing validation and reviewer handoff**: re-run the exact unit and feature commands above, then manually smoke create, link, and failure handling from both existing surfaces; reviewers should explicitly verify that no support-request resource, queue, settings UI, or global-search behavior was added
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
||||||
|
- **Review-stop questions**: did implementation add a new support table, a support-request resource, a support settings UI, a multi-provider registry, or queue or `OperationRun` behavior that the spec forbids?
|
||||||
|
- **Escalation path**: reject-or-split if target-configuration management, multi-provider support, or retry orchestration appears during implementation
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the delivery cost stays local to the existing support-request path; broader configuration or multi-provider expansion is separate work, not latent scope inside this slice
|
||||||
|
|
||||||
|
## Implementation Close-Out — Guardrail / Exception / Smoke Coverage
|
||||||
|
|
||||||
|
- **Guardrail outcome**: PASS. The implementation extends only the existing tenant-dashboard and operation-run `Request support` actions, keeps the run support action grouped under `More`, and does not add a support-request resource, support queue, global-search surface, target-management UI, provider registry, or new `OperationRun` behavior.
|
||||||
|
- **Finalization exception outcome**: PASS. The only post-create mutation on `support_requests` is the Spec 256 bounded finalization write to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`; invalid linked-ticket input is rejected before the internal support request is created.
|
||||||
|
- **Smoke coverage outcome**: PASS. A temporary Pest Browser smoke harness loaded the tenant dashboard and run detail, submitted tenant `create_external_ticket`, submitted run `link_existing_ticket`, forced run create failure, reopened the run support action to verify the latest failure summary, and asserted no browser console or JavaScript errors. The temporary browser test was removed after execution so the permanent coverage remains the planned unit plus feature lanes.
|
||||||
|
- **Follow-up decision**: No in-scope follow-up spec is required. Target-management UI, retry/relink workflows, and multi-provider support remain explicit future-spec candidates only if product pressure proves them necessary.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/256-external-support-desk-handoff/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── external-support-desk-handoff.logical.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ ├── Operations/
|
||||||
|
│ │ │ └── TenantlessOperationRunViewer.php
|
||||||
|
│ │ └── TenantDashboard.php
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── SupportRequest.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ └── Audit/
|
||||||
|
│ │ └── WorkspaceAuditLogger.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── Audit/
|
||||||
|
│ │ └── AuditActionId.php
|
||||||
|
│ └── SupportRequests/
|
||||||
|
│ ├── SupportRequestContextBuilder.php
|
||||||
|
│ ├── SupportRequestReferenceGenerator.php
|
||||||
|
│ ├── SupportRequestSubmissionService.php
|
||||||
|
│ └── ExternalSupportDeskHandoffService.php
|
||||||
|
├── config/
|
||||||
|
│ └── support_desk.php
|
||||||
|
├── database/
|
||||||
|
│ ├── factories/
|
||||||
|
│ │ └── SupportRequestFactory.php
|
||||||
|
│ └── migrations/
|
||||||
|
│ └── *_add_external_handoff_fields_to_support_requests_table.php
|
||||||
|
├── lang/
|
||||||
|
│ ├── de/
|
||||||
|
│ │ └── localization.php
|
||||||
|
│ └── en/
|
||||||
|
│ └── localization.php
|
||||||
|
└── tests/
|
||||||
|
├── Feature/SupportRequests/
|
||||||
|
│ ├── OperationRunSupportRequestExternalHandoffTest.php
|
||||||
|
│ ├── SupportRequestExternalHandoffAuditTest.php
|
||||||
|
│ ├── SupportRequestExternalHandoffAuthorizationTest.php
|
||||||
|
│ └── TenantSupportRequestExternalHandoffTest.php
|
||||||
|
└── Unit/Support/SupportRequests/
|
||||||
|
├── ExternalSupportDeskHandoffServiceTest.php
|
||||||
|
└── SupportRequestLatestHandoffSummaryTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single Laravel application. The slice extends the existing support-request domain and two existing Filament pages only. One minimal application config contract in `config/support_desk.php` is in scope so target resolution is explicit, while workspace settings UI and a support settings domain remain out of scope. The constitution-mandated checklist in `checklists/requirements.md` stays part of the implementation handoff set.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation / review item | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| Extend `support_requests` with four external-handoff columns | The operator must be able to revisit the current support context and still see the same external linkage or failure truth on the canonical support request | A separate `support_tickets` table would create a second lifecycle and a new surface the current slice does not need |
|
||||||
|
| Add one concrete provider-owned handoff service | One real external target must be called or normalized from both existing support-aware surfaces without page-local HTTP logic | A generic interface, registry, or multi-provider adapter catalog would be premature because the repo has exactly one current-release target case |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: the product can already create an internal support request with redacted context, but operators still have to create or paste an external desk ticket manually outside TenantPilot and then remember that linkage separately
|
||||||
|
- **Existing structure is insufficient because**: the current service ends at internal persistence and cannot carry durable external linkage or explicit failure truth back into the current support context
|
||||||
|
- **Narrowest correct implementation**: extend the existing `SupportRequest` row with minimal neutral linkage fields, route create or link decisions through the existing submission service, and render the latest linkage only inside the same two support-aware actions
|
||||||
|
- **Ownership cost created**: one additive migration, one concrete provider-owned service, a few audit IDs and audit-logger methods, modest action-form growth on two pages, and focused tests
|
||||||
|
- **Alternative intentionally rejected**: a new support-ticket model, support-request resource or detail page, target-management UI, provider registry, background retry path, or `OperationRun` delivery orchestration were all rejected as broader than current-release truth
|
||||||
|
- **Release truth**: current-release support follow-through and commercialization gap, not future ITSM platform preparation
|
||||||
|
|
||||||
|
## Implementation Outline
|
||||||
|
|
||||||
|
### 1. Support request persistence extension
|
||||||
|
|
||||||
|
- Add the four external-handoff columns to `support_requests`.
|
||||||
|
- Default `external_handoff_mode` to `internal_only` so existing rows remain truthful without compatibility shims.
|
||||||
|
- Keep the internal `SR-...` reference canonical for every request.
|
||||||
|
|
||||||
|
### 2. Submission service orchestration
|
||||||
|
|
||||||
|
- Continue to authorize and validate through the current `SupportRequestSubmissionService` path.
|
||||||
|
- Persist the internal support request first and keep `WorkspaceAuditLogger::logSupportRequestCreated(...)` unchanged for that stage.
|
||||||
|
- Branch by handoff mode after the internal row exists:
|
||||||
|
- `internal_only`: return immediately with no external fields populated
|
||||||
|
- `link_existing_ticket`: validate and normalize the provided reference or URL locally, persist linkage, and audit `linked`
|
||||||
|
- `create_external_ticket`: call one concrete provider-owned handoff service outside the initial DB transaction with the five-second timeout budget, then perform the one allowed synchronous finalization write back to the same row and audit the outcome
|
||||||
|
|
||||||
|
### 3. Latest-summary derivation
|
||||||
|
|
||||||
|
- Add one shared read path for the latest handoff summary per primary context.
|
||||||
|
- Tenant summary queries the latest `support_requests` row for the current tenant where `primary_context_type = tenant`.
|
||||||
|
- Run summary queries the latest `support_requests` row for the current run where `primary_context_type = operation_run` and `operation_run_id` matches the viewed run.
|
||||||
|
- The visible summary always includes the internal support reference it belongs to.
|
||||||
|
|
||||||
|
### 4. Filament surface extension
|
||||||
|
|
||||||
|
- Extend the existing `Request support` action on both pages with:
|
||||||
|
- mutation-scope guidance (`TenantPilot only` versus `TenantPilot + external support desk`)
|
||||||
|
- handoff mode choice
|
||||||
|
- conditional external reference and URL inputs for `link_existing_ticket`
|
||||||
|
- a read-only latest-handoff summary placeholder scoped to the current context
|
||||||
|
- Keep `Open support diagnostics` unchanged as the diagnostics-secondary affordance.
|
||||||
|
- Success notifications include the internal reference and, when present, the external reference.
|
||||||
|
- External create failure uses explicit partial-success or warning feedback: internal request created, external handoff failed.
|
||||||
|
|
||||||
|
### 5. Audit and copy consistency
|
||||||
|
|
||||||
|
- Add stable audit action IDs for:
|
||||||
|
- external ticket created
|
||||||
|
- external ticket linked
|
||||||
|
- external handoff failed
|
||||||
|
- Keep audit context bounded to workspace, tenant, internal support reference, primary context, handoff mode, and external ticket reference when present.
|
||||||
|
- Preserve neutral UI copy and do not surface provider product names as the primary operator vocabulary.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
1. **Foundation**: add the migration shape, model casts and constants, audit IDs, the concrete handoff service contract for one target, and the minimal `config/support_desk.php` contract.
|
||||||
|
2. **Submission flow**: refactor `SupportRequestSubmissionService` so internal creation commits first, then link or create outcome persists back to the same row.
|
||||||
|
3. **Surface wiring**: extend the tenant dashboard and run viewer forms with handoff mode, latest-summary placeholder, and outcome-sensitive notification copy.
|
||||||
|
4. **Hardening**: add latest-summary derivation, target-unavailable fallback to `internal_only`, authorization proof, and audit proof.
|
||||||
|
|
||||||
|
## Guardrail Close-Out Expectations
|
||||||
|
|
||||||
|
- Livewire v4 compatibility remains unchanged because the flow stays inside existing Filament v5 page actions.
|
||||||
|
- Laravel 12 provider registration facts remain unchanged: panel providers stay in `bootstrap/providers.php`.
|
||||||
|
- No globally searchable resource is added, so there is no new global-search contract to satisfy.
|
||||||
|
- No destructive action is introduced, so there is no new confirmation flow requirement.
|
||||||
|
- No new assets are required; `cd apps/platform && php artisan filament:assets` stays part of the general deployment path but does not change for this feature.
|
||||||
48
specs/256-external-support-desk-handoff/quickstart.md
Normal file
48
specs/256-external-support-desk-handoff/quickstart.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Quickstart — External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
- Docker is running.
|
||||||
|
- Laravel Sail dependencies are installed.
|
||||||
|
- The support-request foundation from Spec 246 is already present in the workspace.
|
||||||
|
- One application-configured external support desk target is available through `apps/platform/config/support_desk.php`, or a fake target is available for implementation tests.
|
||||||
|
|
||||||
|
## Run locally after implementation
|
||||||
|
|
||||||
|
- Start containers:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Run targeted unit proof:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`
|
||||||
|
- Run targeted feature proof:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`
|
||||||
|
- Format after implementation:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## Manual smoke after implementation
|
||||||
|
|
||||||
|
1. Sign in to `/admin` as a workspace member with tenant entitlement and `support_requests.create` capability.
|
||||||
|
2. Open one tenant at `/admin/t/{tenant}` and trigger `Request support`.
|
||||||
|
3. Verify the action shows the existing context summary plus the new handoff mode controls. If no external target is configured in `config/support_desk.php`, verify the action clearly stays in `internal_only` mode.
|
||||||
|
4. Submit the tenant-context flow with `create_external_ticket` and verify the success notification includes the internal support reference plus the created external ticket reference.
|
||||||
|
5. Reopen the tenant-context action and verify the latest-handoff summary names the same internal support reference and external ticket reference.
|
||||||
|
6. Submit the tenant-context flow with `link_existing_ticket` and verify the stored summary shows the linked external reference without issuing a create call.
|
||||||
|
7. Force the external create path to fail, including the five-second timeout path, and verify the action returns explicit partial-success or warning feedback, the internal support request still exists, and the latest-handoff summary shows the persisted failure summary.
|
||||||
|
8. Open one canonical run detail page at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and repeat create, link, and failure checks there.
|
||||||
|
9. Verify a non-member or non-entitled actor receives `404`, while an in-scope member without `support_requests.create` sees the action disabled and receives `403` on execution.
|
||||||
|
10. Verify no new support-request resource, support queue, global-search result, or `OperationRun` side effect appears in this slice.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays inside native Filament page actions.
|
||||||
|
- No provider registration change is part of this slice; Laravel 12 panel providers remain registered in `bootstrap/providers.php`.
|
||||||
|
- No globally searchable resource is added, so there is no new global-search contract to satisfy.
|
||||||
|
- No destructive action is introduced, so `->requiresConfirmation()` is not newly involved here.
|
||||||
|
- No asset strategy changes are required. The general deploy step `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||||
|
|
||||||
|
## Implementation Close-Out Expectations
|
||||||
|
|
||||||
|
- The targeted unit and feature commands above pass.
|
||||||
|
- Manual smoke proves create, link, and explicit failure handling from both existing support-aware surfaces.
|
||||||
|
- Audit review shows `support_request.created`, `support_request.external_ticket_created`, `support_request.external_ticket_linked`, and `support_request.external_handoff_failed` events with the expected bounded metadata.
|
||||||
|
- Internal-only support-request submission still works when the external target is unavailable or intentionally bypassed.
|
||||||
|
- No new support product surface appears beyond the two existing actions.
|
||||||
167
specs/256-external-support-desk-handoff/research.md
Normal file
167
specs/256-external-support-desk-handoff/research.md
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# Research — External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
**Date**: 2026-04-29
|
||||||
|
**Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
This document records the repo-grounded decisions that make the Spec 256 plan implementation-ready without expanding into a generic helpdesk product.
|
||||||
|
|
||||||
|
## Decision 1 — Extend `support_requests` instead of adding a second support-ticket truth
|
||||||
|
|
||||||
|
**Decision**: Keep `App\Models\SupportRequest` as the only persisted truth for this slice and add the external linkage fields directly to `support_requests`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The repo already has one canonical support-request model, migration, factory, and submission service.
|
||||||
|
- The operator workflow needs one durable record that still carries the internal `SR-...` reference after create, link, or failure.
|
||||||
|
- Constitution `PERSIST-001` and `PROP-001` reject a second lifecycle unless it solves a distinct product problem. Spec 256 does not need one.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing model: `apps/platform/app/Models/SupportRequest.php`
|
||||||
|
- Existing persistence: `apps/platform/database/migrations/2026_04_27_095518_create_support_requests_table.php`
|
||||||
|
- Existing write path: `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`
|
||||||
|
- Candidate scope: `docs/product/spec-candidates.md`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a new `SupportTicket` or `SupportRequestLink` model.
|
||||||
|
- Rejected: creates a second truth and encourages a support register or detail page the spec explicitly forbids.
|
||||||
|
- Keep external linkage entirely derived from audit logs.
|
||||||
|
- Rejected: the current support context must show the latest linkage on revisit, which audit-only storage cannot do safely or cheaply.
|
||||||
|
|
||||||
|
## Decision 2 — Persist a bounded failure summary on the same row; keep detailed provider failure out of product truth
|
||||||
|
|
||||||
|
**Decision**: Store `external_handoff_failure_summary` on `support_requests` and keep detailed provider payloads or raw errors out of persisted support-request truth.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The spec requires explicit, revisitable failure handling in the same support context.
|
||||||
|
- A purely audited failure would satisfy compliance but fail the operator need to reopen the action and see what happened.
|
||||||
|
- A bounded human-readable summary is enough for revisit. Provider-specific payloads remain provider-owned and redaction-sensitive.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing audit path is already separate from product truth: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||||
|
- Current support-request row has no external linkage or failure fields, so the revisit contract is impossible without row-level extension.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Audit failure only.
|
||||||
|
- Rejected: failure becomes invisible in the current support context.
|
||||||
|
- Persist raw provider response JSON.
|
||||||
|
- Rejected: violates the spec’s minimal neutral truth and increases leakage risk.
|
||||||
|
|
||||||
|
## Decision 3 — Keep the flow synchronous, preserve internal durability, and document the one bounded finalization write
|
||||||
|
|
||||||
|
**Decision**: Preserve the existing synchronous submit flow, move any external create call outside the current internal-request creation transaction, enforce a five-second outbound timeout, and explicitly allow one bounded post-create finalization write on the same `SupportRequest` row.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The current service wraps internal create plus audit in a transaction.
|
||||||
|
- Spec 256 explicitly requires the internal support request to survive external create failure.
|
||||||
|
- Spec 246 declared the row immutable after creation, so Spec 256 must make its one bounded finalization exception explicit instead of mutating the row silently.
|
||||||
|
- Holding a database transaction open across remote HTTP is unnecessary and increases latency and failure risk.
|
||||||
|
- A hard timeout budget is needed so the operator-visible submit path stays bounded and timeout behavior is testable.
|
||||||
|
- The repo truth does not require `OperationRun`, queueing, or retry scheduling for this slice.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing transaction structure in `SupportRequestSubmissionService`
|
||||||
|
- Existing synchronous page actions on `TenantDashboard` and `TenantlessOperationRunViewer`
|
||||||
|
- The spec’s explicit non-goal for queues, retries, and `OperationRun`
|
||||||
|
- Spec 246 FR-246-011 immutability contract
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Perform external HTTP inside the current DB transaction.
|
||||||
|
- Rejected: risks long transactions and makes internal request durability harder to guarantee.
|
||||||
|
- Introduce queue work or `OperationRun`.
|
||||||
|
- Rejected: broader than current-release truth and not required for one synchronous target.
|
||||||
|
- Keep Spec 246 immutability unchanged and infer final handoff state only from audit.
|
||||||
|
- Rejected: the current support context must show revisitable success or failure on the canonical `SupportRequest`, so the one bounded finalization write has to be explicit.
|
||||||
|
|
||||||
|
## Decision 4 — Keep external linkage visibility inside the existing support request actions only
|
||||||
|
|
||||||
|
**Decision**: Show the latest linkage summary inside the existing `Request support` slide-overs and in submit feedback. Do not add a new support page, dashboard card, or run-detail history section.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The spec says visibility must stay attached to the existing tenant and run support contexts.
|
||||||
|
- The acceptance criteria require reopening the action and seeing the latest linkage summary for that same context.
|
||||||
|
- A broader always-visible history surface would deepen support-product scope and duplicate truth.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing support-aware surfaces: `apps/platform/app/Filament/Pages/TenantDashboard.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||||
|
- No existing `SupportRequest` resource, list, or detail page exists in the repo today.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a support-request resource or detail page.
|
||||||
|
- Rejected: explicitly out of scope.
|
||||||
|
- Add a new page-level widget or card for support linkage.
|
||||||
|
- Rejected: broader than the acceptance requirement and would create duplicate visible truth.
|
||||||
|
|
||||||
|
## Decision 5 — Add one minimal application config contract; do not hide target resolution behind an undefined prerequisite
|
||||||
|
|
||||||
|
**Decision**: Bring one minimal application config contract into scope through `apps/platform/config/support_desk.php` and environment-backed values for the single supported target. Do not add workspace settings UI, a support settings domain, or provider-connection product work.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The repo has no existing `support` settings domain, so leaving target resolution as an external prerequisite would create hidden implementation work.
|
||||||
|
- The product contract only needs one target for v1, so an application config contract is the narrowest explicit source of truth.
|
||||||
|
- Pulling workspace administration into Spec 256 would still expand scope from handoff to setup and administration.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing repo truth has no support-target config seam yet, so a new app config file is the explicit minimal source of truth for one target.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a new `support` settings domain and UI in the same spec.
|
||||||
|
- Rejected: becomes a second feature slice.
|
||||||
|
- Reuse `ProviderConnection` as the support target model.
|
||||||
|
- Rejected: not justified by current repo truth for one external desk handoff target.
|
||||||
|
- Leave target resolution as an undefined prerequisite.
|
||||||
|
- Rejected: the tasks and plan already depend on a concrete resolution seam, so the config contract must be explicit inside the package.
|
||||||
|
|
||||||
|
## Decision 6 — Use one concrete provider-owned handoff service, not a registry or interface framework
|
||||||
|
|
||||||
|
**Decision**: Add one concrete provider-owned handoff service under the support-request path for the single real external target.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Both existing support surfaces need the same create-or-normalize behavior.
|
||||||
|
- Constitution `ABSTR-001` rejects a provider registry or interface framework before two real targets exist.
|
||||||
|
- Page-local HTTP logic would duplicate failure handling, normalization, and audit shape.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- One configured target only in the spec and roadmap candidate
|
||||||
|
- Existing shared write path already centralizes support-request submission across both surfaces
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a provider interface plus registry.
|
||||||
|
- Rejected: future-proofing without current-release variance.
|
||||||
|
- Duplicate HTTP logic inside both Filament pages.
|
||||||
|
- Rejected: immediate drift risk and weaker audit consistency.
|
||||||
|
|
||||||
|
## Decision 7 — Keep queries context-scoped and avoid new search or indexing semantics
|
||||||
|
|
||||||
|
**Decision**: Derive the latest visible linkage from the latest support request for the same primary context, using the existing context indexes. Do not add cross-scope lookup or search by external ticket reference.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Tenant summary and run summary have different scope rules in the spec.
|
||||||
|
- Existing indexes already support latest-by-tenant and latest-by-run queries.
|
||||||
|
- Cross-scope lookup by external reference is explicitly out of scope and would create a new leakage risk.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing indexes on `support_requests(tenant_id, created_at)` and `support_requests(operation_run_id, created_at)`
|
||||||
|
- Context scoping in `SupportRequest::PRIMARY_CONTEXT_TENANT` and `SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add an index and lookup flow for external ticket reference.
|
||||||
|
- Rejected: no current surface needs it, and it would conflict with the no-cross-scope-shortcuts rule.
|
||||||
|
|
||||||
|
## Decision 8 — Proof stays in Unit + Feature lanes with manual smoke only
|
||||||
|
|
||||||
|
**Decision**: Keep the proving strategy in focused Pest unit and feature suites, then use a narrow manual smoke path after implementation.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Business truth is server-side: branching, persistence, audit, and authorization.
|
||||||
|
- Existing support-request tests already cover the same two Filament entry surfaces.
|
||||||
|
- Browser coverage would mostly duplicate the existing action-form semantics.
|
||||||
|
|
||||||
|
**Evidence**:
|
||||||
|
- Existing test family:
|
||||||
|
- `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestActionTest.php`
|
||||||
|
- `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php`
|
||||||
|
- `apps/platform/tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php`
|
||||||
|
- `apps/platform/tests/Feature/SupportRequests/SupportRequestAuditTest.php`
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add browser tests in the first slice.
|
||||||
|
- Rejected: not required to prove the current business truth.
|
||||||
331
specs/256-external-support-desk-handoff/spec.md
Normal file
331
specs/256-external-support-desk-handoff/spec.md
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
# Feature Specification: External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
**Feature Branch**: `256-external-support-desk-handoff`
|
||||||
|
**Created**: 2026-04-29
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Prepare the next open candidate External Support Desk / PSA Handoff as the narrowest repo-grounded slice that extends the already-implemented in-app support request flow with one-way external ticket create or link behavior, stores the resulting external reference on the existing support-request truth, and keeps visibility on the existing tenant and operation-run support contexts only."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: TenantPilot already captures support requests with internal `SR-...` references, redacted context, and audit truth, but support follow-through still breaks at the product boundary because operators must create or paste external service-desk tickets manually outside the current workflow.
|
||||||
|
- **Today's failure**: A tenant or run-scoped support request can be submitted from the product, yet the product cannot tell the operator whether an external ticket was created, linked, or failed. That creates manual duplicate work, weakens audit continuity, and leaves no durable external-ticket linkage in the current support context.
|
||||||
|
- **User-visible improvement**: The existing `Request support` action can create a new external desk ticket or link an already-created ticket through one configured external desk target, then show the resulting external reference or explicit failure in the same tenant or run support context.
|
||||||
|
- **Smallest enterprise-capable version**: Extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` so one configured external support desk target can be used during the existing tenant-dashboard and operation-run support flows, persist the resulting external ticket reference or last handoff failure on the same support request, audit create or link outcomes, and surface the latest handoff summary only on those same support contexts.
|
||||||
|
- **Explicit non-goals**: No new support-request creation flow, no support-request resource/list/detail page, no support inbox or queue product, no generic helpdesk framework, no multi-provider adapter registry, no bidirectional sync, no external ticket status polling, no SLA engine, no retry scheduler, no AI support automation, and no cross-workspace or cross-tenant handoff shortcuts.
|
||||||
|
- **Permanent complexity imported**: One bounded provider-owned handoff adapter for a single configured external target, a small external-handoff mode family on the existing support-request truth, a nullable persisted external ticket reference and URL on `support_requests`, a bounded persisted handoff failure summary, targeted audit action IDs, and focused unit plus feature coverage.
|
||||||
|
- **Why now**: `docs/product/spec-candidates.md` and `docs/product/roadmap.md` both confirm that support-request creation is already repo-real and that the remaining commercialization gap is external handoff plus visible ticket linkage, not another internal support intake feature.
|
||||||
|
- **Why not local**: Page-local ticket creation or a manual copy field on each surface would duplicate logic that already lives in `SupportRequestSubmissionService`, would drift audit and failure behavior between tenant and run contexts, and would still not create one durable support-request-to-external-ticket truth.
|
||||||
|
- **Approval class**: Workflow Compression
|
||||||
|
- **Red flags triggered**: New provider seam, new persisted fields on an existing truth model, and multi-surface action changes. Defense: the slice extends one existing model and one existing submission path, stays on two already-support-aware surfaces, and explicitly forbids a generic helpdesk or queue framework.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant, canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- existing tenant dashboard at `/admin/t/{tenant}` via `App\Filament\Pages\TenantDashboard`
|
||||||
|
- existing canonical operation detail at `/admin/operations/{run}` via `App\Filament\Pages\Operations\TenantlessOperationRunViewer`
|
||||||
|
- no new dedicated support desk or support-request route in v1
|
||||||
|
- **Data Ownership**:
|
||||||
|
- `support_requests` remains the canonical tenant-owned truth and continues to carry required `workspace_id` and `tenant_id`
|
||||||
|
- external handoff truth extends that same record only: external ticket reference, external ticket URL, handoff mode, and last handoff failure are stored on the existing support request rather than in a new ticket-link model or table
|
||||||
|
- one configured external support desk target is treated as application-configured integration truth and is referenced during handoff, but it is not mirrored into tenant-owned support-request records beyond the neutral external linkage fields needed for operator continuity and auditability
|
||||||
|
- **RBAC**:
|
||||||
|
- workspace membership and tenant entitlement remain the first isolation boundaries
|
||||||
|
- the existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability continues to gate support-request submission and any visible create-or-link external handoff controls
|
||||||
|
- non-members or actors not entitled to the workspace or tenant scope receive `404`
|
||||||
|
- members inside scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403`
|
||||||
|
- run-context handoff and any latest-handoff summary on the run page resolve the run's tenant first and must not reveal linkage state for a tenant the actor cannot access
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: `N/A` - the feature does not add a canonical collection page; the operation-run surface stays bound to the currently opened run only.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Any lookup used to show a latest external handoff summary on the operation-run support context must resolve through the current run's entitled tenant scope and the current workspace. Known internal support references or external ticket references must not bypass that scope check.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: header actions, contextual support capture, success and failure notifications, support-context summaries, audit events, and external-link navigation
|
||||||
|
- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\SupportRequests\SupportRequestSubmissionService`, `App\Support\SupportRequests\SupportRequestContextBuilder`, `App\Support\SupportRequests\SupportRequestReferenceGenerator`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Audit\AuditActionId`, `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`, and existing `UiEnforcement` capability gating on both support actions
|
||||||
|
- **Existing pattern(s) to extend**: the current `Request support` slide-over actions, current support-request success feedback, current support-diagnostics context summary, current audit logging path, and current tenant/run support authorization boundaries
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: `SupportRequestSubmissionService`, `SupportRequestContextBuilder`, `WorkspaceAuditLogger`, `UiEnforcement`, and the existing support-diagnostics bundle as the canonical redacted context source
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: The current shared path already assembles support-safe context, issues the internal `SR-...` reference, and writes audit truth consistently from both existing entry surfaces. It is insufficient only because it stops at internal persistence and cannot persist or surface external desk follow-through.
|
||||||
|
- **Allowed deviation and why**: One provider-owned external handoff adapter or service is allowed inside the support-request path because one configured external desk target must be called or normalized from both surfaces. No second page-local handoff client, no generic helpdesk registry, and no parallel support-summary vocabulary are allowed.
|
||||||
|
- **Consistency impact**: `Request support`, `Support reference`, `External ticket`, `Create external ticket`, `Link existing ticket`, and handoff failure wording must have the same meaning on tenant and run surfaces, in success or failure notifications, and in audit summaries.
|
||||||
|
- **Review focus**: Reviewers must block any page-local external desk payload builder, any second support-ticket persistence model, and any new support status language that duplicates the existing support-request truth instead of extending it.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: `N/A`
|
||||||
|
- **Delegated start/completion UX behaviors**: `N/A`
|
||||||
|
- **Local surface-owned behavior that remains**: The operation-run page continues to use the current run only as support context. External desk handoff must not create, resume, or otherwise mutate an `OperationRun`.
|
||||||
|
- **Queued DB-notification policy**: `N/A`
|
||||||
|
- **Terminal notification path**: `N/A`
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Boundary classification**: provider-owned
|
||||||
|
- **Seams affected**: outbound create request payloads, external ticket URL and reference normalization, external-target credential resolution, provider-specific response parsing, and provider-specific failure normalization
|
||||||
|
- **Neutral platform terms preserved or introduced**: support request, support reference, external ticket, external ticket reference, external ticket URL, external handoff mode, external handoff failure, and latest handoff summary
|
||||||
|
- **Provider-specific semantics retained and why**: Authentication, request payload shape, URL templates, provider-specific ticket IDs, and provider-specific validation rules remain inside the one configured external desk adapter because only one concrete target exists in the current release slice.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: The `SupportRequest` record stores only neutral linkage truth needed for operator continuity: the external reference, optional URL, selected handoff mode, and explicit last failure summary. It does not store provider-specific fields such as assignee, queue, SLA, raw payloads, or external status history.
|
||||||
|
- **Follow-up path**: `follow-up-spec` only if a second real external desk target exists or the first target proves that a provider-neutral shared boundary is genuinely needed.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard `Request support` action | yes | Native Filament action + shared support primitives | header actions, support capture, support diagnostics, success or failure notifications | page, action form, bounded latest-handoff summary | yes | Existing tenant-dashboard action-surface exception remains bounded; the feature extends the current slide-over instead of adding a support page |
|
||||||
|
| Operation run `Request support` action | yes | Native Filament action + shared support primitives | grouped detail actions, support capture, monitoring-state support context, success or failure notifications | detail page, action form, bounded latest-handoff summary | no | Extends an already support-aware run-detail action instead of adding a second run-support surface |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard `Request support` action | Secondary Context Surface | The operator decides that the current tenant issue needs external escalation or explicit desk linkage | current support summary, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper support diagnostics remain on the neighboring `Open support diagnostics` action | Secondary because the operator is still primarily troubleshooting the tenant, not working in a support-desk inbox | Follows current tenant troubleshooting and support-escalation flow | Removes manual copy-paste and out-of-band ticket bookkeeping from the tenant troubleshooting path |
|
||||||
|
| Operation run `Request support` action | Secondary Context Surface | The operator decides that the current run already contains enough context to hand off or link to an external desk | run identity, external handoff mode choice, latest external handoff summary when one exists, and what submitting will write | deeper run diagnostics remain on the existing support-diagnostics action and run detail sections | Secondary because the operator is still primarily inspecting one run | Follows current run drill-in workflow | Removes the need to recreate the same run context in an external desk after the operator has already drilled into it |
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard `Request support` action | operator-MSP, support-platform | support summary, selected mutation scope, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted support diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; any provider-specific fields stay inside the adapter and never appear as default-visible operator content | the slide-over states the current handoff truth once and links it to a specific internal support reference instead of duplicating support-request history blocks |
|
||||||
|
| Operation run `Request support` action | operator-MSP, support-platform | run identity, mutation scope note, handoff mode choice, latest external ticket reference or last failure, and required form fields | redacted run diagnostics remain secondary and separately opened | raw provider payloads, external desk raw payloads, and provider-specific response bodies stay hidden | `Submit support request` | diagnostics remain capability-gated; run detail stays the primary evidence surface | the slide-over shows only the latest bounded linkage summary for this run context instead of becoming a support-request register |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard `Request support` action | Dashboard / Overview / Actions | Tenant support escalation entry point | submit the support request with one chosen external handoff mode | explicit header action opens the existing slide-over | forbidden | `Open support diagnostics` remains the neighboring secondary action; any external link stays inside the same support context summary | none | `/admin/t/{tenant}` | `/admin/t/{tenant}` | active workspace, active tenant, and current support context summary | Support request / External ticket | whether the next submit stays internal-only, creates an external ticket, or links an existing ticket | dashboard_exception - existing tenant dashboard action-surface exception remains bounded and justified by the dashboard's role as the tenant troubleshooting hub |
|
||||||
|
| Operation run `Request support` action | Record / Detail / Actions | Run-centered support escalation entry point | submit the support request with one chosen external handoff mode | explicit detail action in the existing grouped support actions | forbidden | `Open support diagnostics` remains grouped beside `Request support`; any external link stays inside the same support slide-over | none | `/admin/operations` | `/admin/operations/{run}` | workspace context, entitled tenant context, and current operation identifier | Support request / External ticket | whether the current run context already has an external ticket linkage or a visible last handoff failure | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard `Request support` action | Workspace manager or support-capable tenant operator | Decide whether this tenant issue should stay internal, create a new external ticket, or link an existing ticket | Dashboard action + contextual slide-over | How do I hand this tenant issue off without losing the current support-request truth? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and context attachment summary | full support diagnostics remain in the neighboring diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none |
|
||||||
|
| Operation run `Request support` action | Workspace manager or support-capable operator | Decide whether this run issue should stay internal, create a new external ticket, or link an existing ticket | Detail action + contextual slide-over | How do I hand this run issue off without recreating the case outside the product? | internal support reference after submit, chosen handoff mode, latest external ticket reference or failure, summary, contact defaults, and run-context attachment summary | full run diagnostics remain in the run detail and diagnostics surface | external handoff mode, external linkage presence, handoff failure presence, attachment completeness | TenantPilot only or TenantPilot + external support desk, depending on the selected handoff mode | Submit support request | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no - the feature extends the existing `SupportRequest` truth rather than introducing a second ticket-link model or support queue truth
|
||||||
|
- **New persisted entity/table/artifact?**: no new table; yes, the existing `support_requests` truth gains bounded external handoff fields needed for operator continuity and auditability
|
||||||
|
- **New abstraction?**: yes - one provider-owned external handoff adapter or service for the single configured target
|
||||||
|
- **New enum/state/reason family?**: yes - one small external handoff mode family (`create_external_ticket`, `link_existing_ticket`, `internal_only`) with direct operator and mutation consequences
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: support requests already exist, but the product still cannot show whether an external desk ticket exists or was attempted from the same support context
|
||||||
|
- **Existing structure is insufficient because**: the current submission service and UI end at internal persistence and cannot safely call or normalize an external desk, store the resulting reference, or keep failure truth visible for the current context
|
||||||
|
- **Narrowest correct implementation**: extend the current `SupportRequest` submission path and current tenant or run support actions only, add one provider-owned handoff adapter for one configured target, and store only the minimal linkage truth on the same support request
|
||||||
|
- **Ownership cost**: extra `support_requests` columns, one provider-owned handoff adapter or service, stable audit IDs for external handoff outcomes, slightly richer action forms, and focused unit plus feature coverage
|
||||||
|
- **Alternative intentionally rejected**: a new `SupportTicket` model, a support-request detail resource, or a generic multi-provider helpdesk framework was rejected because the repo currently has only one real external desk use case and already has the support-request truth needed for v1
|
||||||
|
- **Release truth**: current-release support follow-through and commercialization gap, not future multi-provider preparation
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit, Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: Unit coverage can prove the external handoff adapter normalization rules, handoff mode branching, and latest-handoff summary derivation cheaply. Focused Filament feature coverage can prove tenant and run action behavior, `404` versus `403` boundaries, persisted linkage truth, explicit failure handling, and audit events without needing browser-only coverage.
|
||||||
|
- **New or expanded test families**: one bounded `Unit/Support/SupportRequests/ExternalSupportDesk*` family and one bounded `Feature/SupportRequests/*ExternalHandoff*` family
|
||||||
|
- **Fixture / helper cost impact**: moderate. Reuse existing workspace, tenant, operation run, support request, and authorization fixtures. Add only the narrow target-configuration and adapter-fake setup needed for create or link success and failure paths.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament, monitoring-state-page
|
||||||
|
- **Standard-native relief or required special coverage**: standard Filament action coverage is sufficient for the tenant dashboard action. The run-context action must also preserve the existing canonical monitoring-state-page authorization and context rules.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that no support-request resource or queue page appears, that create failures keep the internal support request, that latest external linkage stays scoped to the current entitled context, and that no provider-specific payloads leak into the persisted support-request truth.
|
||||||
|
- **Budget / baseline / trend impact**: low-to-moderate increase in narrow unit plus feature coverage only
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`
|
||||||
|
|
||||||
|
## External Handoff Contract
|
||||||
|
|
||||||
|
The first slice extends the existing support-request truth instead of creating a second support-ticket product model.
|
||||||
|
|
||||||
|
| Handoff mode | Operator intent | External effect | Stored support-request truth | Default-visible result |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `create_external_ticket` | Create a new external desk ticket from the current support context | One outbound create call through the configured external desk adapter | external ticket reference, optional external ticket URL, chosen mode, and cleared failure summary on success | success feedback shows the internal support reference plus the created external ticket reference |
|
||||||
|
| `link_existing_ticket` | Record an external ticket that already exists outside the product | No outbound ticket-create call; the bounded adapter may normalize the provided reference or URL for the configured target | operator-supplied external ticket reference, optional external ticket URL, chosen mode, and no provider payload mirror | success feedback shows the internal support reference plus the linked external ticket reference |
|
||||||
|
| `internal_only` | Keep the request internal-only when the operator intentionally defers external follow-through or when no target is configured for the application | No outbound call | no external ticket reference, chosen mode, and no external failure summary | the support context clearly states that no external ticket is linked yet |
|
||||||
|
|
||||||
|
Additional rules for v1:
|
||||||
|
|
||||||
|
- The internal `SR-...` support reference remains the canonical TenantPilot support-request identifier even when an external ticket exists.
|
||||||
|
- V1 does not store external assignee, SLA, comments, status history, or raw provider payloads.
|
||||||
|
- V1 does not auto-retry failed create calls. If a retry or relink path becomes necessary later, it requires a follow-up spec.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- extend the existing `SupportRequest` truth and `SupportRequestSubmissionService` rather than introducing a new support domain model
|
||||||
|
- offer one bounded external handoff mode selector inside the current tenant and run `Request support` actions
|
||||||
|
- allow one support request to either create an external ticket, link an existing external ticket, or stay internal-only
|
||||||
|
- call exactly one application-configured external support desk or PSA target when the operator chooses `create_external_ticket`
|
||||||
|
- store the resulting external ticket reference and optional URL on the same support request record
|
||||||
|
- store a bounded last handoff failure summary on the same support request when external create fails after the internal request exists
|
||||||
|
- write explicit audit events for external ticket created, external ticket linked, and external handoff failed
|
||||||
|
- show the latest external handoff summary for the current tenant or run support context without adding a broad support-product surface
|
||||||
|
- keep current redacted support context attachment behavior from `SupportRequestContextBuilder`
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- re-specifying or replacing support-request creation from Spec 246
|
||||||
|
- creating a `SupportRequestResource`, support-request register, or support-request detail page
|
||||||
|
- a generic ticketing or helpdesk framework with provider discovery or multiple adapters
|
||||||
|
- bidirectional sync, external ticket status refresh, webhook ingestion, or comment sync
|
||||||
|
- SLA, priority routing, assignment, support inbox, triage queue, or customer portal work
|
||||||
|
- AI-generated support summaries or automation
|
||||||
|
- background jobs or scheduled retries for external desk delivery
|
||||||
|
- cross-workspace or cross-tenant linking shortcuts based on a known support reference or ticket reference alone
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Exactly one application-configured external support desk target can be resolved through a minimal config contract added in this slice. This spec does not introduce workspace settings UI, per-workspace target management, or a broader support-desk configuration product surface.
|
||||||
|
- The existing `Capabilities::SUPPORT_REQUESTS_CREATE` capability is sufficient for v1. No new role family or support-only secondary capability is required.
|
||||||
|
- The current redacted support context envelope produced by `SupportRequestContextBuilder` is already the canonical payload basis for external handoff. This feature does not redefine the support context contract.
|
||||||
|
- Internal support-request creation remains allowed even when the external target is unavailable or an external create attempt fails, because the product must preserve the internal support truth and auditability.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- A synchronous external create call can slow the current support action if the provider-owned handoff service does not enforce the v1 five-second timeout budget and normalize timeout failures into the same bounded failure-summary path.
|
||||||
|
- If a tenant or run has multiple support requests, the latest-handoff summary can mislead operators unless it also names the internal support reference it belongs to.
|
||||||
|
- Provider-specific response fields can leak into the support-request truth if the adapter boundary is not enforced strictly.
|
||||||
|
- The manual `link_existing_ticket` path could grow into a broader external-ticket management surface if it is allowed outside support-request submission. That growth is out of scope for v1.
|
||||||
|
|
||||||
|
## Follow-up Candidates
|
||||||
|
|
||||||
|
- a second external support desk or PSA target only after a concrete second target exists and the first target proves real operator value
|
||||||
|
- a bounded retry or relink flow from the same support contexts only if repeated external create failures become a proven operator pain point
|
||||||
|
- a read-only support-request register only if current tenant or run context visibility is no longer sufficient
|
||||||
|
- bidirectional sync or external ticket status refresh only if operators demonstrate a real need beyond stored reference continuity
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Create a new external ticket from the existing support flow (Priority: P1)
|
||||||
|
|
||||||
|
As a support-capable operator, I want the existing support-request action to create an external desk ticket from the current tenant or run context so I do not have to recreate the same case manually outside TenantPilot.
|
||||||
|
|
||||||
|
**Why this priority**: This is the direct commercialization gap named by the roadmap and candidate. Without outbound create, the product still stops at an internal support request only.
|
||||||
|
|
||||||
|
**Independent Test**: Submit a support request from the tenant dashboard and from the operation-run viewer with `create_external_ticket`, fake the configured external desk target, and verify that the support request keeps the internal `SR-...` reference while also storing the returned external ticket reference and URL.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an entitled operator opens the tenant dashboard and the application has one configured external desk target, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system creates the internal support request, creates one external ticket through the bounded adapter, stores the resulting external ticket reference on that same support request, and returns both references in success feedback.
|
||||||
|
2. **Given** an entitled operator opens an operation run that resolves to an entitled tenant, **When** the operator submits the existing `Request support` action with `create_external_ticket`, **Then** the system stores the internal support request with the run as primary context and persists the external ticket linkage on that same request.
|
||||||
|
3. **Given** the current context already has an earlier external handoff summary, **When** the operator opens the current `Request support` action again, **Then** the action shows the latest external linkage summary for that same context without turning the surface into a support-request history page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Link an already-existing external ticket during support submission (Priority: P1)
|
||||||
|
|
||||||
|
As a support-capable operator who already opened a desk ticket outside TenantPilot, I want to link that ticket during support-request submission so the product records the same external reference without creating a duplicate external case.
|
||||||
|
|
||||||
|
**Why this priority**: The candidate explicitly requires create or link behavior, and linking an already-created external ticket is the smallest way to avoid duplicates without inventing a broader support-ticket management surface.
|
||||||
|
|
||||||
|
**Independent Test**: Submit the existing tenant or run `Request support` action with `link_existing_ticket`, provide a ticket reference and optional URL, and verify that the support request stores that linkage truth without issuing an external create call.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an entitled operator already has an external ticket reference, **When** the operator submits the existing tenant-context support action with `link_existing_ticket`, **Then** the system persists the provided external reference on the same support request and records an explicit audit event that the ticket was linked rather than created.
|
||||||
|
2. **Given** an entitled operator is on the operation-run support context, **When** the operator submits the action with `link_existing_ticket`, **Then** the system links the external reference to the run-scoped support request without creating a new external desk ticket.
|
||||||
|
3. **Given** the operator leaves the ticket reference blank or otherwise invalid for the bounded target format, **When** the action is submitted with `link_existing_ticket`, **Then** the system rejects the linkage input and does not create misleading external-ticket truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep failures explicit, scoped, and auditable (Priority: P2)
|
||||||
|
|
||||||
|
As a support-capable operator, I want external handoff failures to be explicit without losing the internal support request so I can continue follow-through safely and without guessing what happened.
|
||||||
|
|
||||||
|
**Why this priority**: The value of external handoff depends on failure honesty. Silent loss of the desk ticket or silent loss of the internal request would be worse than the current manual workflow.
|
||||||
|
|
||||||
|
**Independent Test**: Force the external adapter to fail during `create_external_ticket`, then verify that the internal support request remains persisted, the current support context shows the latest failure summary for that same support reference, and audit truth records the failed handoff.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the internal support request is created successfully but the external create call fails, **When** the action completes, **Then** the internal support request remains persisted, the operator receives explicit partial-success feedback with the internal support reference plus the handoff failure, and the failed handoff is audited.
|
||||||
|
2. **Given** a user is not entitled to the current workspace or tenant scope, **When** they attempt to access tenant or run external handoff state or submit the support action, **Then** the system returns `404` and reveals neither the internal support reference nor any external ticket reference.
|
||||||
|
3. **Given** a user is entitled to the tenant but lacks `Capabilities::SUPPORT_REQUESTS_CREATE`, **When** they attempt the same action, **Then** the system returns `403` and does not create, link, or reveal external handoff truth.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- The application may not have an external desk target configured. In that case the existing support-request flow must remain available in `internal_only` mode with an explicit note that no external target is configured.
|
||||||
|
- An external create call may fail after the internal support request is already committed. The request must remain the canonical support truth and must keep a bounded failure summary rather than disappearing or rolling back silently.
|
||||||
|
- A tenant or run can have multiple support requests over time. The visible handoff summary on the current support context must clearly identify which internal support reference the shown external ticket reference belongs to.
|
||||||
|
- An operator may know an external ticket reference but not a URL. The product may store the reference alone in v1 and must not invent a URL it cannot prove.
|
||||||
|
- The operation-run viewer can only surface latest handoff state when the run resolves to an entitled tenant. Runs without an entitled tenant must continue to resolve as `404` without leaking any linkage hint.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature adds a synchronous outbound create call to one configured external support desk target as part of an existing support-request mutation. It does not create a new `OperationRun`, queue, or scheduler. Successful internal request creation, external ticket creation, external ticket linking, and external handoff failure MUST all be auditable.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature extends the existing `SupportRequest` truth instead of adding a second support-ticket model or queue. The only new semantic family is one bounded handoff mode family because operator choice and resulting mutation behavior differ materially between create, link, and internal-only paths.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** Existing `Request support` actions, support-diagnostics context, `SupportRequestSubmissionService`, and `WorkspaceAuditLogger` must be reused. Any new external handoff behavior must plug into that shared path instead of creating separate tenant and run implementations.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** External handoff truth must stay secondary to the current tenant or run troubleshooting workflow. The current support contexts should show only the bounded latest linkage summary or failure, while diagnostics remain separately opened and raw provider details remain hidden.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** External desk payloads, authentication, and provider-specific identifiers remain provider-owned. The shared product truth remains the existing `SupportRequest` plus neutral external linkage fields only.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in unit plus feature lanes only. Browser and heavy-governance coverage are out of scope for the first slice.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The affected authorization plane remains the tenant-admin `/admin` plane. Non-members and non-entitled users receive `404`. Entitled users lacking `Capabilities::SUPPORT_REQUESTS_CREATE` receive `403`. No raw capability strings or role-string checks may appear in feature code.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature must continue to use native Filament actions and action forms on the current pages. No custom standalone support desk page or local replacement shell is allowed.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Primary operator-facing copy must preserve `Request support`, `Support reference`, and `External ticket`. Provider-specific product names, payload terminology, or API vocabulary must not replace those primary labels.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-256-001 Existing surfaces only**: The system MUST extend only the existing tenant dashboard and canonical operation-run `Request support` actions for v1. It MUST NOT introduce a new support-request resource, support-request detail view, or support queue page.
|
||||||
|
- **FR-256-002 Bounded handoff mode choice**: When the application has a configured external desk target, the existing `Request support` action MUST let the operator choose exactly one of `create_external_ticket`, `link_existing_ticket`, or `internal_only`. When no target is configured, the action MUST remain available in `internal_only` mode and MUST explain that no external desk target is configured.
|
||||||
|
- **FR-256-003 Internal request remains canonical**: Every path in this feature MUST create or preserve the existing internal `SupportRequest` truth first. The internal `SR-...` reference remains the canonical support-request identifier even when an external ticket is created or linked.
|
||||||
|
- **FR-256-003A Bounded finalization exception to Spec 246 immutability**: Spec 256 explicitly narrows Spec 246 FR-246-011 in one bounded way: after internal request creation, the same `SupportRequest` row MAY receive exactly one synchronous finalization write limited to `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary`. No broader edit, reopen, merge, or status workflow is introduced.
|
||||||
|
- **FR-256-004 External create path**: When the operator selects `create_external_ticket`, the system MUST call exactly one application-configured external support desk target through one bounded provider-owned adapter, apply a maximum five-second outbound timeout, store the returned external ticket reference on the same support request, and store the external ticket URL when the target returns one.
|
||||||
|
- **FR-256-005 External link path**: When the operator selects `link_existing_ticket`, the system MUST store the provided external ticket reference on the same support request and MUST NOT issue an external ticket-create call for that request.
|
||||||
|
- **FR-256-006 Persisted linkage truth**: The existing `support_requests` truth MUST be extended with only the neutral external linkage fields needed for operator continuity: external ticket reference, optional external ticket URL, selected handoff mode, and bounded last handoff failure summary.
|
||||||
|
- **FR-256-007 No mirrored external lifecycle**: V1 MUST NOT persist or display external assignee, SLA, queue, comment stream, status history, or raw provider payloads.
|
||||||
|
- **FR-256-008 Failure honesty**: If the external create path fails after the internal support request exists, the system MUST keep the internal request, persist a bounded last handoff failure summary on that same request, and show explicit feedback that the internal request succeeded but the external handoff failed.
|
||||||
|
- **FR-256-009 Context-safe visibility**: The current tenant and run support contexts MUST show the latest external handoff summary for that same primary context, including the internal support reference it belongs to, without becoming a broad support-request history surface.
|
||||||
|
- **FR-256-010 Audit coverage**: The system MUST write stable audit entries for support request created, external ticket created, external ticket linked, and external handoff failed, with workspace and tenant context plus the internal support reference and the external ticket reference when present.
|
||||||
|
- **FR-256-011 Authorization boundaries**: Non-members and non-entitled actors MUST receive `404`. Members in scope who lack `Capabilities::SUPPORT_REQUESTS_CREATE` MUST receive `403`. Latest-handoff visibility, create, and link behavior MUST all enforce the same boundary.
|
||||||
|
- **FR-256-012 Provider boundary**: Provider-specific authentication, request payload shape, response parsing, and URL normalization MUST remain inside one provider-owned adapter or service. Shared platform code MUST work only with the neutral external linkage truth stored on `SupportRequest`.
|
||||||
|
- **FR-256-013 No background expansion**: V1 MUST NOT add background jobs, retry scheduling, webhook ingestion, or `OperationRun` usage for external desk delivery.
|
||||||
|
- **FR-256-014 No cross-scope shortcuts**: A known internal support reference or external ticket reference MUST NOT be sufficient to reveal or mutate linkage truth outside the current entitled workspace and tenant scope.
|
||||||
|
- **FR-256-015 Mutation-scope clarity**: The existing support actions MUST make it clear whether the current submission writes to `TenantPilot only` or to `TenantPilot + external support desk`, based on the selected handoff mode.
|
||||||
|
- **FR-256-016 Timeout normalization**: When `create_external_ticket` exceeds the five-second outbound timeout budget or times out for any other target-level reason, the system MUST keep the internal support request, persist a bounded timeout-oriented failure summary on the same row, and route the outcome through the same explicit feedback and audit path as other external create failures.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant dashboard support context | `App\Filament\Pages\TenantDashboard` | `Request support`, `Open support diagnostics` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | Existing dashboard action-surface exemption remains. The feature only extends the current `Request support` action with handoff mode choice and latest linkage summary. |
|
||||||
|
| Operation-run support context | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | grouped `Open support diagnostics`, `Request support` | `N/A` | `N/A` | `N/A` | `N/A` | `N/A` | one slide-over with `Submit support request` and a standard close action | yes | No new run action group or support page. The feature only extends the existing support action with handoff mode choice and latest linkage summary. |
|
||||||
|
|
||||||
|
## Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Support Request**: Existing tenant-owned support truth with internal reference, primary context, redacted context envelope, severity, and the new bounded external linkage fields needed for external handoff continuity.
|
||||||
|
- **External Support Desk Target**: The single application-configured external desk or PSA destination used for v1 handoff. It owns provider-specific authentication and payload semantics.
|
||||||
|
- **External Ticket Linkage**: The bounded support-request extension that records whether the current request stayed internal-only, created an external ticket, or linked an existing one, together with the neutral external ticket reference, optional URL, and last failure summary.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: From the existing tenant dashboard or operation-run support context, an authorized operator can complete support-request submission with external create or link behavior in one flow without leaving the current page to recreate the case manually.
|
||||||
|
- **SC-002**: 100% of successful external create or link submissions persist an external ticket reference on the same support request and make that reference visible again from the same entitled support context on revisit.
|
||||||
|
- **SC-003**: 100% of external create failures leave the internal support request intact, produce explicit operator-visible failure feedback, and write an audit entry for the failed handoff.
|
||||||
|
- **SC-004**: Authorization tests prove that operators never see or mutate external ticket linkage for a workspace or tenant they are not entitled to, even when they know an internal support reference or external ticket reference.
|
||||||
192
specs/256-external-support-desk-handoff/tasks.md
Normal file
192
specs/256-external-support-desk-handoff/tasks.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for External Support Desk / PSA Handoff"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: External Support Desk / PSA Handoff
|
||||||
|
|
||||||
|
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/`
|
||||||
|
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md`
|
||||||
|
|
||||||
|
**Support truth**: Spec 246 and the existing repo code remain authoritative, except for one bounded Spec 256 finalization exception: after internal request creation, the same `SupportRequest` row may receive exactly one synchronous write limited to the external handoff fields. Extend `apps/platform/app/Models/SupportRequest.php` and the current support-request submission path only; do not add a second support-ticket entity, support queue, support register, or support-request resource.
|
||||||
|
**Tests (TEST-GOV-001)**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in focused unit plus feature lanes only, then run the narrow manual smoke path from `quickstart.md`.
|
||||||
|
**Operations**: This slice must stay synchronous inside the existing support-request path. Do not create, queue, resume, or complete an `OperationRun`.
|
||||||
|
**RBAC**: Workspace membership and tenant entitlement remain `404` boundaries; in-scope members missing `Capabilities::SUPPORT_REQUESTS_CREATE` remain `403`; latest-handoff visibility must follow the same boundary.
|
||||||
|
**Provider boundary**: One configured external desk target only. No helpdesk registry, no target-management UI, and no multi-provider framework in this slice.
|
||||||
|
**Organization**: Tasks are grouped by user story so create, link, and explicit-failure behavior can be implemented and validated independently once the shared foundation exists.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Preparation)
|
||||||
|
|
||||||
|
**Purpose**: Lock the bounded repo-grounded scope before runtime work begins.
|
||||||
|
|
||||||
|
- [x] T001 Review `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/contracts/external-support-desk-handoff.logical.openapi.yaml`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/checklists/requirements.md` and confirm the slice stays one-way, single-target, and SupportRequest-backed.
|
||||||
|
- [x] T002 [P] Verify the exact reuse seams from Spec 246 in `apps/platform/app/Models/SupportRequest.php`, `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/SupportRequests/SupportRequestContextBuilder.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and the new app-config seam `apps/platform/config/support_desk.php` before adding any new handoff behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Add the bounded persistence, target-resolution, audit, and shared summary seams that every story depends on.
|
||||||
|
|
||||||
|
**Critical**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T003 Extend the existing support-request truth with `external_handoff_mode`, `external_ticket_reference`, `external_ticket_url`, and `external_handoff_failure_summary` in `apps/platform/database/migrations/*_add_external_handoff_fields_to_support_requests_table.php`, `apps/platform/app/Models/SupportRequest.php`, and `apps/platform/database/factories/SupportRequestFactory.php` without creating a second support-ticket model or table.
|
||||||
|
- [x] T004 [P] Add the one concrete provider-owned handoff seam and the single-target app-config contract in `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php` and `apps/platform/config/support_desk.php`, enforce the five-second outbound timeout there, and avoid introducing a support settings UI, provider registry, or generic helpdesk framework.
|
||||||
|
- [x] T005 [P] Preserve the existing `support_request.created` audit path and add stable audit action IDs plus bounded audit payload helpers for external ticket created, external ticket linked, and external handoff failed in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`.
|
||||||
|
- [x] T006 Add one shared latest-handoff summary read path for tenant and run primary contexts in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so both existing support actions reuse the same scoped query, naming, and no-cross-scope-shortcut rules.
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready. The existing support-request path can now persist neutral external-linkage truth, resolve one target, and read the latest scoped handoff summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Create A New External Ticket From The Existing Support Flow (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: An entitled operator can submit the existing support action and create one external desk ticket from the current tenant or run context without leaving the product.
|
||||||
|
|
||||||
|
**Independent Test**: Submit `Request support` from the tenant dashboard and the operation-run viewer with `create_external_ticket`, fake one configured target, and verify the same `SupportRequest` row keeps the internal `SR-...` reference while storing the returned external reference and URL.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T007 [P] [US1] Add unit coverage for `create_external_ticket` branching, single-target availability fallback, the five-second timeout path, and created-ticket reference or URL normalization in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`.
|
||||||
|
- [x] T008 [P] [US1] Add feature coverage for tenant and run `create_external_ticket` success paths in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [US1] Extend `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` so internal support-request creation commits first, `create_external_ticket` runs synchronously afterward, and the one allowed Spec 256 finalization write records the external reference or URL back onto the same `SupportRequest` row.
|
||||||
|
- [x] T010 [US1] Extend the tenant dashboard support action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with handoff-mode choice, target-availability guidance, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, latest-handoff summary copy, and success feedback that shows both internal and external references when a ticket is created.
|
||||||
|
- [x] T011 [US1] Extend the run-context support action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` with the same create flow, scoped latest-handoff summary, `TenantPilot only` versus `TenantPilot + external support desk` mutation-scope copy, and success feedback without adding a new run-support surface.
|
||||||
|
- [x] T012 [US1] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any create-path regressions before moving to the link flow.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional when both existing support actions can create one external ticket and immediately show the persisted linkage on the same support-request truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Link An Already-Existing External Ticket During Support Submission (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: An entitled operator can record an external ticket that already exists without creating a duplicate external case.
|
||||||
|
|
||||||
|
**Independent Test**: Submit the existing tenant and run `Request support` actions with `link_existing_ticket`, provide a valid reference and optional URL, and verify the `SupportRequest` stores that linkage without issuing an external create call.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T013 [P] [US2] Add unit coverage for `link_existing_ticket` reference normalization, optional URL normalization, and invalid-link rejection in `apps/platform/tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php`.
|
||||||
|
- [x] T014 [P] [US2] Add feature coverage for tenant and run `link_existing_ticket` submissions, including the no-create-call guarantee and linked-flow success feedback that shows both references, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T015 [US2] Implement `link_existing_ticket` branching, conditional validation, and persisted external reference or URL behavior in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` and `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`.
|
||||||
|
- [x] T016 [US2] Add conditional external reference and URL inputs plus linked-flow success feedback that shows the internal and external references on the tenant dashboard action in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`.
|
||||||
|
- [x] T017 [US2] Add the same link controls plus linked-flow success feedback to the run-context action in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`.
|
||||||
|
- [x] T018 [US2] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php` and fix any link-path regressions before moving to failure hardening.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional when both existing support actions can link an already-created external ticket without producing a duplicate external create call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Keep Failures Explicit, Scoped, And Auditable (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: External handoff failure remains visible and auditable while the internal support request stays durable and the same tenant or run contexts stay the only visibility surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Force `create_external_ticket` to fail after internal request creation, then verify the internal `SupportRequest` remains persisted, the current support context shows the latest failure summary for that same support reference, and audit plus authorization behavior stays correct.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T019 [P] [US3] Add unit coverage for latest-handoff summary derivation, latest-per-context selection, and persisted failure-summary semantics in `apps/platform/tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php`.
|
||||||
|
- [x] T020 [P] [US3] Add feature coverage for tenant and run failed-create partial-success behavior, including timeout-normalized failure feedback, in `apps/platform/tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php` and `apps/platform/tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php`.
|
||||||
|
- [x] T021 [P] [US3] Add feature coverage for `404` versus `403` boundaries and context-scoped latest-summary visibility in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php`.
|
||||||
|
- [x] T022 [P] [US3] Add feature coverage for preserved `support_request.created` auditing plus created, linked, and failed external-handoff audit events in `apps/platform/tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php`.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T023 [US3] Persist bounded `external_handoff_failure_summary` semantics, the one allowed Spec 256 finalization-write contract, and latest-summary scoping rules in `apps/platform/app/Models/SupportRequest.php` and `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php` without adding support history pages, external-reference lookup routes, or a second support product surface.
|
||||||
|
- [x] T024 [US3] Implement explicit partial-success or warning feedback plus revisit-time failure-summary rendering in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php`.
|
||||||
|
- [x] T025 [US3] Enforce the shared authorization and audit boundary for create, link, failure, and latest-summary visibility in `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` without introducing queues, retries, or `OperationRun` orchestration.
|
||||||
|
- [x] T026 [US3] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` and fix any failure, audit, or authorization regressions before final polish.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional when explicit failure truth, scoped visibility, and audit coverage all hold without losing the internal support request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Close the slice without widening scope, and leave a clean validation and guardrail trail for review.
|
||||||
|
|
||||||
|
- [x] T027 Confirm `Request support`, `Support reference`, `External ticket`, handoff-mode labels, mutation-scope wording, and latest-summary copy stay aligned across `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/SupportRequests/ExternalSupportDeskHandoffService.php`, `apps/platform/lang/en/localization.php`, and `apps/platform/lang/de/localization.php` without leaking provider-specific product names into primary operator copy.
|
||||||
|
- [x] T028 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` on the touched platform files before final validation.
|
||||||
|
- [x] T029 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportRequests/ExternalSupportDeskHandoffServiceTest.php tests/Unit/Support/SupportRequests/SupportRequestLatestHandoffSummaryTest.php` as the focused unit close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`.
|
||||||
|
- [x] T030 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/TenantSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/OperationRunSupportRequestExternalHandoffTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuthorizationTest.php tests/Feature/SupportRequests/SupportRequestExternalHandoffAuditTest.php` as the focused feature close-out suite from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md`.
|
||||||
|
- [x] T031 Execute the manual smoke path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/quickstart.md` for tenant and run create, link, and failure handling, including the no-new-support-surface and no-`OperationRun` checks. Completed through a temporary Pest Browser smoke harness covering tenant create, run link, run failure, latest failure summary, no console errors, and no persistent browser-test surface.
|
||||||
|
- [x] T032 Record the final implementation close-out in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/256-external-support-desk-handoff/plan.md`, including the guardrail outcome and any explicit `document-in-feature` or named `follow-up-spec` decision for target configuration, retry pressure, or multi-provider pressure instead of hiding that scope in code review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- Phase 1 starts immediately.
|
||||||
|
- Phase 2 depends on Phase 1 and blocks all story work.
|
||||||
|
- Phase 3 depends on Phase 2 and delivers the MVP create flow.
|
||||||
|
- Phase 4 depends on Phase 2 and is safest after Phase 3 because it extends the same submission service and the same two action forms.
|
||||||
|
- Phase 5 depends on Phases 3 and 4 because failure, visibility, and audit proof must cover both create and link behavior on both existing surfaces.
|
||||||
|
- Phase 6 depends on every prior phase.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- US1 is the MVP and first shippable increment.
|
||||||
|
- US2 is independently testable but should follow US1 because both stories extend the same `SupportRequestSubmissionService` and support-action forms.
|
||||||
|
- US3 depends on US1 and US2 because explicit failure, audit, and scoped-visibility rules must cover every handoff mode.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the listed Pest coverage first and ensure it fails before implementation.
|
||||||
|
- Land shared submission-service changes before surface wiring whenever both are required.
|
||||||
|
- Re-run the story-specific validation task before moving to the next story.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- T001 and T002 can run in parallel.
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- T004 and T005 can run in parallel after T003 establishes the persisted handoff fields.
|
||||||
|
|
||||||
|
### User Story 1
|
||||||
|
|
||||||
|
- T007 and T008 can run in parallel before implementation work starts.
|
||||||
|
|
||||||
|
### User Story 2
|
||||||
|
|
||||||
|
- T013 and T014 can run in parallel before implementation work starts.
|
||||||
|
|
||||||
|
### User Story 3
|
||||||
|
|
||||||
|
- T019, T020, T021, and T022 can run in parallel before the failure hardening pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1.
|
||||||
|
2. Complete Phase 2.
|
||||||
|
3. Complete Phase 3.
|
||||||
|
4. Stop and review the create-only external handoff slice before adding link and failure hardening.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Ship US1 so the product can create one external ticket from the two existing support-aware surfaces.
|
||||||
|
2. Add US2 so operators can link an already-opened external ticket without duplicate create behavior.
|
||||||
|
3. Add US3 so failure honesty, scoped visibility, and audit proof hold across every handoff mode.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Finish Phase 2 together before splitting story work.
|
||||||
|
2. Parallelize test authoring inside each story.
|
||||||
|
3. Sequence merges carefully around `apps/platform/app/Support/SupportRequests/SupportRequestSubmissionService.php`, `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and `apps/platform/lang/en/localization.php` plus `apps/platform/lang/de/localization.php`, because every story touches those same shared seams.
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
# Preparation Review Checklist: Governance Decision Surface Convergence v1
|
||||||
|
|
||||||
|
**Purpose**: Verify that the preparation package is repo-grounded, narrowly scoped, and ready for a later implementation loop.
|
||||||
|
**Created**: 2026-04-29
|
||||||
|
**Review outcome class**: Workflow Compression
|
||||||
|
**Workflow outcome**: approve for implementation
|
||||||
|
**Test-governance outcome**: keep
|
||||||
|
|
||||||
|
## Candidate Selection
|
||||||
|
|
||||||
|
- [x] CHK001 The selected slice is anchored to `docs/product/roadmap.md` (`Decision-Based Operating Foundations`) and the open-gap truth in `docs/product/implementation-ledger.md`.
|
||||||
|
- [x] CHK002 Already-specced candidates such as Spec 043, Spec 249, Spec 250, Spec 251, Spec 252, Spec 253, Spec 254, Spec 255, and Spec 256 are explicitly excluded from reopening.
|
||||||
|
- [x] CHK003 The package chooses the smallest repo-grounded slice: extend the existing governance inbox and specialist pages instead of inventing a new shell or workflow engine.
|
||||||
|
|
||||||
|
## Scope And Truth
|
||||||
|
|
||||||
|
- [x] CHK004 The spec, plan, and tasks all state that no new persistence, inbox-item truth, queue state, or mutation lane is introduced.
|
||||||
|
- [x] CHK005 The governance inbox remains the canonical workspace decision home, while specialist queues and the customer review workspace remain secondary-context surfaces.
|
||||||
|
- [x] CHK006 The finding-exceptions lane and review-consumption handoff are described as derived from existing repo truth rather than a new abstraction layer.
|
||||||
|
|
||||||
|
## UX And Authorization
|
||||||
|
|
||||||
|
- [x] CHK007 The package makes one dominant next action explicit for the governance home, preserves one dominant default action on each specialist surface, and keeps specialist pages from duplicating the workspace-level summary.
|
||||||
|
- [x] CHK008 `404` vs `403` semantics are explicit for workspace membership, tenant scope, and no-visible-family cases.
|
||||||
|
- [x] CHK009 Hidden tenants and hidden families are omitted from counts, labels, previews, and empty-state hints.
|
||||||
|
|
||||||
|
## Test Governance
|
||||||
|
|
||||||
|
- [x] CHK010 Planned proof stays in focused `Unit` plus `Feature` lanes only, with explicit validation commands.
|
||||||
|
- [x] CHK011 The tasks include explicit coverage for family assembly, arrival/return continuity, latest-published-review preference versus workspace fallback, duplicate-truth prevention, and read-only review integrity on the specialist pages.
|
||||||
|
- [x] CHK012 The checklist records the active review outcome class and workflow outcome instead of leaving readiness implicit.
|
||||||
|
|
||||||
|
## Readiness Outcome
|
||||||
|
|
||||||
|
- [x] CHK013 The package is ready for implementation only if analysis confirms that the scope remains bounded to existing governance, findings, monitoring, and review seams and that `CustomerReviewWorkspace` stays read-only.
|
||||||
|
- [x] CHK014 The package explicitly preserves the no-new-Graph-call, no-queue, no-`OperationRun`, and no-new-audit-stream constraints for this slice.
|
||||||
|
- [x] CHK015 Any broader dashboard-entry, cross-tenant, or workflow-engine follow-up is listed separately rather than hidden inside this slice.
|
||||||
254
specs/257-governance-decision-convergence/plan.md
Normal file
254
specs/257-governance-decision-convergence/plan.md
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
# Implementation Plan: Governance Decision Surface Convergence v1
|
||||||
|
|
||||||
|
**Branch**: `257-governance-decision-convergence` | **Date**: 2026-04-29 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from [spec.md](spec.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Tighten TenantPilot's decision-first operating model by converging onto the existing `GovernanceInbox` as the canonical workspace decision home, extending it with the still-missing finding-exceptions lane, and aligning the specialist findings, exceptions, and customer-review pages behind one truthful arrival and return model. The slice is intentionally read-only and reuses existing page, builder, and navigation seams instead of adding a new page shell, workflow state, or task engine.
|
||||||
|
|
||||||
|
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no new asset bundle is expected.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4, Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing governance inbox and navigation helpers
|
||||||
|
**Storage**: PostgreSQL via existing findings, finding-exceptions, reviews, packs, alerts, and operation-run truth only
|
||||||
|
**Testing**: Pest v4 `Unit` plus `Feature` coverage
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
||||||
|
**Project Type**: Web application (Laravel monolith with Filament pages)
|
||||||
|
**Performance Goals**: derived DB-only page rendering, no new remote calls, and no queue or `OperationRun` start in v1
|
||||||
|
**Constraints**: no new persistence, no new page shell, no new mutation lane, no customer portal scope, no duplicate truth across equal-priority cards
|
||||||
|
**Scale/Scope**: 1 existing canonical page, 4 specialist page classes plus their Blade views, and 1 bounded section-builder extension
|
||||||
|
|
||||||
|
## Likely Affected Repo Surfaces
|
||||||
|
|
||||||
|
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
|
||||||
|
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
|
||||||
|
- `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
|
||||||
|
- `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
|
||||||
|
- `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||||
|
- `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
|
||||||
|
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||||
|
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
|
||||||
|
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
|
||||||
|
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
|
||||||
|
- `apps/platform/app/Support/Badges/BadgeRenderer.php`
|
||||||
|
|
||||||
|
## UI / Filament & Livewire Fit
|
||||||
|
|
||||||
|
- Reuse the existing `GovernanceInbox` Filament page instead of introducing a new page class or a utility shell.
|
||||||
|
- Keep the governance home as a section-based read-only page. Add one derived exception lane and adjust review-consumption handoff copy and arrival context, but keep diagnostics and proof on the existing specialist or detail routes.
|
||||||
|
- Preserve specialist-page ownership. `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace` remain the pages where lane-specific truth and existing safe actions live.
|
||||||
|
- Any state that must survive navigation or Livewire requests stays on public, query-backed, or existing session-backed state. Do not move convergence state into private page properties.
|
||||||
|
- No new resource, global-search result, or panel asset registration is planned.
|
||||||
|
|
||||||
|
## RBAC / Policy Fit
|
||||||
|
|
||||||
|
- Workspace membership remains the first gate for the governance home and all converged routes.
|
||||||
|
- Findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW`; existing inline safe actions such as claim remain on the owning specialist pages and continue to require their existing capabilities such as `Capabilities::TENANT_FINDINGS_ASSIGN`.
|
||||||
|
- The exception lane must reuse the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE` rather than inventing a second exception-view capability.
|
||||||
|
- The review-consumption handoff must reuse the current review and review-pack access rules instead of adding a new customer-review-workspace capability family.
|
||||||
|
- `404` applies to non-members and out-of-scope tenant targets. `403` applies only to in-scope members who still cannot see any converged family.
|
||||||
|
|
||||||
|
## Audit / Logging Fit
|
||||||
|
|
||||||
|
- The convergence layer stays read-only and should not add a new page-view audit stream.
|
||||||
|
- Existing mutations and downloads remain audited on their current owning surfaces.
|
||||||
|
- No new `OperationRun`, notification stream, or navigation-event ledger is required.
|
||||||
|
|
||||||
|
## Data & Query Fit
|
||||||
|
|
||||||
|
- Extend `GovernanceInboxSectionBuilder` rather than creating a new persistence or projection layer.
|
||||||
|
- The new exception lane must derive from existing `FindingException` truth and the current queue semantics, not from a copied workflow summary.
|
||||||
|
- Review-consumption handoff should keep using the current latest-published-review vs customer-review-workspace fallback logic.
|
||||||
|
- Family counts, previews, and empty-state decisions must be computed only after tenant and capability filtering, so hidden tenants and hidden lanes do not leak through aggregate counts.
|
||||||
|
- Keep any new family key local to the page and builder; do not introduce a new domain enum or persisted state family.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament
|
||||||
|
- **Shared-family relevance**: governance decision home, specialist queues, customer-review routing, navigation continuity
|
||||||
|
- **State layers in scope**: page, URL-query, table/session restore
|
||||||
|
- **Audience modes in scope**: operator-MSP, customer-read-only on the existing review workspace
|
||||||
|
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the governance home, diagnostics-second on specialist pages, raw/support detail remains on existing detail paths only
|
||||||
|
- **Raw/support gating plan**: hidden by default on the governance home; existing gating remains on source/detail surfaces
|
||||||
|
- **One-primary-action / duplicate-truth control**: governance home keeps one dominant CTA per section; specialist pages keep their existing lane-owned primary action and must not duplicate the governance-home summary
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: global-context-shell
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none planned
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the specialist findings, exception, and review pages listed above
|
||||||
|
- **Shared abstractions reused**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing source-page action-surface declarations
|
||||||
|
- **New abstraction introduced? why?**: at most one bounded convergence helper for arrival and return semantics if the existing navigation-context helper needs a thin extension; no new framework or registry is justified
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions are sufficient for page ownership and current routing, but not yet sufficient to make the governance home the single truthful start surface across the missing exception and review-consumption lanes
|
||||||
|
- **Bounded deviation / spread control**: no new shell or workflow engine; all implementation stays inside existing governance, findings, monitoring, reviews, and navigation seams
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` contract change
|
||||||
|
- **Central contract reused**: the already-existing stale-operations deep-link behavior remains unchanged
|
||||||
|
- **Delegated UX behaviors**: `N/A`
|
||||||
|
- **Surface-owned behavior kept local**: the governance home continues to list stale operations through the existing family, but this spec does not change that family's start or completion semantics
|
||||||
|
- **Queued DB-notification policy**: `N/A`
|
||||||
|
- **Terminal notification path**: `N/A`
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: no
|
||||||
|
- **Provider-owned seams**: `N/A`
|
||||||
|
- **Platform-core seams**: existing governance and navigation vocabulary only
|
||||||
|
- **Neutral platform terms / contracts preserved**: `Governance inbox`, `finding exceptions`, `customer review workspace`, `Open lane`, and `Back to governance inbox`
|
||||||
|
- **Retained provider-specific semantics and why**: none new
|
||||||
|
- **Bounded extraction or follow-up path**: `N/A`
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before implementation preparation continues.*
|
||||||
|
|
||||||
|
- Inventory-first: PASS. All sections remain derived from existing findings, exceptions, reviews, alerts, and operation-run truth.
|
||||||
|
- Read/write separation: PASS. The convergence layer remains read-only and keeps mutations on existing source surfaces.
|
||||||
|
- Graph contract path: PASS. No new Graph or provider calls are introduced.
|
||||||
|
- Deterministic capabilities: PASS. Existing capability registries remain authoritative.
|
||||||
|
- Workspace and tenant isolation: PASS. Workspace membership remains first, and tenant/family omission happens before counts are exposed.
|
||||||
|
- RBAC-UX plane separation: PASS. Everything stays in `/admin`; no `/system` expansion.
|
||||||
|
- Destructive action discipline: PASS by non-use. No new destructive or risky actions are introduced.
|
||||||
|
- Global search: PASS. No new resource or search result is added.
|
||||||
|
- OperationRun / Ops-UX: PASS by non-use. No new run start or completion behavior exists.
|
||||||
|
- Data minimization: PASS. Default-visible content stays limited to family summaries, lane scope, and next action.
|
||||||
|
- Test governance: PASS. Proof remains in focused `Unit` and `Feature` lanes.
|
||||||
|
- Proportionality / no premature abstraction: PASS. The design extends an existing page and builder instead of introducing a new shell or engine.
|
||||||
|
- Persisted truth: PASS. No new table, artifact, or cached projection is introduced.
|
||||||
|
- Behavioral state: PASS. Any additional family key remains derived page state only.
|
||||||
|
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing page and navigation patterns are extended rather than bypassed.
|
||||||
|
- Provider boundary: PASS. No provider/platform seam widens.
|
||||||
|
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
|
||||||
|
|
||||||
|
**Gate evaluation**: PASS.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Unit` for section assembly and convergence routing, `Feature` for page visibility, family omission, and navigation continuity
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: unit coverage proves derived-family assembly cheaply; feature coverage proves route access, family omission, tenant-prefilter continuity, and duplicate-truth prevention on existing pages
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse existing workspace, tenant, finding, exception, and review fixtures without widening into browser or heavy-governance families
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: `global-context-shell` coverage is required because return context and tenant-filter continuity are part of the contract
|
||||||
|
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify the governance home stays read-only, and confirm specialist surfaces preserve lane-specific truth without duplicating the workspace summary
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
|
||||||
|
- **Review-stop questions**: lane fit, hidden fixture growth, accidental new shell, accidental new mutation lane, hidden leakage through counts
|
||||||
|
- **Escalation path**: `document-in-feature` for contained navigation-context notes; `reject-or-split` for any new shell or workflow-engine drift
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||||
|
- **Test-governance outcome**: keep
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the bounded convergence work remains feature-local unless future work demands a broader dashboard or portfolio action-center spec
|
||||||
|
|
||||||
|
## Rollout & Risk Controls
|
||||||
|
|
||||||
|
- Keep the governance inbox as the only primary start surface touched by this slice.
|
||||||
|
- Keep all specialist mutations on their existing pages.
|
||||||
|
- Do not widen the exception or review lane into new workflow state.
|
||||||
|
- Prefer extending the current section builder and navigation helper over adding a new orchestrator.
|
||||||
|
- Treat any attempt to add a second workspace summary banner on the specialist pages as out-of-scope drift.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/257-governance-decision-convergence/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
This preparation package intentionally stays on the core artifacts plus the review checklist. The repo truth is already known, the slice adds no new persistence or external contract, and no extra research/data-model/contracts package is required to make the implementation bounded.
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/Pages/
|
||||||
|
│ │ ├── Findings/
|
||||||
|
│ │ │ ├── MyFindingsInbox.php
|
||||||
|
│ │ │ └── FindingsIntakeQueue.php
|
||||||
|
│ │ ├── Governance/
|
||||||
|
│ │ │ └── GovernanceInbox.php
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ │ └── FindingExceptionsQueue.php
|
||||||
|
│ │ └── Reviews/
|
||||||
|
│ │ └── CustomerReviewWorkspace.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── GovernanceInbox/
|
||||||
|
│ │ └── GovernanceInboxSectionBuilder.php
|
||||||
|
│ ├── Navigation/
|
||||||
|
│ │ └── CanonicalNavigationContext.php
|
||||||
|
│ └── OperateHub/
|
||||||
|
│ └── OperateHubShell.php
|
||||||
|
└── resources/views/filament/pages/
|
||||||
|
├── findings/
|
||||||
|
│ ├── my-findings-inbox.blade.php
|
||||||
|
│ └── findings-intake-queue.blade.php
|
||||||
|
├── governance/
|
||||||
|
│ └── governance-inbox.blade.php
|
||||||
|
├── monitoring/
|
||||||
|
│ └── finding-exceptions-queue.blade.php
|
||||||
|
└── reviews/
|
||||||
|
└── customer-review-workspace.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| One additional derived family in `GovernanceInboxSectionBuilder` | the exception lane still sits outside the canonical decision home | leaving exceptions on a standalone specialist page keeps the current fragmented start state |
|
||||||
|
| One bounded convergence contract for arrival and return context | specialist pages need a truthful way back to the same governance scope | page-local ad hoc back links would drift across surfaces and duplicate navigation logic |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: operators still have to decide between several repo-real specialist surfaces before they can begin work.
|
||||||
|
- **Existing structure is insufficient because**: the current governance home does not yet own all high-signal lanes and the specialist pages do not clearly behave as secondary contexts.
|
||||||
|
- **Narrowest correct implementation**: extend the existing governance inbox and navigation continuity instead of adding a new shell or persisted workflow engine.
|
||||||
|
- **Ownership cost created**: maintain one more derived family, one bounded convergence helper, and focused tests.
|
||||||
|
- **Alternative intentionally rejected**: a new action-center page or persisted cross-family work queue was rejected as unnecessary structure for current-release truth.
|
||||||
|
- **Release truth**: current-release workflow compression.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
MVP = **User Story 1 + User Story 2 together**. The convergence slice only becomes meaningful once the governance home shows the missing lanes and the specialist surfaces preserve truthful return context.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Extend the governance inbox family assembly and page rendering.
|
||||||
|
2. Add convergence-aware arrival and return semantics on the specialist pages.
|
||||||
|
3. Tighten duplicate-truth prevention and calm secondary-context copy.
|
||||||
|
4. Finish with focused validation and formatting.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Settle the governance inbox family extension and navigation-context contract first.
|
||||||
|
2. Parallelize unit coverage for builder behavior and feature coverage for navigation continuity.
|
||||||
|
3. Serialize merges around the shared governance inbox and specialist page views so the decision-home language stays coherent.
|
||||||
320
specs/257-governance-decision-convergence/spec.md
Normal file
320
specs/257-governance-decision-convergence/spec.md
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
# Feature Specification: Governance Decision Surface Convergence v1
|
||||||
|
|
||||||
|
**Feature Branch**: `257-governance-decision-convergence`
|
||||||
|
**Created**: 2026-04-29
|
||||||
|
**Status**: Ready for implementation
|
||||||
|
**Input**: User description: "Prepare the roadmap-fit Decision-Based Operating Foundations slice as Governance Decision Surface Convergence: use the existing governance inbox as the canonical workspace decision home, converge the still-missing exception and customer-review decision lanes, and keep the work read-only, repo-grounded, and free of new workflow state."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: TenantPilot already has multiple repo-real decision surfaces such as `MyFindingsInbox`, `FindingsIntakeQueue`, `GovernanceInbox`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`, but operators still have to decide where to start by hopping between specialist pages.
|
||||||
|
- **Today's failure**: The product has a decision-first foundation, but it still lacks one clearly dominant workspace decision home that converges the open exception lane and the customer-review follow-up lane into the same calm operating model.
|
||||||
|
- **User-visible improvement**: An authorized workspace operator lands on one canonical governance home, sees the remaining high-signal lanes in one place, and can open a specialized surface with preserved context and a truthful return path instead of reconstructing the workflow manually.
|
||||||
|
- **Smallest enterprise-capable version**: Reuse the existing `/admin/governance/inbox` page as the canonical decision home, extend it with a derived `finding_exceptions` family from existing queue truth, make customer-review follow-up handoff explicit through existing review workspace surfaces, and align `My Findings`, `Findings intake`, `Finding exceptions`, and `Customer Review Workspace` behind shared arrival and return context. No new page shell, no new persistence, and no new mutation lane ship in this slice.
|
||||||
|
- **Explicit non-goals**: No new portal or dashboard shell, no new persisted inbox item or task state, no new assign/claim/approve/review mutations on the convergence surface, no cross-tenant compare or promotion work, no customer-facing portfolio board, no AI prioritization, and no generic workflow framework.
|
||||||
|
- **Permanent complexity imported**: One bounded family extension inside the existing governance inbox assembly path, one small convergence contract for arrival and return context across existing pages, and focused unit plus feature coverage.
|
||||||
|
- **Why now**: `docs/product/roadmap.md` still has an open `Decision-Based Operating Foundations` lane, while `docs/product/implementation-ledger.md` identifies decision-surface fragmentation as the highest unspecced operator workflow gap after already-specced compare, commercial, and cleanup packages are removed from the queue.
|
||||||
|
- **Why not local**: Extending only `FindingExceptionsQueue` or only `CustomerReviewWorkspace` would keep the current start-state ambiguity intact and would not establish a single truthful operator entry point.
|
||||||
|
- **Approval class**: Workflow Compression
|
||||||
|
- **Red flags triggered**: One multi-surface convergence flag and one derived-family-extension flag. Defense: the slice extends existing pages and builders, introduces no new persistence, and keeps all workflow state on the existing source surfaces.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- existing canonical workspace route `/admin/governance/inbox`
|
||||||
|
- existing `/admin/findings/my-work`
|
||||||
|
- existing `/admin/findings/intake`
|
||||||
|
- existing `/admin/finding-exceptions/queue`
|
||||||
|
- existing `/admin/reviews/workspace`
|
||||||
|
- existing tenant-scoped finding and review detail routes as drill-through targets only
|
||||||
|
- **Data Ownership**:
|
||||||
|
- `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only persisted truth for their respective families
|
||||||
|
- the convergence layer remains derived from the existing `GovernanceInbox` page and supporting builders; it introduces no new inbox-item table, cache, mirror entity, or workflow state
|
||||||
|
- no new review, exception, or decision summary persistence is introduced
|
||||||
|
- **RBAC**:
|
||||||
|
- workspace membership remains the first boundary for the canonical decision home and all converged launches
|
||||||
|
- non-members and explicit out-of-scope tenant filters remain `404` deny-as-not-found boundaries
|
||||||
|
- in-scope members who can access none of the converged families receive `403`, not a silent empty shell
|
||||||
|
- findings lanes continue to reuse `Capabilities::TENANT_FINDINGS_VIEW` and keep claim or assignment semantics on their existing pages, including `Capabilities::TENANT_FINDINGS_ASSIGN`
|
||||||
|
- the exception lane reuses the existing `FindingExceptionsQueue` visibility contract based on `Capabilities::FINDING_EXCEPTION_APPROVE`; this slice must not introduce a second exception capability family
|
||||||
|
- customer-review follow-up and review-pack handoff continue to reuse existing review and pack visibility checks; this slice must not introduce a new review-workspace capability family
|
||||||
|
- the convergence surface stays read-only; all mutations remain enforced on their existing source pages and actions
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped or tenant-prefiltered specialist page, the governance inbox prefilters to that tenant and, when relevant, to the originating family. Clearing filters returns to the workspace-wide decision home instead of preserving a local specialist scope forever.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Broad workspace listings silently omit inaccessible tenants and hidden families from counts, labels, and previews. Explicit tenant or record targets outside visible scope resolve as not found and do not leak family counts or presence hints.
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: navigation entry points, decision-home section summaries, action links, queue empty states, back-link continuity, and badge or status reuse
|
||||||
|
- **Systems touched**: `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, `CustomerReviewWorkspace`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and existing action-surface declarations on those pages
|
||||||
|
- **Existing pattern(s) to extend**: the existing governance inbox route and section builder, tenant-prefilter state handling, canonical navigation context, calm empty-state copy on specialist pages, and existing read-first decision surfaces
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, `ActionSurfaceDeclaration`, and existing source-page capability guards
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: the existing source pages already own the truth and the current governance inbox already owns the canonical route, but the current convergence is insufficient because finding exceptions and explicit customer-review decision continuity still sit outside the calm default operating model
|
||||||
|
- **Allowed deviation and why**: none. The slice should extend the existing governance inbox and source pages instead of introducing a second convergence shell or a generic task framework.
|
||||||
|
- **Consistency impact**: `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, `Open customer review workspace`, and `Back to governance inbox` language must stay consistent across section copy, specialist page affordances, and empty-state recovery actions.
|
||||||
|
- **Review focus**: reviewers must block any implementation that creates a new standalone convergence page, adds local specialist mutations to the governance home, or duplicates specialist proof content on the decision surface.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: no new `OperationRun` start or completion behavior is introduced
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: existing deep-link-only behavior on the already-present stale-operations family remains unchanged
|
||||||
|
- **Delegated start/completion UX behaviors**: `N/A`
|
||||||
|
- **Local surface-owned behavior that remains**: the governance home still decides whether the existing stale-operations section is shown, but this spec does not widen or redefine that contract
|
||||||
|
- **Queued DB-notification policy**: `N/A`
|
||||||
|
- **Terminal notification path**: `N/A`
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
N/A - no new provider or platform-core boundary is widened. This slice only converges existing operator-facing decision surfaces.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Governance inbox page | yes | Native Filament page plus existing section builder | decision-home summaries, navigation entry points, badge reuse | page, URL-query, derived family state | no | Existing canonical route remains authoritative; no new shell is added |
|
||||||
|
| Findings and exception specialist queues (`MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`) | yes | Native Filament pages | arrival context, return continuity, calm secondary-context copy | page, URL-query, table/session filter restore | no | Remain specialized secondary-context surfaces; no workflow ownership moves here |
|
||||||
|
| Customer review workspace | yes | Native Filament page | review-follow-up handoff, return continuity, calm read-only context | page, URL-query, table/session filter restore | no | Remains the review-consumption surface, not a second governance home |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Governance inbox page | Primary Decision Surface | Operator decides which lane to open next across findings, exceptions, stale operations, alert failures, and review follow-up | visible family counts, top blockers, tenant scope, and one dominant next action per section | specialist queue details, review detail, alert detail, and finding detail after explicit open | Primary because it becomes the single workspace start surface instead of one of several competing starts | Aligns the product with the roadmap's decision-first operating direction | Reduces page hopping before the first action |
|
||||||
|
| Findings and exception specialist queues | Secondary Context | Operator acts inside the chosen lane after the first decision is already made | lane-specific list rows, current tenant scope, and one existing safe next action | record detail, due context, approval context, and deeper diagnostics on existing detail surfaces | Secondary because the lane is chosen at the governance home, not discovered here first | Keeps specialist work inside the existing pages without making them compete as starts | Removes the need to re-evaluate the whole workspace after every lane jump |
|
||||||
|
| Customer review workspace | Secondary Context | Operator verifies the latest customer-safe review state after a governance or review-follow-up cue | latest published review outcome, pack availability, and read-only summary | full review detail and pack download after explicit open | Secondary because it remains a read-only review-consumption surface entered from a governance cue | Preserves the customer-safe review workflow while fitting into the same decision hierarchy | Prevents the review workspace from becoming a competing attention home |
|
||||||
|
|
||||||
|
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Governance inbox page | operator-MSP | family summary, tenant scope, top blockers, and section CTA | diagnostics stay on the specialist pages | raw payloads and debug detail stay on existing source pages only | `Open attention lane` | raw detail is never rendered on the decision home | the page states the current blocker once and sends the operator to the owning surface for proof |
|
||||||
|
| Findings and exception specialist queues | operator-MSP | lane-specific queue rows, due cues, status, and existing next action | specialist diagnostics and record detail remain available on existing routes | raw/support detail stays behind existing record detail affordances | existing lane-owned action only | any broad workspace summary stays off the specialist pages | specialist pages do not restate the workspace decision-home summary |
|
||||||
|
| Customer review workspace | operator-MSP, customer-read-only | latest customer-safe review status, pack availability, and read-only summary | deeper review diagnostics stay on review detail | raw JSON, provider payloads, and internal support evidence remain hidden or gated on existing detail/download paths | `Open latest review` | support-only raw evidence stays off the workspace surface | review workspace states review truth once and relies on review detail for proof |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Governance inbox page | Utility / Workspace Decision | Read-only workflow hub | Open the correct existing lane | explicit section CTA or preview-entry CTA | forbidden | section footer links and preview-entry links only | none | `/admin/governance/inbox` | existing specialist routes only | active workspace, optional tenant and family filter | Governance inbox | which lane needs attention now | none |
|
||||||
|
| Findings and exception specialist queues | List / Table / Read-only decision queue | Specialist queue | Open the owning record or use the existing inline safe shortcut | row click and existing specialist inspect path | required | existing lane-owned actions only | existing grouped destructive or risky actions remain on owning detail surfaces | `/admin/findings/my-work`, `/admin/findings/intake`, `/admin/finding-exceptions/queue` | existing finding or exception detail routes | tenant filter, queue view, queue-specific states | Findings / Finding exceptions | lane-specific actionable truth only | none |
|
||||||
|
| Customer review workspace | List / Table / Read-only workspace report | Read-only review consumption | Open the latest published review | clickable row to latest review or explicit download/open action | required | existing read-only actions only | none | `/admin/reviews/workspace` | existing tenant review detail route | tenant filter and review availability | Customer review workspace | latest published review state and pack availability | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Governance inbox page | Workspace operator / MSP operator | Decide which governance lane to open next | Workspace decision hub | What needs attention now across my visible governance lanes? | family counts, top blockers, tenant scope, and section CTA | diagnostics remain on specialist pages | lane urgency, lane ownership, tenant scope | none | Open lane | none |
|
||||||
|
| Findings and exception specialist queues | Workspace operator / MSP operator | Work inside a chosen findings or exception lane | Specialist queue | What in this chosen lane needs action first? | queue rows, due or review cues, owner/assignee context, and current filter state | full record detail and deep diagnostics remain on existing detail routes | workflow status, due/overdue state, review or approval state | existing lane-owned mutations only | inspect record or existing lane action | existing queue-owned risky actions only |
|
||||||
|
| Customer review workspace | Workspace operator / readonly-capable tenant actor | Consume the latest review truth after a review-follow-up cue | Read-only review workspace | What is the latest published review state for this tenant? | latest review outcome, pack availability, and read-only summary | full review detail and pack detail after explicit open | review availability, review freshness, pack availability | none | Open latest review | none |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: yes - one bounded convergence contract inside the existing governance inbox assembly and navigation-context seams
|
||||||
|
- **New enum/state/reason family?**: no new persisted family; any added family key remains derived and page-scoped
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: operators still start from multiple repo-real specialist surfaces even though the repo already has enough decision surfaces to support one calmer governance home
|
||||||
|
- **Existing structure is insufficient because**: the current governance inbox does not yet own all remaining high-signal lanes and the specialist pages do not clearly behave as secondary contexts
|
||||||
|
- **Narrowest correct implementation**: extend the existing governance inbox and current specialist pages with one more derived family and shared arrival/return semantics instead of creating a new shell or workflow engine
|
||||||
|
- **Ownership cost**: maintain one more derived section and a small navigation-context convergence layer plus focused tests
|
||||||
|
- **Alternative intentionally rejected**: a new global action center, persisted inbox-item table, and mutation-capable workflow engine were rejected as premature and structurally heavier than the current release truth requires
|
||||||
|
- **Release truth**: current-release workflow compression, not future-release platform speculation
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical extension of the existing governance inbox is preferred over adding a parallel decision surface.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Unit, Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: unit coverage proves section assembly, family ordering, and convergence routing without Filament boot cost; focused feature coverage proves visibility, tenant-filter continuity, return context, and calm secondary-surface behavior on the existing pages
|
||||||
|
- **New or expanded test families**: focused `Unit/Support/GovernanceInbox` coverage plus focused `Feature/Governance`, `Feature/Monitoring`, `Feature/Reviews`, and `Feature/Findings` convergence coverage
|
||||||
|
- **Fixture / helper cost impact**: moderate; tests need workspace membership, visible and hidden tenants, findings, exceptions, review-follow-up states, and review-workspace fixtures, but should reuse existing factories and avoid browser or heavy-governance setup
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: global-context-shell
|
||||||
|
- **Standard-native relief or required special coverage**: special coverage is required for arrival/return context and duplicate-truth prevention across the specialist pages
|
||||||
|
- **Reviewer handoff**: reviewers must confirm that the governance inbox remains the single start surface, counts omit inaccessible families, specialist pages keep one dominant action, and no new mutation lane or persistence appears
|
||||||
|
- **Budget / baseline / trend impact**: low feature-local increase only
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php`
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- reuse the existing `GovernanceInbox` page as the canonical workspace decision home
|
||||||
|
- extend the governance inbox with a derived finding-exceptions family sourced from existing queue truth
|
||||||
|
- make customer-review follow-up and customer-review-workspace handoff explicit within the same decision hierarchy
|
||||||
|
- preserve tenant and family arrival context plus truthful return links between the governance home and `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`
|
||||||
|
- keep specialist pages as calm secondary-context surfaces with no duplicate workspace-level blocker summary
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- creating a new global action-center page or dashboard shell
|
||||||
|
- replacing the existing specialist pages or moving their mutations to the governance home
|
||||||
|
- adding a new persisted inbox item, queue state, or workflow engine
|
||||||
|
- changing existing finding, exception, or review lifecycle semantics
|
||||||
|
- cross-tenant compare, promotion, or portfolio execution work
|
||||||
|
- customer-facing portfolio boards or AI-driven prioritization
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- the existing `GovernanceInboxSectionBuilder` can accept one more derived family without turning into a generic task engine
|
||||||
|
- current `CanonicalNavigationContext` and tenant-prefilter handling are sufficient to preserve truthful return paths between the decision home and specialist pages
|
||||||
|
- `CustomerReviewWorkspace` remains the correct read-only destination for customer-safe review consumption when a published review detail is not the better direct target
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- implementation could overreach and turn the governance home into a new task engine instead of a routing surface
|
||||||
|
- the finding-exceptions family could leak hidden tenant hints if capability and tenant scoping are not applied before counts and previews are derived
|
||||||
|
- specialist-page convergence could accidentally duplicate blocker language instead of keeping the decision summary on the governance home only
|
||||||
|
|
||||||
|
## Follow-up Candidates
|
||||||
|
|
||||||
|
- wider dashboard-entry convergence once the governance home proves adoption
|
||||||
|
- portfolio-level decision convergence with cross-tenant compare after Spec 043 implementation
|
||||||
|
- any mutation consolidation only after the read-only convergence hierarchy is proven and remains bounded
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Use one canonical governance home (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I want one governance home that includes the still-missing exception and review-consumption lanes so I can decide where to work next without choosing between multiple start pages first.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest slice that completes the roadmap's decision-first operating direction without inventing new workflow state.
|
||||||
|
|
||||||
|
**Independent Test**: Seed visible findings, finding exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and section CTAs.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the actor can see findings, finding exceptions, and review follow-up for the current workspace, **When** they open the governance inbox, **Then** the page shows those lanes in one canonical surface with one dominant action per section.
|
||||||
|
2. **Given** the actor cannot see finding exceptions, **When** they open the governance inbox, **Then** the exception lane does not appear and no count or empty-state hint implies hidden work exists.
|
||||||
|
3. **Given** the actor applies a tenant-prefilter that hides all current rows, **When** they open the governance inbox, **Then** the page explains that the tenant filter is hiding other visible attention instead of falsely implying the whole workspace is calm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Move into a specialist lane and back without losing context (Priority: P1)
|
||||||
|
|
||||||
|
As a workspace operator, I want to open a specialist queue or review workspace from the governance home and come back with the same tenant and family context so the governance home becomes my operating anchor instead of a one-off report.
|
||||||
|
|
||||||
|
**Why this priority**: Convergence does not help if every lane jump loses the original decision context.
|
||||||
|
|
||||||
|
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the actor opens `My Findings` or `Findings intake` from the governance inbox, **When** the specialist queue loads, **Then** the existing queue keeps its specialist semantics while exposing a truthful return path to the governance inbox.
|
||||||
|
2. **Given** the actor opens `Finding exceptions` from the governance inbox, **When** the queue loads, **Then** the queue preserves the arrival tenant context and does not become a competing workspace start surface.
|
||||||
|
3. **Given** the actor opens `Customer Review Workspace` from a review-follow-up cue, **When** they inspect the review lane, **Then** the page stays read-only and preserves the governance-home return path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep specialist surfaces calm and secondary (Priority: P2)
|
||||||
|
|
||||||
|
As a workspace operator, I want specialist queues and the customer review workspace to keep their own lane truth without re-explaining the whole workspace blocker summary so each page stays focused on the action I already chose.
|
||||||
|
|
||||||
|
**Why this priority**: Convergence should reduce attention load, not spread the same summary across more pages.
|
||||||
|
|
||||||
|
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps its lane-specific content while the workspace-level blocker summary remains on the governance home.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the actor opens a specialist queue from the governance home, **When** the specialist page renders, **Then** it shows only lane-specific actionable truth and not a duplicated workspace summary banner.
|
||||||
|
2. **Given** the actor opens the customer review workspace from a review-follow-up cue, **When** the page renders, **Then** it shows customer-safe review truth and not a second governance-home summary card.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- the actor can access the governance inbox but none of the converged specialist families
|
||||||
|
- the requested tenant filter is outside the actor's visible scope
|
||||||
|
- the same tenant has findings, exceptions, and review follow-up at once, but the governance home must still avoid duplicating the same blocker explanation across sections
|
||||||
|
- review follow-up exists for a tenant without a currently published review, requiring the fallback customer-review-workspace destination
|
||||||
|
- the selected tenant is calm for exceptions but not for other families, so the empty-state message must be truthful about what the filter is hiding
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-257-001 Canonical decision home**: The system MUST treat the existing `/admin/governance/inbox` page as the canonical workspace decision home for operator-facing governance attention.
|
||||||
|
- **FR-257-002 Exception-family convergence**: The governance inbox MUST derive a `finding_exceptions` family from existing `FindingExceptionsQueue` truth and render it without adding a new persisted inbox item or queue-state layer.
|
||||||
|
- **FR-257-003 Review-consumption handoff**: Review-follow-up cues on the governance inbox MUST route into the existing review-consumption surfaces, using the latest published review when available and the existing customer review workspace when it is the truthful fallback.
|
||||||
|
- **FR-257-004 Arrival and return continuity**: Launching `My Findings`, `Findings intake`, `Finding exceptions`, or `Customer Review Workspace` from the governance inbox MUST preserve truthful arrival and return context for the current tenant and family scope.
|
||||||
|
- **FR-257-005 Secondary-surface discipline**: Specialist queues and the customer review workspace MUST remain secondary-context surfaces and MUST NOT become competing workspace start surfaces through duplicated workspace summary banners or second primary CTAs.
|
||||||
|
- **FR-257-006 Visibility and omission semantics**: Family counts, section previews, and empty states MUST be derived only from tenants and families the actor can already see through existing capability and entitlement checks.
|
||||||
|
- **FR-257-007 Authorization semantics**: Non-members and out-of-scope tenant targets MUST resolve as `404`, while in-scope members who lack visibility to every converged family MUST receive `403`.
|
||||||
|
- **FR-257-008 No new workflow truth**: The slice MUST NOT add a new inbox-item table, a persisted convergence state, or a new cross-family mutation contract.
|
||||||
|
- **FR-257-009 Source-surface ownership**: Claim, assignment, approval, review, and pack-download behaviors MUST remain on their existing source surfaces and continue to enforce their existing capabilities there.
|
||||||
|
- **FR-257-010 Decision-first disclosure**: The governance inbox MUST show summary and next-action content only; raw payloads, review evidence, and specialist diagnostics MUST remain on the owning specialist or detail surfaces.
|
||||||
|
- **FR-257-011 Duplicate-truth prevention**: The governance inbox and the specialist surfaces MUST NOT restate the same workspace-level blocker or next-action summary as equal-priority content.
|
||||||
|
- **FR-257-012 Read-only review integrity**: The customer review workspace remains read-only in this slice and MUST NOT gain operator-only mutation controls through convergence work.
|
||||||
|
|
||||||
|
### Non-Functional Requirements
|
||||||
|
|
||||||
|
- **NFR-257-001**: The convergence layer remains DB-only and derived from existing persisted truth; it MUST NOT add Graph calls, remote calls, queues, or `OperationRun` creation.
|
||||||
|
- **NFR-257-002**: The slice MUST reuse existing Filament and shared UI primitives before any local UI framework or semantic layer is introduced.
|
||||||
|
- **NFR-257-003**: The feature MUST stay within focused `Unit` and `Feature` lanes only; browser or heavy-governance coverage is out of scope unless implementation proves a specific need.
|
||||||
|
|
||||||
|
### UX Requirements
|
||||||
|
|
||||||
|
- **UXR-257-001**: The governance inbox remains the one dominant start surface for the converged lanes.
|
||||||
|
- **UXR-257-002**: Each affected surface has exactly one dominant next action visible by default.
|
||||||
|
- **UXR-257-003**: Specialist surfaces keep lane-specific truth only and rely on explicit return links for workspace context.
|
||||||
|
|
||||||
|
### RBAC / Security Requirements
|
||||||
|
|
||||||
|
- **RBR-257-001**: The slice MUST reuse existing capability registries and MUST NOT introduce raw capability strings or role-name checks.
|
||||||
|
- **RBR-257-002**: Tenant-filter and family-filter state MUST NOT leak inaccessible tenant or family hints through counts, labels, or empty-state copy.
|
||||||
|
|
||||||
|
### Auditability / Observability Requirements
|
||||||
|
|
||||||
|
- **AOR-257-001**: The slice MUST NOT create a new page-view audit stream; existing audit ownership remains on the existing source-surface mutations and downloads.
|
||||||
|
- **AOR-257-002**: Any convergence-specific navigation or UI state remains derived and inspectable through tests rather than new runtime logging.
|
||||||
|
|
||||||
|
### Data / Truth-Source Requirements
|
||||||
|
|
||||||
|
- **DTR-257-001**: `Finding`, `FindingException`, `TenantTriageReview`, `TenantReview`, `ReviewPack`, `AlertDelivery`, and `OperationRun` remain the only source truth inputs for the decision home.
|
||||||
|
- **DTR-257-002**: Any added convergence family key remains derived page state, not persisted domain truth.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- new persistence or workflow-state layers
|
||||||
|
- new operator mutations on the governance home
|
||||||
|
- cross-tenant compare or promotion work
|
||||||
|
- customer-facing portfolio boards or customer portal changes
|
||||||
|
- AI prioritization or recommendation logic
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- the selected operator can open one canonical governance home that includes the missing exception lane and truthful review-consumption handoff without seeing a second competing start surface
|
||||||
|
- specialist pages preserve truthful arrival and return context when opened from the governance home
|
||||||
|
- hidden families and inaccessible tenants do not leak through counts, labels, or empty-state hints
|
||||||
|
- the customer review workspace remains read-only and customer-safe while participating in the same decision hierarchy
|
||||||
|
- no new persistence, workflow state, queue, or runtime mutation surface is introduced
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- operators can explain one default start surface for governance work in the workspace
|
||||||
|
- the specialist pages feel like chosen lanes, not competing homes
|
||||||
|
- implementation can stay bounded to existing page and builder seams with no new framework layer
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- none
|
||||||
189
specs/257-governance-decision-convergence/tasks.md
Normal file
189
specs/257-governance-decision-convergence/tasks.md
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Governance Decision Surface Convergence v1"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Governance Decision Surface Convergence v1
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/257-governance-decision-convergence/`
|
||||||
|
**Prerequisites**: `specs/257-governance-decision-convergence/plan.md` (required), `specs/257-governance-decision-convergence/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 for this read-only convergence slice.
|
||||||
|
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Existing stale-operation links remain unchanged.
|
||||||
|
**RBAC**: Workspace membership remains the first boundary. Non-members or out-of-scope tenant targets return `404`; in-scope members with no visible family return `403`. Findings lanes reuse `Capabilities::TENANT_FINDINGS_VIEW`, existing inline safe actions keep their current capability checks such as `Capabilities::TENANT_FINDINGS_ASSIGN`, the exception lane reuses `Capabilities::FINDING_EXCEPTION_APPROVE`, and review handoff reuses existing review and pack visibility checks.
|
||||||
|
**Shared Pattern Reuse**: Reuse `GovernanceInbox`, `GovernanceInboxSectionBuilder`, `CanonicalNavigationContext`, `OperateHubShell`, `BadgeRenderer`, and the existing specialist page action-surface contracts. No new shell, task engine, or persistence layer is allowed.
|
||||||
|
**Organization**: Tasks are grouped by user story so the governance-home extension, navigation convergence, and calm secondary-context rules remain independently testable after the shared groundwork is complete.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in focused `apps/platform/tests/Unit/Support/GovernanceInbox/`, `apps/platform/tests/Feature/Governance/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Monitoring/`, and `apps/platform/tests/Feature/Reviews/` families only.
|
||||||
|
- [x] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or generic workflow fixtures.
|
||||||
|
- [x] Planned validation commands cover governance-home assembly, authorization, and arrival/return continuity without widening scope.
|
||||||
|
- [x] The declared surface test profile remains `global-context-shell` because arrival context and tenant-filter continuity are part of the contract.
|
||||||
|
- [x] Any broader action-center, dashboard-entry, or cross-tenant follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden implementation growth.
|
||||||
|
- [x] Test-governance outcome resolves as `keep` for this feature and does not widen the work into a heavier family.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Context)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the bounded convergence slice, the existing governance-home seams, and the reviewer stop conditions before implementation begins.
|
||||||
|
|
||||||
|
- [x] T001 Review the bounded convergence slice in `specs/257-governance-decision-convergence/spec.md` and `specs/257-governance-decision-convergence/plan.md` together with `docs/product/roadmap.md` and `docs/product/implementation-ledger.md`.
|
||||||
|
- [x] T002 [P] Confirm the current governance-home families and summary seams in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php`.
|
||||||
|
- [x] T003 [P] Confirm the specialist-page arrival, return, and filter-state seams in `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/resources/views/filament/pages/findings/my-findings-inbox.blade.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/resources/views/filament/pages/findings/findings-intake-queue.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Extend the shared governance-home and navigation seams that every user story depends on.
|
||||||
|
|
||||||
|
**Critical**: No user-story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T004 [P] Define or extend the bounded family-aware arrival and return contract inside `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` and any minimal supporting helper under `apps/platform/app/Support/GovernanceInbox/` without creating new persistence or a generic workflow framework.
|
||||||
|
- [x] T005 [P] Tighten family omission and access evaluation in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` so inaccessible tenants and families disappear before counts are derived and in-scope no-family access resolves as `403`.
|
||||||
|
- [x] T006 Implement the derived `finding_exceptions` family in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` using existing `FindingExceptionsQueue` truth, current queue semantics, and existing capability rules.
|
||||||
|
- [x] T007 Implement truthful review-consumption handoff logic in `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` and `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` so review-follow-up entries prefer existing latest review detail and fall back to `CustomerReviewWorkspace` only when that is the honest destination.
|
||||||
|
- [x] T008 [P] Update `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to keep one dominant CTA per section and avoid duplicate workspace-summary cards as the new family is added.
|
||||||
|
|
||||||
|
**Checkpoint**: The governance home can derive the new family, family counts stay capability-safe, and navigation context rules are settled before story-specific work begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Use One Canonical Governance Home (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Give the operator one governance home that includes the missing exception and review-consumption lanes without creating a new shell.
|
||||||
|
|
||||||
|
**Independent Test**: Seed visible findings, exceptions, and review-follow-up states, open the governance inbox, and verify that the page shows the converged lanes with calm summaries and one dominant CTA per section.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [P] [US1] Extend `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php` to cover exception-family inclusion, family ordering, review-workspace fallback, and omission semantics for hidden tenants or families.
|
||||||
|
- [x] T010 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxPageTest.php` to cover the visible exception lane, review-consumption handoff summary, tenant-filter empty-state truth, and one dominant CTA per section.
|
||||||
|
- [x] T011 [P] [US1] Extend `apps/platform/tests/Feature/Governance/GovernanceInboxAuthorizationTest.php` to cover `404` vs `403` behavior when workspace access exists but all converged family visibility is removed.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [US1] Update `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to render the new convergence lane and family-aware summary or empty-state copy.
|
||||||
|
- [x] T013 [US1] Align governance-home copy in `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php` and `apps/platform/resources/views/filament/pages/governance/governance-inbox.blade.php` to the stable vocabulary `Governance inbox`, `Open my findings`, `Open findings intake`, `Open finding exceptions`, and `Open customer review workspace`.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional when the governance inbox truthfully shows the missing lane and routes to the existing specialist destinations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Move Into A Specialist Lane And Back (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Preserve tenant and family context when the operator opens a specialist page from the governance home and returns.
|
||||||
|
|
||||||
|
**Independent Test**: Open the governance inbox with tenant and family filters, jump into a specialist page, and verify that the specialist page exposes a truthful return path back to the same governance scope.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T014 [P] [US2] Add or extend `apps/platform/tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php` for tenant and family arrival/return continuity across governance-home launches.
|
||||||
|
- [x] T015 [P] [US2] Add or extend `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php` for governance-home arrival, preserved tenant context, and truthful `Back to governance inbox` continuity.
|
||||||
|
- [x] T016 [P] [US2] Add or extend `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` for governance-home arrival and return continuity on review-follow-up launches, preferred latest-published-review destination when available, fallback to `CustomerReviewWorkspace` when not, preserved read-only state, and the absence of operator-only mutation controls.
|
||||||
|
- [x] T017 [P] [US2] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php` and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php` for governance-home launch and return continuity on the findings specialist pages.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T018 [US2] Wire governance-home arrival and return context through `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`.
|
||||||
|
- [x] T019 [US2] Expose truthful return affordances without adding a second primary CTA. Repo truth: these native Filament pages expose the affordance through page header actions in `MyFindingsInbox`, `FindingsIntakeQueue`, `FindingExceptionsQueue`, and `CustomerReviewWorkspace`; no specialist Blade edits were required.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional when the operator can move between the governance home and the specialist pages without losing truthful context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Keep Specialist Surfaces Calm And Secondary (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Ensure the specialist pages stay focused on lane-specific truth and do not duplicate the workspace-level summary once convergence context exists.
|
||||||
|
|
||||||
|
**Independent Test**: Open the governance home and then each specialist surface, and verify that the specialist page keeps lane-specific content while the workspace-level blocker summary remains on the governance home only.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T020 [P] [US3] Add or extend `apps/platform/tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`, `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php`, and `apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php` to assert duplicate-truth prevention, one dominant default action on each specialist surface, and secondary-context copy when the pages are opened from the governance home.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T021 [US3] Keep lane-specific summaries focused and avoid duplicating workspace-level blocker text. Repo truth: page classes now add secondary return context while existing specialist Blade views stay lane-focused; regression tests assert the governance-home summary text is absent from secondary pages.
|
||||||
|
- [x] T022 [US3] Align action-surface declarations, header affordances, and empty-state recovery actions across `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`, `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` so the canonical start surface remains obvious.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional when specialist surfaces remain lane-specific secondary contexts instead of competing starts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
|
||||||
|
|
||||||
|
- [x] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`.
|
||||||
|
- [x] T024 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/GovernanceInboxPageTest.php tests/Feature/Governance/GovernanceInboxAuthorizationTest.php tests/Feature/Governance/GovernanceInboxNavigationContextConvergenceTest.php tests/Feature/Monitoring/FindingExceptionsQueueNavigationContextTest.php tests/Feature/Reviews/CustomerReviewWorkspaceNavigationContextTest.php tests/Feature/Findings/MyFindingsInboxNavigationContextTest.php tests/Feature/Findings/FindingsIntakeQueueNavigationContextTest.php`.
|
||||||
|
- [x] T025 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
|
||||||
|
- [x] T026 [P] Confirm the slice introduced no new asset registration, no new globally searchable resource, and no new mutation lane; record any bounded follow-up for broader dashboard-entry or portfolio action-center work in the active implementation notes.
|
||||||
|
- [x] T027 [P] Confirm the slice introduced no new Graph or remote calls, no queue or `OperationRun` start path, and no page-view audit or runtime logging stream; record any bounded follow-up if implementation uncovers a structural need outside this slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
||||||
|
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
||||||
|
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical governance-home truth.
|
||||||
|
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the governance home is not a dead-end report.
|
||||||
|
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because specialist pages must already participate in the convergence flow.
|
||||||
|
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: independently testable after Phase 2 and establishes the new canonical decision-home behavior.
|
||||||
|
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the new home has truthful workflow continuity.
|
||||||
|
- **US3 (P2)**: independently testable after Phase 2 and refines the specialist pages once the convergence contract exists.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- After the shared foundational contract work in Phase 2 is complete, write the listed Pest coverage first for each user story and make it fail for the intended gap.
|
||||||
|
- Land the shared builder and navigation contract before widening Blade or copy work.
|
||||||
|
- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### User Story 1
|
||||||
|
|
||||||
|
- T009, T010, and T011 can run in parallel before runtime edits begin.
|
||||||
|
- After the family contract settles, T012 and T013 can proceed in parallel because rendering and copy alignment touch different seams.
|
||||||
|
|
||||||
|
### User Story 2
|
||||||
|
|
||||||
|
- T014, T015, T016, and T017 can run in parallel because they cover different destinations in the convergence flow.
|
||||||
|
- After T018 settles the shared navigation contract, T019 can follow to align the Blade affordances.
|
||||||
|
|
||||||
|
### User Story 3
|
||||||
|
|
||||||
|
- T020 can start before implementation finishes because it only captures the expected secondary-context behavior.
|
||||||
|
- T021 and T022 can proceed together once the shared convergence path is stable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- MVP = **US1 + US2 together**. The slice becomes product-meaningful only when the governance home shows the missing lanes and the specialist pages preserve truthful return context.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Phase 1 and Phase 2.
|
||||||
|
2. Deliver US1 and US2 together.
|
||||||
|
3. Add US3 secondary-context tightening.
|
||||||
|
4. Finish with focused validation and formatting in Phase 6.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Settle the governance-home family extension and navigation-context contract first.
|
||||||
|
2. Parallelize unit and feature coverage inside each story before runtime edits widen.
|
||||||
|
3. Serialize merges around the governance inbox and specialist Blade views so the decision-home language stays coherent.
|
||||||
@ -1,228 +0,0 @@
|
|||||||
# Feature 005: Policy Lifecycle Management
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling.
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
Currently, when a policy is deleted in Intune:
|
|
||||||
- ❌ Policy remains in TenantAtlas database indefinitely
|
|
||||||
- ❌ No indication that policy no longer exists in Intune
|
|
||||||
- ❌ Backup Items reference "ghost" policies
|
|
||||||
- ❌ Users cannot distinguish between active and deleted policies
|
|
||||||
|
|
||||||
**Discovered during**: Feature 004 manual testing (user deleted policy in Intune)
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
- **Primary**: Implement soft delete for policies removed from Intune
|
|
||||||
- **Secondary**: Show clear UI indicators for deleted policies
|
|
||||||
- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- **Policy Sync**: Detect missing policies during `SyncPoliciesJob`
|
|
||||||
- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern)
|
|
||||||
- **UI**: Badge indicators, filters, restore capability
|
|
||||||
- **Audit**: Log when policies are soft-deleted and restored
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Stories
|
|
||||||
|
|
||||||
### User Story 1 - Automatic Soft Delete on Sync
|
|
||||||
|
|
||||||
**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123",
|
|
||||||
**When** the next policy sync runs and "abc-123" is NOT returned by Graph API,
|
|
||||||
**Then** the policy is soft-deleted (sets `deleted_at = now()`)
|
|
||||||
|
|
||||||
2. **Given** a soft-deleted policy,
|
|
||||||
**When** it re-appears in Intune (same `external_id`),
|
|
||||||
**Then** the policy is automatically restored (`deleted_at = null`)
|
|
||||||
|
|
||||||
3. **Given** multiple policies are deleted in Intune,
|
|
||||||
**When** sync runs,
|
|
||||||
**Then** all missing policies are soft-deleted in a single transaction
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - UI Indicators for Deleted Policies
|
|
||||||
|
|
||||||
**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
1. **Given** I view a Backup Item referencing a deleted policy,
|
|
||||||
**When** I see the policy name,
|
|
||||||
**Then** it shows a red "Deleted" badge next to the name
|
|
||||||
|
|
||||||
2. **Given** I view the Policies list,
|
|
||||||
**When** I enable the "Show Deleted" filter,
|
|
||||||
**Then** deleted policies appear with:
|
|
||||||
- Red "Deleted" badge
|
|
||||||
- Deleted date in "Last Synced" column
|
|
||||||
- Grayed-out appearance
|
|
||||||
|
|
||||||
3. **Given** a policy was deleted,
|
|
||||||
**When** I view the Policy detail page,
|
|
||||||
**Then** I see:
|
|
||||||
- Warning banner: "This policy was deleted from Intune on {date}"
|
|
||||||
- All data remains readable (versions, snapshots, metadata)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Restore Workflow
|
|
||||||
|
|
||||||
**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
1. **Given** I view a deleted policy's detail page,
|
|
||||||
**When** I click the "Restore to Intune" action,
|
|
||||||
**Then** the restore wizard opens pre-filled with the latest policy snapshot
|
|
||||||
|
|
||||||
2. **Given** a policy is successfully restored to Intune,
|
|
||||||
**When** the next sync runs,
|
|
||||||
**Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Functional Requirements
|
|
||||||
|
|
||||||
### Data Model
|
|
||||||
|
|
||||||
**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern:
|
|
||||||
```php
|
|
||||||
Schema::table('policies', function (Blueprint $table) {
|
|
||||||
$table->softDeletes(); // deleted_at
|
|
||||||
$table->string('deleted_by')->nullable(); // admin email who triggered deletion
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**FR-005.2**: Policy model MUST use `SoftDeletes` trait:
|
|
||||||
```php
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
class Policy extends Model {
|
|
||||||
use SoftDeletes;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Policy Sync Behavior
|
|
||||||
|
|
||||||
**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies:
|
|
||||||
- Collect all `external_id` values returned by Graph API
|
|
||||||
- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)`
|
|
||||||
- Soft delete missing policies: `each(fn($p) => $p->delete())`
|
|
||||||
|
|
||||||
**FR-005.4**: System MUST restore policies that re-appear:
|
|
||||||
- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()`
|
|
||||||
- If soft-deleted: call `$policy->restore()`
|
|
||||||
- Update `last_synced_at` timestamp
|
|
||||||
|
|
||||||
**FR-005.5**: System MUST log audit entries:
|
|
||||||
- `policy.deleted` (when soft-deleted during sync)
|
|
||||||
- `policy.restored` (when re-appears in Intune)
|
|
||||||
|
|
||||||
### UI Display
|
|
||||||
|
|
||||||
**FR-005.6**: PolicyResource table MUST:
|
|
||||||
- Default query: exclude soft-deleted policies
|
|
||||||
- Add filter "Show Deleted" (includes `withTrashed()` in query)
|
|
||||||
- Show "Deleted" badge for soft-deleted policies
|
|
||||||
|
|
||||||
**FR-005.7**: BackupItemsRelationManager MUST:
|
|
||||||
- Show "Deleted" badge when `policy->trashed()` returns true
|
|
||||||
- Allow viewing deleted policy details (read-only)
|
|
||||||
|
|
||||||
**FR-005.8**: Policy detail view MUST:
|
|
||||||
- Show warning banner when policy is soft-deleted
|
|
||||||
- Display deletion date and reason (if available)
|
|
||||||
- Disable edit actions (policy no longer exists in Intune)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
|
||||||
|
|
||||||
**NFR-005.1**: Soft delete MUST NOT break existing features:
|
|
||||||
- Backup Items keep valid foreign keys
|
|
||||||
- Policy Versions remain accessible
|
|
||||||
- Restore functionality works for deleted policies
|
|
||||||
|
|
||||||
**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries:
|
|
||||||
- Use single `whereNotIn()` query to find missing policies
|
|
||||||
- Batch soft-delete operation
|
|
||||||
|
|
||||||
**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Data Model (30 min)
|
|
||||||
1. Create migration for `policies` soft delete columns
|
|
||||||
2. Add `SoftDeletes` trait to Policy model
|
|
||||||
3. Run migration on dev environment
|
|
||||||
|
|
||||||
### Phase 2: Sync Logic (1 hour)
|
|
||||||
1. Update `PolicySyncService::syncPolicies()`
|
|
||||||
- Track current external IDs from Graph
|
|
||||||
- Soft delete missing policies
|
|
||||||
- Restore re-appeared policies
|
|
||||||
2. Add audit logging
|
|
||||||
3. Test with manual deletion in Intune
|
|
||||||
|
|
||||||
### Phase 3: UI Indicators (1.5 hours)
|
|
||||||
1. Update `PolicyResource`:
|
|
||||||
- Add "Show Deleted" filter
|
|
||||||
- Add "Deleted" badge column
|
|
||||||
- Modify query to exclude deleted by default
|
|
||||||
2. Update `BackupItemsRelationManager`:
|
|
||||||
- Show "Deleted" badge for `policy->trashed()`
|
|
||||||
3. Update Policy detail view:
|
|
||||||
- Warning banner for deleted policies
|
|
||||||
- Disable edit actions
|
|
||||||
|
|
||||||
### Phase 4: Testing (1 hour)
|
|
||||||
1. Unit tests:
|
|
||||||
- Test soft delete on sync
|
|
||||||
- Test restore on re-appearance
|
|
||||||
2. Feature tests:
|
|
||||||
- E2E sync with deleted policies
|
|
||||||
- UI filter behavior
|
|
||||||
3. Manual QA:
|
|
||||||
- Delete policy in Intune → sync → verify soft delete
|
|
||||||
- Re-create policy → sync → verify restore
|
|
||||||
|
|
||||||
**Total Estimated Duration**: ~4-5 hours
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|------|------------|
|
|
||||||
| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid |
|
|
||||||
| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies |
|
|
||||||
| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle
|
|
||||||
2. ✅ Re-appearing policies are automatically restored
|
|
||||||
3. ✅ UI clearly indicates deleted status
|
|
||||||
4. ✅ Backup Items and Versions remain accessible for deleted policies
|
|
||||||
5. ✅ No breaking changes to existing features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Features
|
|
||||||
- Feature 004: Assignments & Scope Tags (discovered this issue during testing)
|
|
||||||
- Feature 001: Backup/Restore (must work with deleted policies)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: Planned (Post-Feature 004)
|
|
||||||
**Priority**: P2 (Quality of Life improvement)
|
|
||||||
**Created**: 2025-12-22
|
|
||||||
**Author**: AI + Ahmed
|
|
||||||
**Next Steps**: Implement after Feature 004 Phase 3 testing complete
|
|
||||||
Loading…
Reference in New Issue
Block a user