@salesforce/afv-skills 1.47.0 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/education-cloud-domain-configure/SKILL.md +234 -0
- package/skills/education-cloud-domain-configure/references/dependent-features.md +198 -0
- package/skills/education-cloud-domain-configure/references/domain-features/academic-operations.md +386 -0
- package/skills/education-cloud-domain-configure/references/domain-features/alumni-relations.md +155 -0
- package/skills/education-cloud-domain-configure/references/domain-features/cross-domain-dependencies.md +18 -0
- package/skills/education-cloud-domain-configure/references/domain-features/industriessettings-toggle-reference.md +43 -0
- package/skills/education-cloud-domain-configure/references/domain-features/mentoring.md +198 -0
- package/skills/education-cloud-domain-configure/references/domain-features/recruitment-admissions.md +315 -0
- package/skills/education-cloud-domain-configure/references/domain-features/student-management.md +155 -0
- package/skills/education-cloud-domain-configure/references/domain-features/student-success.md +249 -0
- package/skills/education-cloud-domain-configure/references/domain-features.md +27 -0
- package/skills/education-cloud-domain-configure/references/gotchas-extended.md +23 -0
- package/skills/education-cloud-domain-configure/references/toggle-write-path.md +67 -0
- package/skills/education-cloud-domain-configure/scripts/check_license_provisioned.py +62 -0
- package/skills/education-cloud-domain-configure/scripts/check_org_edition.py +44 -0
- package/skills/education-cloud-domain-configure/scripts/check_permission_assigned.py +39 -0
- package/skills/education-cloud-multi-campus-configure/SKILL.md +2 -0
- package/skills/education-cloud-multi-campus-configure/references/foundation_prerequisites.md +4 -3
- package/skills/service-agentforce-channel-configure/SKILL.md +33 -28
- package/skills/service-agentforce-human-escalation-configure/SKILL.md +205 -0
- package/skills/service-agentforce-human-escalation-configure/assets/escalation-thresholds.instructions.md +37 -0
- package/skills/service-agentforce-human-escalation-configure/assets/force-app/main/default/flows/Human_Escalation_Outbound_Flow.flow-meta.xml +173 -0
- package/skills/service-agentforce-human-escalation-configure/assets/package.xml +8 -0
- package/skills/service-agentforce-human-escalation-configure/assets/sfdx-project.json +5 -0
- package/skills/service-agentforce-human-escalation-configure/references/classifier-contracts.md +103 -0
- package/skills/service-agentforce-human-escalation-configure/references/escalation-mechanism.md +63 -0
- package/skills/service-agentforce-human-escalation-configure/references/failure-policy.md +44 -0
- package/skills/service-agentforce-human-escalation-configure/references/nga-escalation.md +57 -0
- package/skills/service-agentforce-human-escalation-configure/references/runtime-verification.md +72 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/classify-agent-active.mjs +49 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/classify-nga-escalation.mjs +166 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/extract-outbound-route.mjs +67 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/patch-escalation-surfaces.mjs +131 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/tests/__init__.py +0 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/tests/_bootstrap.py +249 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/tests/test_escalation_contracts.py +899 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/verify-and-configure.sh +705 -0
- package/skills/service-agentforce-human-escalation-configure/scripts/verify-escalation-config.mjs +89 -0
- package/skills/service-de-channel-routing-configure/SKILL.md +13 -5
- package/skills/service-de-channel-routing-configure/scripts/tests/test_skill_contract.py +25 -0
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +23 -10
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +63 -48
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/scripts/verify-child-verdict.mjs +6 -2
- package/skills/service-omni-agent-users-create/SKILL.md +96 -0
- package/skills/service-omni-agent-users-create/assets/create-users.apex.template +80 -0
- package/skills/service-omni-agent-users-create/references/apex-patterns.md +135 -0
- package/skills/service-omni-agent-users-create/references/apex-template-notes.md +40 -0
- package/skills/service-omni-agent-users-create/scripts/detect-and-create.sh +206 -0
- package/skills/service-omni-agent-users-create/scripts/detect-existing.sh +122 -0
- package/skills/service-omni-agent-users-create/scripts/run-create.sh +298 -0
- package/skills/service-omni-agent-users-create/scripts/tests/test_user_create_security.py +171 -0
- package/skills/service-omni-agent-work-sharing-configure/SKILL.md +78 -0
- package/skills/service-omni-agent-work-sharing-configure/references/api-notes.md +31 -0
- package/skills/service-omni-agent-work-sharing-configure/scripts/configure-and-report.sh +186 -0
- package/skills/service-omni-agent-work-sharing-configure/scripts/tests/_bootstrap.py +120 -0
- package/skills/service-omni-agent-work-sharing-configure/scripts/tests/test_agent_work_sharing_contracts.py +93 -0
- package/skills/service-omni-base-settings-configure/SKILL.md +77 -0
- package/skills/service-omni-base-settings-configure/assets/force-app/main/default/settings/OmniChannel.settings-meta.xml +8 -0
- package/skills/service-omni-base-settings-configure/assets/package.xml +8 -0
- package/skills/service-omni-base-settings-configure/assets/sfdx-project.json +12 -0
- package/skills/service-omni-base-settings-configure/references/api-notes.md +121 -0
- package/skills/service-omni-base-settings-configure/scripts/configure-and-report.sh +164 -0
- package/skills/service-omni-base-settings-configure/scripts/detect-existing.sh +69 -0
- package/skills/service-omni-channel-setup-coordinate/SKILL.md +168 -0
- package/skills/service-omni-channel-setup-coordinate/examples/output-templates.md +161 -0
- package/skills/service-omni-channel-setup-coordinate/references/gap-catalog.md +99 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/integration-driver.sh +1542 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/__init__.py +0 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/_bootstrap.py +223 -0
- package/skills/service-omni-channel-setup-coordinate/scripts/tests/test_omni_contracts.py +804 -0
- package/skills/service-omni-command-center-analyze/SKILL.md +84 -0
- package/skills/service-omni-command-center-analyze/references/api-notes.md +54 -0
- package/skills/service-omni-command-center-analyze/scripts/analyze.sh +198 -0
- package/skills/service-omni-command-center-analyze/scripts/tests/_bootstrap.py +115 -0
- package/skills/service-omni-command-center-analyze/scripts/tests/test_command_center_analyze_contracts.py +165 -0
- package/skills/service-omni-permission-set-assign/SKILL.md +88 -0
- package/skills/service-omni-permission-set-assign/assets/package.xml +8 -0
- package/skills/service-omni-permission-set-assign/references/api-notes.md +143 -0
- package/skills/service-omni-permission-set-assign/scripts/verify-and-assign.sh +397 -0
- package/skills/service-omni-presence-status-deploy/SKILL.md +87 -0
- package/skills/service-omni-presence-status-deploy/assets/package.xml +12 -0
- package/skills/service-omni-presence-status-deploy/references/api-notes.md +115 -0
- package/skills/service-omni-presence-status-deploy/scripts/deploy-and-report.sh +422 -0
- package/skills/service-omni-presence-user-config-deploy/SKILL.md +80 -0
- package/skills/service-omni-presence-user-config-deploy/references/api-notes.md +60 -0
- package/skills/service-omni-presence-user-config-deploy/scripts/deploy-and-report.sh +261 -0
- package/skills/service-omni-presence-user-config-deploy/scripts/tests/_bootstrap.py +106 -0
- package/skills/service-omni-presence-user-config-deploy/scripts/tests/test_presence_user_config_contracts.py +142 -0
- package/skills/service-omni-queue-deploy/SKILL.md +91 -0
- package/skills/service-omni-queue-deploy/references/api-notes.md +140 -0
- package/skills/service-omni-queue-deploy/scripts/verify-and-align.sh +470 -0
- package/skills/service-omni-queue-members-assign/SKILL.md +93 -0
- package/skills/service-omni-queue-members-assign/references/api-notes.md +144 -0
- package/skills/service-omni-queue-members-assign/scripts/tests/_bootstrap.py +67 -0
- package/skills/service-omni-queue-members-assign/scripts/tests/test_queue_members_contracts.py +73 -0
- package/skills/service-omni-queue-members-assign/scripts/verify-and-bind.sh +362 -0
- package/skills/service-omni-queue-routing-config-deploy/SKILL.md +101 -0
- package/skills/service-omni-queue-routing-config-deploy/references/api-notes.md +127 -0
- package/skills/service-omni-queue-routing-config-deploy/scripts/tests/_bootstrap.py +90 -0
- package/skills/service-omni-queue-routing-config-deploy/scripts/tests/test_queue_routing_config_contracts.py +70 -0
- package/skills/service-omni-queue-routing-config-deploy/scripts/upsert-and-report.sh +538 -0
- package/skills/service-omni-routing-flow-deploy/SKILL.md +111 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.SkillsBased.flow-meta.xml +94 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.flow-meta.xml +88 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Cases.flow-meta.xml +144 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.SkillsBased.flow-meta.xml +94 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.flow-meta.xml +88 -0
- package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCalls.flow-meta.xml +144 -0
- package/skills/service-omni-routing-flow-deploy/assets/package.xml +11 -0
- package/skills/service-omni-routing-flow-deploy/assets/sfdx-project.json +11 -0
- package/skills/service-omni-routing-flow-deploy/references/api-notes.md +265 -0
- package/skills/service-omni-routing-flow-deploy/scripts/deploy-and-report.sh +684 -0
- package/skills/service-omni-service-channel-configure/SKILL.md +83 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Cases.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Incidents.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/MessagingSessions.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/VoiceCalls.serviceChannel-meta.xml +9 -0
- package/skills/service-omni-service-channel-configure/assets/package.xml +11 -0
- package/skills/service-omni-service-channel-configure/assets/sfdx-project.json +12 -0
- package/skills/service-omni-service-channel-configure/references/api-notes.md +84 -0
- package/skills/service-omni-service-channel-configure/scripts/deploy-and-report.sh +383 -0
- package/skills/service-omni-sidebar-configure/SKILL.md +77 -0
- package/skills/service-omni-sidebar-configure/references/api-notes.md +51 -0
- package/skills/service-omni-sidebar-configure/scripts/enable-and-report.sh +315 -0
- package/skills/service-omni-sidebar-configure/scripts/tests/_bootstrap.py +142 -0
- package/skills/service-omni-sidebar-configure/scripts/tests/test_sidebar_contracts.py +139 -0
- package/skills/service-omni-skills-based-routing-configure/SKILL.md +81 -0
- package/skills/service-omni-skills-based-routing-configure/references/api-notes.md +50 -0
- package/skills/service-omni-skills-based-routing-configure/scripts/configure-and-report.sh +270 -0
- package/skills/service-omni-skills-based-routing-configure/scripts/tests/_bootstrap.py +120 -0
- package/skills/service-omni-skills-based-routing-configure/scripts/tests/test_sbr_contracts.py +129 -0
- package/skills/service-omni-supervisor-config-deploy/SKILL.md +97 -0
- package/skills/service-omni-supervisor-config-deploy/assets/force-app/main/default/omniSupervisorConfigs/Omni_Supervisor.omniSupervisorConfig-meta.xml +9 -0
- package/skills/service-omni-supervisor-config-deploy/assets/package.xml +8 -0
- package/skills/service-omni-supervisor-config-deploy/assets/sfdx-project.json +12 -0
- package/skills/service-omni-supervisor-config-deploy/references/api-notes.md +205 -0
- package/skills/service-omni-supervisor-config-deploy/scripts/deploy-and-report.sh +586 -0
- package/skills/service-omni-supervisor-config-deploy/scripts/tests/_bootstrap.py +153 -0
- package/skills/service-omni-supervisor-config-deploy/scripts/tests/test_supervisor_config_contracts.py +116 -0
- package/skills/service-omni-supervisor-permset-assign/SKILL.md +88 -0
- package/skills/service-omni-supervisor-permset-assign/references/api-notes.md +48 -0
- package/skills/service-omni-supervisor-permset-assign/scripts/verify-and-assign.sh +316 -0
- package/skills/service-omni-supervisor-surface-deploy/SKILL.md +85 -0
- package/skills/service-omni-supervisor-surface-deploy/references/api-notes.md +47 -0
- package/skills/service-omni-supervisor-surface-deploy/scripts/deploy-and-report.sh +216 -0
- package/skills/service-omni-supervisor-surface-deploy/scripts/tests/_bootstrap.py +95 -0
- package/skills/service-omni-supervisor-surface-deploy/scripts/tests/test_supervisor_surface_contracts.py +154 -0
- package/skills/service-omni-supervisor-users-create/SKILL.md +92 -0
- package/skills/service-omni-supervisor-users-create/assets/create-supervisors.apex.template +72 -0
- package/skills/service-omni-supervisor-users-create/references/apex-patterns.md +73 -0
- package/skills/service-omni-supervisor-users-create/references/apex-template-notes.md +41 -0
- package/skills/service-omni-supervisor-users-create/scripts/detect-and-create.sh +197 -0
- package/skills/service-omni-supervisor-users-create/scripts/detect-existing.sh +112 -0
- package/skills/service-omni-supervisor-users-create/scripts/run-create.sh +296 -0
- package/skills/service-omni-work-skill-routing-configure/SKILL.md +80 -0
- package/skills/service-omni-work-skill-routing-configure/references/api-notes.md +67 -0
- package/skills/service-omni-work-skill-routing-configure/scripts/configure-and-report.sh +262 -0
- package/skills/service-omni-work-skill-routing-configure/scripts/tests/_bootstrap.py +121 -0
- package/skills/service-omni-work-skill-routing-configure/scripts/tests/test_work_skill_routing_contracts.py +161 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# API notes — GroupMember binding for Omni Queue
|
|
2
|
+
|
|
3
|
+
## `GroupMember` sObject shape (v66)
|
|
4
|
+
|
|
5
|
+
The `GroupMember` sObject represents a user's (or nested group's) membership in a Salesforce `Group`. On Salesforce, a **Queue is a specialized `Group`** — same underlying table, distinguished by `Group.Type='Queue'` vs. `Type='Regular'` (public groups). Every membership row lives in `GroupMember`.
|
|
6
|
+
|
|
7
|
+
Fields we care about:
|
|
8
|
+
|
|
9
|
+
| Field | Type | Purpose |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `Id` | 18-char Id | The membership row's Id, used for teardown |
|
|
12
|
+
| `GroupId` | Reference (`Group.Id`) | The queue/public-group this membership belongs to |
|
|
13
|
+
| `UserOrGroupId` | Polymorphic reference (`User.Id` or `Group.Id`) | The member being bound. For agent-to-queue bindings this is always a `User.Id` |
|
|
14
|
+
| `SystemModstamp` | Datetime | Server-side write timestamp; useful in idempotency checks |
|
|
15
|
+
|
|
16
|
+
**We never set:**
|
|
17
|
+
- `CreatedById`, `CreatedDate`, `LastModifiedById`, `LastModifiedDate` — Salesforce writes these on insert; supplying them is a compile-time error on the Data API.
|
|
18
|
+
|
|
19
|
+
## Why we query, not upsert
|
|
20
|
+
|
|
21
|
+
`GroupMember` has no external ID field and no natural composite key exposed as an upsert target. The Data API's `PATCH /sobjects/GroupMember/<externalId__c>/<value>` pattern is unavailable. The only insert paths are:
|
|
22
|
+
|
|
23
|
+
- **POST** with `{GroupId, UserOrGroupId}` — creates a new row. Duplicate `(GroupId, UserOrGroupId)` tuples raise `DUPLICATE_VALUE`.
|
|
24
|
+
- **Metadata API `Queue` deploy with `<queueMembers>` block** — replaces membership atomically (dangerous — will remove out-of-band members).
|
|
25
|
+
|
|
26
|
+
For a skill that must be idempotent AND non-destructive, the pattern is:
|
|
27
|
+
|
|
28
|
+
1. SOQL for existing memberships → set of `existing_user_ids`
|
|
29
|
+
2. Compute `missing_user_ids = expected − existing_user_ids`
|
|
30
|
+
3. POST one row per missing user (individually — no batching, no rollback semantics needed at this scale)
|
|
31
|
+
4. Re-query to confirm.
|
|
32
|
+
|
|
33
|
+
This is why the skill is called `verify-and-bind.sh`, not `upsert-and-report.sh` — no upsert primitive exists.
|
|
34
|
+
|
|
35
|
+
## SOQL patterns used
|
|
36
|
+
|
|
37
|
+
**Locate the queue:**
|
|
38
|
+
|
|
39
|
+
```sql
|
|
40
|
+
SELECT Id
|
|
41
|
+
FROM Group
|
|
42
|
+
WHERE DeveloperName = 'CaseQueue' AND Type = 'Queue'
|
|
43
|
+
```text
|
|
44
|
+
|
|
45
|
+
- `Type='Queue'` matters — a public group and a queue can share the same DeveloperName (rare, but possible on orgs with hand-crafted metadata).
|
|
46
|
+
- We use `DeveloperName`, not `Name` — Name is the localized label and can drift; DeveloperName is the stable API identifier.
|
|
47
|
+
|
|
48
|
+
**Locate the agent users:**
|
|
49
|
+
|
|
50
|
+
```sql
|
|
51
|
+
SELECT Id, Username
|
|
52
|
+
FROM User
|
|
53
|
+
WHERE Username IN ('agent1.0abc123a@example.com', 'agent2.0abc123a@example.com', 'agent3.0abc123a@example.com')
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- We use `Username` (globally unique), not `Email` (not unique) or `Alias` (not unique).
|
|
57
|
+
- No `IsActive` filter — inactive users can still be queue members (they just don't receive new work); we want to bind them regardless.
|
|
58
|
+
|
|
59
|
+
**Locate existing memberships:**
|
|
60
|
+
|
|
61
|
+
```sql
|
|
62
|
+
SELECT Id, UserOrGroupId
|
|
63
|
+
FROM GroupMember
|
|
64
|
+
WHERE GroupId = '00GRZ000004esvR2AQ'
|
|
65
|
+
AND UserOrGroupId IN ('005RZ0000005xyzAAA', '005RZ0000005abcBBB', '005RZ0000005defCCC')
|
|
66
|
+
```text
|
|
67
|
+
|
|
68
|
+
- Scope the query to the specific queue + specific users, not all queue members — avoids returning hundreds of unrelated rows on production-like orgs.
|
|
69
|
+
- The polymorphic `UserOrGroupId` filter works for User Ids; the query returns only User-typed members.
|
|
70
|
+
|
|
71
|
+
## POST payload
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
POST /services/data/v66.0/sobjects/GroupMember
|
|
75
|
+
{
|
|
76
|
+
"GroupId": "00GRZ000004esvR2AQ",
|
|
77
|
+
"UserOrGroupId": "005RZ0000005abcBBB"
|
|
78
|
+
}
|
|
79
|
+
```text
|
|
80
|
+
|
|
81
|
+
- Success: HTTP 201 with `{"id":"011RZ00000ABCDE0AB","success":true,"errors":[]}`.
|
|
82
|
+
- **Duplicate write:** Salesforce server-side dedupes `GroupMember` on the (`GroupId`, `UserOrGroupId`) uniqueness constraint at the persistence layer. A duplicate POST returns HTTP 201 with `success:true` and the **existing row's Id** — no `DUPLICATE_VALUE` error is raised, and no new row is created. Successive POSTs of the same tuple produce exactly one GroupMember row.
|
|
83
|
+
- Implication: the skill's detect step (querying existing memberships) is the primary mechanism preventing inflated `bound_count` on re-runs. Without detect, every re-run would report all N users as newly "bound" even though no new rows were created.
|
|
84
|
+
- The `DUPLICATE_VALUE` branch in the skill's script is retained for future-proofing (in case Salesforce changes this behavior in a future API version) but is not currently reachable.
|
|
85
|
+
|
|
86
|
+
## `sf api request rest` output channel
|
|
87
|
+
|
|
88
|
+
The `sf api request rest` CLI command emits a `Warning: This command is currently in beta. Any aspect of this command can change without advanced notice. Don't use beta commands in your scripts.` line to **stderr** on every invocation. The JSON response body goes to **stdout**.
|
|
89
|
+
|
|
90
|
+
**Do NOT** capture with `2>&1` when the output is going to be parsed by `jq`, because the Warning line becomes the first line of the merged stream and every subsequent `jq '.field'` call silently fails (jq returns exit-1 for non-JSON input, which gets swallowed by `|| echo "false"` fallbacks).
|
|
91
|
+
|
|
92
|
+
**Correct pattern:**
|
|
93
|
+
```bash
|
|
94
|
+
POST_STDERR=$(mktemp)
|
|
95
|
+
POST_RESULT=$(sf api request rest ... 2>"$POST_STDERR" || true)
|
|
96
|
+
POST_SUCCESS=$(echo "$POST_RESULT" | jq -r '.success // false')
|
|
97
|
+
# On failure, read $POST_STDERR (excluding Warning: lines) for the real error
|
|
98
|
+
rm -f "$POST_STDERR"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Also acceptable:** `2>/dev/null` if you don't need to surface CLI-level errors (auth failures, network drops). But redirecting to a temp file preserves that visibility.
|
|
102
|
+
|
|
103
|
+
## Common failure modes and how the skill translates them
|
|
104
|
+
|
|
105
|
+
| Salesforce error | Skill translation | Actionable? |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| ~~`DUPLICATE_VALUE` on POST~~ | ~~Treat as reused~~ | **Not reachable** — GroupMember dedupes at the persistence layer (see above) |
|
|
108
|
+
| Non-JSON stdout (Warning line) | Skill bug — script merged stderr into stdout | Yes — use the temp-file pattern |
|
|
109
|
+
| `MALFORMED_ID` | Skill bug — user Id or group Id malformed before POST | Yes — file an issue against the skill |
|
|
110
|
+
| `INSUFFICIENT_ACCESS_OR_READONLY` on POST | Executing user lacks `ModifyAllData` or queue-write perm | Yes — re-authenticate as a System Administrator |
|
|
111
|
+
| `INVALID_TYPE` on GroupMember SOQL | Never seen — GroupMember is universally available | If it happens, the org is corrupt; escalate |
|
|
112
|
+
| `INVALID_FIELD` on `GroupMember.CreatedDate` | `GroupMember` does not expose `CreatedDate` in SOQL (nor `CreatedById`, `LastModifiedDate`, `SystemModstamp` — they exist internally but are not queryable). Non-blocking for this skill. | Query only `Id`, `GroupId`, `UserOrGroupId` — the reliably queryable columns |
|
|
113
|
+
| Empty `User` query result (any expected username missing) | The agent users were not created, or were created with a different `count`/suffix | Yes — re-run `service-omni-agent-users-create` with the same count |
|
|
114
|
+
| `Group` DeveloperName not found | The queue is missing (or was renamed) | Yes — re-run `service-omni-queue-deploy`, or create the queue manually per its click-path |
|
|
115
|
+
|
|
116
|
+
## Why individual POSTs, not composite tree
|
|
117
|
+
|
|
118
|
+
Salesforce supports `POST /composite/tree/GroupMember` for batching up to 200 inserts atomically. We deliberately avoid it here because:
|
|
119
|
+
|
|
120
|
+
- **Blast radius.** A single bad user Id rolls back all inserts in a tree request. Individual POSTs isolate failures — one duplicate doesn't sink the batch.
|
|
121
|
+
- **Report clarity.** Individual POST responses give us per-user status directly; parsing composite-tree responses adds ~40 lines of jq without any latency win at N ≤ 10.
|
|
122
|
+
- **Idempotency reasoning.** With individual POSTs, the loop `for missing_user in missing_users: POST; on-duplicate: skip` is transparent. A tree request would need pre-filtering by re-querying inside the batch, adding a race window.
|
|
123
|
+
|
|
124
|
+
For skills that need to bind hundreds of users (a v1.1 concern — supervisor tools), we'd revisit this and use `/composite/tree` with client-side duplicate stripping.
|
|
125
|
+
|
|
126
|
+
## Teardown hook (not implemented in v1)
|
|
127
|
+
|
|
128
|
+
A future `service-omni-queue-members-assign-remove` skill would:
|
|
129
|
+
|
|
130
|
+
1. Query `GroupMember` where `GroupId=<queue-id>` AND `UserOrGroupId IN <agent-user-ids>` → collect Ids to delete.
|
|
131
|
+
2. DELETE `/sobjects/GroupMember/<id>` one at a time.
|
|
132
|
+
3. Re-verify final member count.
|
|
133
|
+
|
|
134
|
+
Not part of the v1 coordinator's forward path — teardown is out of scope for the routing-setup coordinator; a separate `teardown-coordinate` skill will handle it holistically.
|
|
135
|
+
|
|
136
|
+
## Why not Apex?
|
|
137
|
+
|
|
138
|
+
Compared to `service-omni-agent-users-create`, this skill does not use Anonymous Apex because:
|
|
139
|
+
|
|
140
|
+
- **No transactional invariant.** User creation needs `insert User + System.setPassword` in one transaction — if setPassword fails, we want the User rolled back. GroupMember has no such coupling.
|
|
141
|
+
- **No policy enforcement.** User creation triggers profile-validation, license-consumption, and password-policy Apex-side checks that are cleaner to observe in a debug log. GroupMember insert is a simple two-field row.
|
|
142
|
+
- **Simpler operator debugging.** REST POSTs return structured JSON; Apex debug log parsing requires anchored regexes.
|
|
143
|
+
|
|
144
|
+
The tradeoff: an Apex approach could bulk-insert all N rows in one transaction (~1 API round-trip instead of N). At N ≤ 10 the difference is a second or two — not worth the debugging complexity.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared paths + fake-`sf` harness for the queue-members-assign contract suite.
|
|
2
|
+
|
|
3
|
+
Drives ``verify-and-bind.sh`` via ``subprocess`` and asserts on exit codes + emitted JSON.
|
|
4
|
+
Org-free and network-free. ``jq`` comes from the real PATH.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SKILLS_ROOT = Path(__file__).resolve().parents[3]
|
|
14
|
+
SKILL_DIR = "service-omni-queue-members-assign"
|
|
15
|
+
SCRIPT = f"{SKILL_DIR}/scripts/verify-and-bind.sh"
|
|
16
|
+
|
|
17
|
+
# org display succeeds, but the Organization safe_to_write query FAILS (non-zero, stderr-only).
|
|
18
|
+
# Reproduces an auth/network/API failure: the script must emit structured blocked JSON on stdout
|
|
19
|
+
# instead of a silent `set -e` exit.
|
|
20
|
+
_FAKE_SF_ORG_QUERY_FAILS = r"""#!/usr/bin/env bash
|
|
21
|
+
sub="${1:-} ${2:-}"; args="$*"
|
|
22
|
+
case "$sub" in
|
|
23
|
+
"org display") echo '{"status":0,"result":{"id":"00Dxx0000000000EAA"}}'; exit 0;;
|
|
24
|
+
"data query")
|
|
25
|
+
if printf '%s' "$args" | grep -q "FROM Organization"; then
|
|
26
|
+
echo "ERROR running data query: INVALID_SESSION_ID" >&2
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
echo '{"status":0,"result":{"records":[],"totalSize":0}}'; exit 0;;
|
|
30
|
+
*) echo '{"status":0,"result":{}}'; exit 0;;
|
|
31
|
+
esac
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(args, path_prefix=None, scrub_sf=False, extra_env=None):
|
|
36
|
+
env = dict(os.environ)
|
|
37
|
+
if scrub_sf:
|
|
38
|
+
kept = [d for d in env.get("PATH", "").split(os.pathsep)
|
|
39
|
+
if d and not os.path.exists(os.path.join(d, "sf"))]
|
|
40
|
+
env["PATH"] = os.pathsep.join(kept)
|
|
41
|
+
if path_prefix:
|
|
42
|
+
env["PATH"] = path_prefix + os.pathsep + env.get("PATH", "")
|
|
43
|
+
if extra_env:
|
|
44
|
+
env.update({k: str(v) for k, v in extra_env.items()})
|
|
45
|
+
proc = subprocess.run(
|
|
46
|
+
["bash", str(SKILLS_ROOT / SCRIPT), *args],
|
|
47
|
+
capture_output=True, text=True, env=env, cwd=str(SKILLS_ROOT),
|
|
48
|
+
)
|
|
49
|
+
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FakeSf:
|
|
53
|
+
def __init__(self, body=_FAKE_SF_ORG_QUERY_FAILS):
|
|
54
|
+
self._body = body
|
|
55
|
+
self._tmp = None
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
self._tmp = tempfile.mkdtemp(prefix="qm-fake-sf-")
|
|
59
|
+
sf = Path(self._tmp) / "sf"
|
|
60
|
+
sf.write_text(self._body)
|
|
61
|
+
sf.chmod(0o755)
|
|
62
|
+
return self._tmp
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *exc):
|
|
65
|
+
import shutil
|
|
66
|
+
if self._tmp:
|
|
67
|
+
shutil.rmtree(self._tmp, ignore_errors=True)
|
package/skills/service-omni-queue-members-assign/scripts/tests/test_queue_members_contracts.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Contract tests for service-omni-queue-members-assign/scripts/verify-and-bind.sh."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
from _bootstrap import ( # noqa: E402
|
|
13
|
+
SCRIPT,
|
|
14
|
+
SKILLS_ROOT,
|
|
15
|
+
FakeSf,
|
|
16
|
+
_FAKE_SF_ORG_QUERY_FAILS,
|
|
17
|
+
run,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _last_json(out: str) -> dict:
|
|
22
|
+
dec = json.JSONDecoder()
|
|
23
|
+
obj, i, n = None, 0, len(out)
|
|
24
|
+
while i < n:
|
|
25
|
+
if out[i] in "{[":
|
|
26
|
+
try:
|
|
27
|
+
cand, end = dec.raw_decode(out, i)
|
|
28
|
+
if isinstance(cand, dict):
|
|
29
|
+
obj = cand
|
|
30
|
+
i = end
|
|
31
|
+
continue
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
pass
|
|
34
|
+
i += 1
|
|
35
|
+
assert obj is not None, f"no JSON object found in output:\n{out}"
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SyntaxTests(unittest.TestCase):
|
|
40
|
+
def test_bash_syntax_valid(self):
|
|
41
|
+
proc = subprocess.run(["bash", "-n", str(SKILLS_ROOT / SCRIPT)],
|
|
42
|
+
capture_output=True, text=True)
|
|
43
|
+
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InputContractTests(unittest.TestCase):
|
|
47
|
+
def test_missing_args_rejected(self):
|
|
48
|
+
rc, out = run([], scrub_sf=True)
|
|
49
|
+
self.assertEqual(rc, 1, out)
|
|
50
|
+
self.assertIn("Usage", out)
|
|
51
|
+
|
|
52
|
+
def test_injection_shaped_queue_name_rejected(self):
|
|
53
|
+
rc, out = run(["org", "Bad Name; DROP"], scrub_sf=True)
|
|
54
|
+
self.assertEqual(rc, 1, out)
|
|
55
|
+
self.assertIn("Invalid queue_developer_name", out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OrgQueryFailClosedTests(unittest.TestCase):
|
|
59
|
+
def test_org_query_failure_emits_blocked_json_on_stdout(self):
|
|
60
|
+
# Regression (blocker 2): a failing Organization safe_to_write query used to make the command
|
|
61
|
+
# substitution non-zero and `set -e` killed the script before any JSON. It must now fail closed
|
|
62
|
+
# with a structured blocked result on stdout.
|
|
63
|
+
with FakeSf(_FAKE_SF_ORG_QUERY_FAILS) as p:
|
|
64
|
+
rc, out = run(["sandbox", "CaseQueue"], path_prefix=p)
|
|
65
|
+
self.assertEqual(rc, 1, out)
|
|
66
|
+
self.assertTrue(out.strip(), "expected structured JSON, got empty output")
|
|
67
|
+
v = _last_json(out)
|
|
68
|
+
self.assertEqual(v["status"], "blocked", out)
|
|
69
|
+
self.assertIn("Organization", v["blocking_issue"], out)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# verify-and-bind.sh - bind agent users into a Queue via GroupMember POSTs (explicit usernames/IDs or the generated demo pattern); idempotent, non-destructive (never removes members). Args, output contract, exit codes: SKILL.md.
|
|
3
|
+
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
if [ $# -lt 1 ]; then
|
|
7
|
+
echo '{"error":"Usage: bash verify-and-bind.sh <org-alias> [<queue-developer-name>] [<count>] [<explicit-members-csv>]"}' >&2
|
|
8
|
+
exit 1
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
ORG="$1"
|
|
12
|
+
QUEUE_DN="${2:-CaseQueue}"
|
|
13
|
+
COUNT="${3:-3}"
|
|
14
|
+
API_VERSION="v66.0"
|
|
15
|
+
USERNAME_PREFIX="agent"
|
|
16
|
+
USERNAME_DOMAIN="example.com"
|
|
17
|
+
|
|
18
|
+
# Explicit real-agent members: 4th positional, or env (IDs take precedence over usernames).
|
|
19
|
+
EXPLICIT_MEMBERS_CSV="${4:-${MEMBER_USER_IDS_CSV:-${MEMBER_USERNAMES_CSV:-}}}"
|
|
20
|
+
EXPLICIT_MODE="false"
|
|
21
|
+
if [ -n "$EXPLICIT_MEMBERS_CSV" ]; then
|
|
22
|
+
EXPLICIT_MODE="true"
|
|
23
|
+
# Derive COUNT from the explicit list length (trim blanks) - it overrides the positional count.
|
|
24
|
+
COUNT=$(printf '%s' "$EXPLICIT_MEMBERS_CSV" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | grep -c . || true)
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Sanity: count must be integer 1..10
|
|
28
|
+
if ! [[ "$COUNT" =~ ^[0-9]+$ ]] || [ "$COUNT" -lt 1 ] || [ "$COUNT" -gt 10 ]; then
|
|
29
|
+
echo "{\"error\":\"Invalid count '$COUNT' - must be integer 1..10${EXPLICIT_MODE:+ (derived from explicit-members-csv)}\"}" >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# QUEUE_DN is interpolated into SOQL, so validate its DeveloperName shape before any sf call.
|
|
34
|
+
if ! [[ "$QUEUE_DN" =~ ^[A-Za-z][A-Za-z0-9_]{0,79}$ ]]; then
|
|
35
|
+
echo "{\"error\":\"Invalid queue_developer_name '$QUEUE_DN'. Must start with a letter and contain only letters, digits, and underscores (max 80 chars).\"}" >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
if ! sf org display --target-org "$ORG" --json >/dev/null 2>&1; then
|
|
40
|
+
echo "{\"error\":\"Org alias '$ORG' is not authenticated. Run: sf org login web -a $ORG -r <my-domain-url>\"}" >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Helper: emit blocked JSON and exit 1
|
|
45
|
+
emit_blocked() {
|
|
46
|
+
local msg="$1"
|
|
47
|
+
local click_path="${2:-}"
|
|
48
|
+
local queue_id="${3:-null}"
|
|
49
|
+
local suffix="${4:-}"
|
|
50
|
+
|
|
51
|
+
local manual_actions='[]'
|
|
52
|
+
if [ -n "$click_path" ]; then
|
|
53
|
+
manual_actions=$(jq -n --arg cp "$click_path" '[{id: "MANUAL_FIX", title: "Manual fix required", click_path: $cp}]')
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
jq -n \
|
|
57
|
+
--arg msg "$msg" \
|
|
58
|
+
--arg dn "$QUEUE_DN" \
|
|
59
|
+
--arg qid "$queue_id" \
|
|
60
|
+
--arg suf "$suffix" \
|
|
61
|
+
--argjson count "$COUNT" \
|
|
62
|
+
--argjson ma "$manual_actions" \
|
|
63
|
+
'{
|
|
64
|
+
skill: "service-omni-queue-members-assign",
|
|
65
|
+
status: "blocked",
|
|
66
|
+
queue: {
|
|
67
|
+
developer_name: $dn,
|
|
68
|
+
id: (if $qid == "null" then null else $qid end)
|
|
69
|
+
},
|
|
70
|
+
org_suffix: (if $suf == "" then null else $suf end),
|
|
71
|
+
requested_count: $count,
|
|
72
|
+
before: null,
|
|
73
|
+
bound_this_run: [],
|
|
74
|
+
bound_count: 0,
|
|
75
|
+
reused_count: 0,
|
|
76
|
+
after: null,
|
|
77
|
+
manual_actions: $ma,
|
|
78
|
+
blocking_issue: $msg
|
|
79
|
+
}'
|
|
80
|
+
exit 1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Helper: emit read-only plan preview JSON and exit 0 (PLAN_ONLY mode)
|
|
84
|
+
emit_plan() {
|
|
85
|
+
local status="$1"
|
|
86
|
+
local detail="$2"
|
|
87
|
+
jq -n \
|
|
88
|
+
--arg status "$status" \
|
|
89
|
+
--arg detail "$detail" \
|
|
90
|
+
--arg dn "$QUEUE_DN" \
|
|
91
|
+
'{
|
|
92
|
+
skill: "service-omni-queue-members-assign",
|
|
93
|
+
status: $status,
|
|
94
|
+
plan_mode: true,
|
|
95
|
+
plan_detail: $detail,
|
|
96
|
+
queue: {developer_name: $dn},
|
|
97
|
+
manual_actions: [],
|
|
98
|
+
blocking_issue: null
|
|
99
|
+
}'
|
|
100
|
+
exit 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Step 1 - Organization + safe_to_write
|
|
104
|
+
# Fail-closed with structured JSON: without the `|| emit_blocked`, an auth/network/API failure makes
|
|
105
|
+
# the command substitution non-zero and `set -e` would kill the script before any blocked JSON is
|
|
106
|
+
# emitted. The jq -e guard covers a zero-exit-but-empty/unparseable response for the same reason.
|
|
107
|
+
ORG_JSON=$(sf data query --target-org "$ORG" \
|
|
108
|
+
--query "SELECT Id, IsSandbox, TrialExpirationDate, OrganizationType FROM Organization LIMIT 1" \
|
|
109
|
+
--json 2>/dev/null) \
|
|
110
|
+
|| emit_blocked "Could not query the Organization object (authentication, network, or API access failed). Re-run with a valid, reachable org connection." "" "null" ""
|
|
111
|
+
echo "$ORG_JSON" | jq -e '.result.records[0].Id' >/dev/null 2>&1 \
|
|
112
|
+
|| emit_blocked "Organization query returned no parseable record (inconclusive). Refusing to proceed without confirming the org is safe to write." "" "null" ""
|
|
113
|
+
|
|
114
|
+
ORG_ID=$(echo "$ORG_JSON" | jq -r '.result.records[0].Id')
|
|
115
|
+
IS_SANDBOX=$(echo "$ORG_JSON" | jq -r '.result.records[0].IsSandbox')
|
|
116
|
+
TRIAL_EXP=$(echo "$ORG_JSON" | jq -r '.result.records[0].TrialExpirationDate // "null"')
|
|
117
|
+
ORG_TYPE=$(echo "$ORG_JSON" | jq -r '.result.records[0].OrganizationType')
|
|
118
|
+
|
|
119
|
+
SAFE_TO_WRITE=false
|
|
120
|
+
if [ "$IS_SANDBOX" = "true" ]; then
|
|
121
|
+
SAFE_TO_WRITE=true
|
|
122
|
+
elif [ "$TRIAL_EXP" != "null" ]; then
|
|
123
|
+
SAFE_TO_WRITE=true
|
|
124
|
+
elif [ "$ORG_TYPE" = "Developer Edition" ] || [ "$ORG_TYPE" = "Base Edition" ]; then
|
|
125
|
+
SAFE_TO_WRITE=true
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
# Canonical org suffix: chars 10-17 of the 18-char Org Id, lowercased. Uses the SAME expression as the
|
|
129
|
+
# user-create leaves' detect-existing.sh so the generated agent/supervisor usernames match exactly.
|
|
130
|
+
SUFFIX=$(echo -n "${ORG_ID:10:8}" | tr '[:upper:]' '[:lower:]')
|
|
131
|
+
|
|
132
|
+
if [ "$SAFE_TO_WRITE" != "true" ]; then
|
|
133
|
+
emit_blocked "Refusing to bind queue members on a real production customer org (IsSandbox=$IS_SANDBOX, TrialExpirationDate=$TRIAL_EXP, OrganizationType=$ORG_TYPE). Target a sandbox, trial CDO, or Developer Edition org." "" "null" "$SUFFIX"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Step 2 - Locate the Queue
|
|
137
|
+
Q_JSON=$(sf data query --target-org "$ORG" \
|
|
138
|
+
--query "SELECT Id FROM Group WHERE DeveloperName='$QUEUE_DN' AND Type='Queue'" \
|
|
139
|
+
--json 2>/dev/null)
|
|
140
|
+
|
|
141
|
+
Q_COUNT=$(echo "$Q_JSON" | jq -r '.result.totalSize // 0' 2>/dev/null || echo "0")
|
|
142
|
+
if [ "$Q_COUNT" -eq 0 ]; then
|
|
143
|
+
emit_blocked \
|
|
144
|
+
"Queue '$QUEUE_DN' does not exist on org. Run service-omni-queue-deploy first, or create manually via the click-path." \
|
|
145
|
+
"Setup → Queues → New → Label: '$QUEUE_DN', DeveloperName: '$QUEUE_DN', Supported Objects: Case → Save" \
|
|
146
|
+
"null" "$SUFFIX"
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
QUEUE_ID=$(echo "$Q_JSON" | jq -r '.result.records[0].Id')
|
|
150
|
+
|
|
151
|
+
# Step 3 - Locate the users to bind (generated demo pattern OR explicit real agents)
|
|
152
|
+
if [ "$EXPLICIT_MODE" = "true" ]; then
|
|
153
|
+
# Explicit real agents: each token is a Username (...@...) or a 15/18-char User Id (005...).
|
|
154
|
+
USERNAME_IN=""
|
|
155
|
+
ID_IN=""
|
|
156
|
+
BADTOK=""
|
|
157
|
+
while IFS= read -r tok; do
|
|
158
|
+
[ -z "$tok" ] && continue
|
|
159
|
+
if [[ "$tok" =~ ^005[A-Za-z0-9]{12}([A-Za-z0-9]{3})?$ ]]; then
|
|
160
|
+
ID_IN="${ID_IN:+$ID_IN,}'$tok'"
|
|
161
|
+
elif [[ "$tok" =~ ^[^,\'\"[:space:]]+@[^,\'\"[:space:]]+$ ]]; then
|
|
162
|
+
USERNAME_IN="${USERNAME_IN:+$USERNAME_IN,}'$tok'"
|
|
163
|
+
else
|
|
164
|
+
BADTOK="$BADTOK '$tok'"
|
|
165
|
+
fi
|
|
166
|
+
done <<< "$(printf '%s' "$EXPLICIT_MEMBERS_CSV" | tr ',' '\n' | sed 's/^ *//;s/ *$//')"
|
|
167
|
+
|
|
168
|
+
if [ -n "$BADTOK" ]; then
|
|
169
|
+
emit_blocked "explicit-members-csv contains token(s) that are neither a Username (...@...) nor a 15/18-char User Id (005...):$BADTOK" "" "$QUEUE_ID" "$SUFFIX"
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
# Build the WHERE predicate from whichever token kinds were supplied.
|
|
173
|
+
WHERE_PARTS=""
|
|
174
|
+
[ -n "$USERNAME_IN" ] && WHERE_PARTS="Username IN ($USERNAME_IN)"
|
|
175
|
+
[ -n "$ID_IN" ] && WHERE_PARTS="${WHERE_PARTS:+$WHERE_PARTS OR }Id IN ($ID_IN)"
|
|
176
|
+
|
|
177
|
+
U_JSON=$(sf data query --target-org "$ORG" \
|
|
178
|
+
--query "SELECT Id, Username FROM User WHERE ($WHERE_PARTS) AND IsActive=true" \
|
|
179
|
+
--json 2>/dev/null)
|
|
180
|
+
|
|
181
|
+
U_COUNT=$(echo "$U_JSON" | jq -r '.result.totalSize // 0' 2>/dev/null || echo "0")
|
|
182
|
+
if [ "$U_COUNT" -lt "$COUNT" ]; then
|
|
183
|
+
FOUND=$(echo "$U_JSON" | jq -c '[.result.records[].Username]')
|
|
184
|
+
emit_blocked \
|
|
185
|
+
"Expected $COUNT ACTIVE user(s) from the explicit list; resolved only $U_COUNT active ($FOUND). Check the usernames/IDs are correct and the users are active." \
|
|
186
|
+
"" "$QUEUE_ID" "$SUFFIX"
|
|
187
|
+
fi
|
|
188
|
+
else
|
|
189
|
+
# Demo pattern: agent1..N.<suffix>@example.com
|
|
190
|
+
EXPECTED_USERNAMES=()
|
|
191
|
+
for i in $(seq 1 "$COUNT"); do
|
|
192
|
+
EXPECTED_USERNAMES+=("${USERNAME_PREFIX}${i}.${SUFFIX}@${USERNAME_DOMAIN}")
|
|
193
|
+
done
|
|
194
|
+
|
|
195
|
+
# Build SOQL IN clause: 'un1','un2',...
|
|
196
|
+
IN_CLAUSE=""
|
|
197
|
+
for un in "${EXPECTED_USERNAMES[@]}"; do
|
|
198
|
+
if [ -n "$IN_CLAUSE" ]; then IN_CLAUSE="$IN_CLAUSE,"; fi
|
|
199
|
+
IN_CLAUSE="$IN_CLAUSE'$un'"
|
|
200
|
+
done
|
|
201
|
+
|
|
202
|
+
# Only active users can be routed work; filter IsActive=true so an inactive occupant can't satisfy the count.
|
|
203
|
+
U_JSON=$(sf data query --target-org "$ORG" \
|
|
204
|
+
--query "SELECT Id, Username FROM User WHERE Username IN ($IN_CLAUSE) AND IsActive=true" \
|
|
205
|
+
--json 2>/dev/null)
|
|
206
|
+
|
|
207
|
+
U_COUNT=$(echo "$U_JSON" | jq -r '.result.totalSize // 0' 2>/dev/null || echo "0")
|
|
208
|
+
if [ "$U_COUNT" -lt "$COUNT" ]; then
|
|
209
|
+
FOUND=$(echo "$U_JSON" | jq -c '[.result.records[].Username]')
|
|
210
|
+
emit_blocked \
|
|
211
|
+
"Expected $COUNT ACTIVE agent users matching pattern 'agent{1..$COUNT}.$SUFFIX@example.com'; found $U_COUNT active ($FOUND). Run service-omni-agent-users-create with count=$COUNT first (and reactivate any deactivated agent users)." \
|
|
212
|
+
"" "$QUEUE_ID" "$SUFFIX"
|
|
213
|
+
fi
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
# Map username → userId, and build user Id array for downstream queries
|
|
217
|
+
USERS_JSON=$(echo "$U_JSON" | jq -c '[.result.records[] | {id: .Id, username: .Username}]')
|
|
218
|
+
USER_IDS_ARR=$(echo "$USERS_JSON" | jq -c '[.[].id]')
|
|
219
|
+
|
|
220
|
+
# Build SOQL IN clause for user IDs
|
|
221
|
+
USER_IDS_IN=""
|
|
222
|
+
for uid in $(echo "$USER_IDS_ARR" | jq -r '.[]'); do
|
|
223
|
+
if [ -n "$USER_IDS_IN" ]; then USER_IDS_IN="$USER_IDS_IN,"; fi
|
|
224
|
+
USER_IDS_IN="$USER_IDS_IN'$uid'"
|
|
225
|
+
done
|
|
226
|
+
|
|
227
|
+
# Step 4 - Query existing GroupMember rows for this queue + these users
|
|
228
|
+
GM_JSON=$(sf data query --target-org "$ORG" \
|
|
229
|
+
--query "SELECT Id, UserOrGroupId FROM GroupMember WHERE GroupId='$QUEUE_ID' AND UserOrGroupId IN ($USER_IDS_IN)" \
|
|
230
|
+
--json 2>/dev/null)
|
|
231
|
+
|
|
232
|
+
EXISTING_USER_IDS=$(echo "$GM_JSON" | jq -c '[.result.records[].UserOrGroupId]')
|
|
233
|
+
EXISTING_COUNT=$(echo "$EXISTING_USER_IDS" | jq 'length')
|
|
234
|
+
|
|
235
|
+
# Compute missing = expected - existing
|
|
236
|
+
MISSING_USERS=$(echo "$USERS_JSON" | jq -c --argjson existing "$EXISTING_USER_IDS" \
|
|
237
|
+
'[.[] | select(.id as $id | ($existing | index($id)) == null)]')
|
|
238
|
+
MISSING_COUNT=$(echo "$MISSING_USERS" | jq 'length')
|
|
239
|
+
|
|
240
|
+
# PLAN_ONLY: all detection above is read-only. Report what a --run would do and stop before any write.
|
|
241
|
+
if [ "${PLAN_ONLY:-}" = "1" ]; then
|
|
242
|
+
if [ "$MISSING_COUNT" -eq 0 ]; then
|
|
243
|
+
emit_plan "reused" "All $COUNT agent user(s) already members of queue '$QUEUE_DN'."
|
|
244
|
+
else
|
|
245
|
+
emit_plan "action_needed" "Would bind $MISSING_COUNT missing of $COUNT agent user(s) into queue '$QUEUE_DN'."
|
|
246
|
+
fi
|
|
247
|
+
fi
|
|
248
|
+
|
|
249
|
+
# Step 5 - POST GroupMember for each missing user
|
|
250
|
+
BOUND_THIS_RUN='[]'
|
|
251
|
+
BOUND_COUNT=0
|
|
252
|
+
PARTIAL_ERRORS=()
|
|
253
|
+
|
|
254
|
+
if [ "$MISSING_COUNT" -gt 0 ]; then
|
|
255
|
+
for row in $(echo "$MISSING_USERS" | jq -c '.[]'); do
|
|
256
|
+
USER_ID=$(echo "$row" | jq -r '.id')
|
|
257
|
+
USERNAME=$(echo "$row" | jq -r '.username')
|
|
258
|
+
|
|
259
|
+
# sf api request rest (beta) writes a warning to stderr; redirect it so stdout is pure JSON.
|
|
260
|
+
POST_STDERR="$(mktemp)"
|
|
261
|
+
POST_RESULT=$(sf api request rest --target-org "$ORG" \
|
|
262
|
+
"/services/data/$API_VERSION/sobjects/GroupMember" \
|
|
263
|
+
--method POST \
|
|
264
|
+
--body "{\"GroupId\":\"$QUEUE_ID\",\"UserOrGroupId\":\"$USER_ID\"}" 2>"$POST_STDERR" || true)
|
|
265
|
+
|
|
266
|
+
# Success = {id, success:true, errors:[]}; error = array of {errorCode, message} or single obj
|
|
267
|
+
POST_SUCCESS=$(echo "$POST_RESULT" | jq -r '.success // false' 2>/dev/null || echo "false")
|
|
268
|
+
|
|
269
|
+
if [ "$POST_SUCCESS" = "true" ]; then
|
|
270
|
+
GM_ID=$(echo "$POST_RESULT" | jq -r '.id')
|
|
271
|
+
# GroupMember dedupes server-side (a duplicate POST returns success with the existing Id), so
|
|
272
|
+
# the detect step above is what prevents inflated bound_count; any success here is a new bind.
|
|
273
|
+
BOUND_THIS_RUN=$(echo "$BOUND_THIS_RUN" | jq -c \
|
|
274
|
+
--arg uid "$USER_ID" --arg un "$USERNAME" --arg gmid "$GM_ID" \
|
|
275
|
+
'. + [{user_id: $uid, username: $un, group_member_id: $gmid}]')
|
|
276
|
+
BOUND_COUNT=$((BOUND_COUNT + 1))
|
|
277
|
+
else
|
|
278
|
+
# Try to extract error code + message
|
|
279
|
+
ERR_CODE=$(echo "$POST_RESULT" | jq -r 'if type == "array" then .[0].errorCode else .errorCode // "UNKNOWN" end' 2>/dev/null || echo "UNKNOWN")
|
|
280
|
+
ERR_MSG=$(echo "$POST_RESULT" | jq -r 'if type == "array" then .[0].message else .message // "Unknown POST error" end' 2>/dev/null || echo "Unknown POST error")
|
|
281
|
+
|
|
282
|
+
# Fallback: if POST_RESULT was empty (CLI-level failure), pull first non-Warning stderr line
|
|
283
|
+
if [ "$ERR_CODE" = "UNKNOWN" ] && [ -z "$POST_RESULT" ]; then
|
|
284
|
+
ERR_MSG=$(grep -v '^Warning:' "$POST_STDERR" | head -1 || echo "sf CLI produced no output")
|
|
285
|
+
fi
|
|
286
|
+
|
|
287
|
+
if [ "$ERR_CODE" = "DUPLICATE_VALUE" ]; then
|
|
288
|
+
# Safety net for a concurrent re-POST; detect-before-POST already prevents duplicate binds.
|
|
289
|
+
:
|
|
290
|
+
else
|
|
291
|
+
PARTIAL_ERRORS+=("$USERNAME ($USER_ID): $ERR_CODE - $ERR_MSG")
|
|
292
|
+
fi
|
|
293
|
+
fi
|
|
294
|
+
rm -f "$POST_STDERR"
|
|
295
|
+
done
|
|
296
|
+
fi
|
|
297
|
+
|
|
298
|
+
# Step 6 - Re-query final membership
|
|
299
|
+
AFTER_GM_JSON=$(sf data query --target-org "$ORG" \
|
|
300
|
+
--query "SELECT UserOrGroupId FROM GroupMember WHERE GroupId='$QUEUE_ID' AND UserOrGroupId IN ($USER_IDS_IN)" \
|
|
301
|
+
--json 2>/dev/null)
|
|
302
|
+
|
|
303
|
+
AFTER_USER_IDS=$(echo "$AFTER_GM_JSON" | jq -c '[.result.records[].UserOrGroupId]')
|
|
304
|
+
AFTER_COUNT=$(echo "$AFTER_USER_IDS" | jq 'length')
|
|
305
|
+
|
|
306
|
+
# Step 7 - Compute status
|
|
307
|
+
REUSED_COUNT=$EXISTING_COUNT
|
|
308
|
+
|
|
309
|
+
if [ ${#PARTIAL_ERRORS[@]} -gt 0 ] || [ "$AFTER_COUNT" -lt "$COUNT" ]; then
|
|
310
|
+
STATUS="partial"
|
|
311
|
+
# Assemble blocking_issue string
|
|
312
|
+
BLOCKING_ISSUE="Partial bind: after re-query, $AFTER_COUNT of $COUNT expected users are members. Failures: ${PARTIAL_ERRORS[*]:-"(none; count mismatch only)"}"
|
|
313
|
+
elif [ "$BOUND_COUNT" -eq 0 ]; then
|
|
314
|
+
STATUS="reused"
|
|
315
|
+
BLOCKING_ISSUE=""
|
|
316
|
+
else
|
|
317
|
+
STATUS="bound"
|
|
318
|
+
BLOCKING_ISSUE=""
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# Step 8 - Emit report
|
|
322
|
+
BLOCKING_JQ_ARG='null'
|
|
323
|
+
if [ -n "$BLOCKING_ISSUE" ]; then
|
|
324
|
+
BLOCKING_JQ_ARG=$(jq -n --arg s "$BLOCKING_ISSUE" '$s')
|
|
325
|
+
fi
|
|
326
|
+
|
|
327
|
+
jq -n \
|
|
328
|
+
--arg status "$STATUS" \
|
|
329
|
+
--arg dn "$QUEUE_DN" \
|
|
330
|
+
--arg qid "$QUEUE_ID" \
|
|
331
|
+
--arg suf "$SUFFIX" \
|
|
332
|
+
--argjson count "$COUNT" \
|
|
333
|
+
--argjson before_ids "$EXISTING_USER_IDS" \
|
|
334
|
+
--argjson before_count "$EXISTING_COUNT" \
|
|
335
|
+
--argjson bound "$BOUND_THIS_RUN" \
|
|
336
|
+
--argjson bound_count "$BOUND_COUNT" \
|
|
337
|
+
--argjson reused_count "$REUSED_COUNT" \
|
|
338
|
+
--argjson after_ids "$AFTER_USER_IDS" \
|
|
339
|
+
--argjson after_count "$AFTER_COUNT" \
|
|
340
|
+
--argjson blocking "$BLOCKING_JQ_ARG" \
|
|
341
|
+
--arg member_source "$([ "$EXPLICIT_MODE" = "true" ] && echo explicit || echo generated_pattern)" \
|
|
342
|
+
'{
|
|
343
|
+
skill: "service-omni-queue-members-assign",
|
|
344
|
+
status: $status,
|
|
345
|
+
queue: {developer_name: $dn, id: $qid},
|
|
346
|
+
org_suffix: $suf,
|
|
347
|
+
member_source: $member_source,
|
|
348
|
+
requested_count: $count,
|
|
349
|
+
before: {
|
|
350
|
+
existing_member_user_ids: $before_ids,
|
|
351
|
+
existing_member_count: $before_count
|
|
352
|
+
},
|
|
353
|
+
bound_this_run: $bound,
|
|
354
|
+
bound_count: $bound_count,
|
|
355
|
+
reused_count: $reused_count,
|
|
356
|
+
after: {
|
|
357
|
+
member_user_ids: $after_ids,
|
|
358
|
+
member_count: $after_count
|
|
359
|
+
},
|
|
360
|
+
manual_actions: [],
|
|
361
|
+
blocking_issue: $blocking
|
|
362
|
+
}'
|