@cryptotaxi247 / CoPilot / commits / 110159b9

feat: Phase 3a — Shuffle org picker dropdown in integration form (#831)

* feat(shuffle): Phase 3a — org picker dropdown in integration form Replaces the manual Shuffle Org-Id paste in the integration form with a dropdown populated from Shuffle's `/api/v1/orgs` endpoint, queried through the deployment's admin Bearer key (the existing Shuffle connector). Manual entry stays as a one-checkbox escape hatch for cases where the listing call fails or the org is too new to appear. - `dispatchers.list_shuffle_orgs(base_url, api_key)` — un-Org-Id-scoped GET against `/api/v1/orgs`. Tolerates both flat-list and `{"orgs": [...]}` response shapes. - `services.list_orgs(session)` — fetches Shuffle connector creds, calls the dispatcher, trims each org to {id, name, role, org_type}. - `routes.list_shuffle_orgs_route` — `GET /api/notifications/shuffle/orgs`. Deployment-scoped (not customer-scoped); admin/analyst auth gate. - `schema.ShuffleOrg` + `ShuffleOrgListResponse` — wire shapes. - `types.ShuffleOrg` mirrors the backend shape. - `api.notifications.listShuffleOrgs()` calls the new endpoint. - `CustomerShuffleIntegrationForm.vue`: - Replaced the bare Org-Id text input with an `n-select` filled from the orgs endpoint, label format "Org Name (uuid8…)" so admins can disambiguate same-named orgs. - Added a "Refresh list" button + a "Don't see your org? Enter manually" checkbox that toggles back to the legacy text input. - On edit, if the integration's existing org_id isn't in the list (e.g. fetched-once-then-deleted-from-Shuffle), the form auto-flips to manual entry so the row stays editable. - Loads orgs on mount (`onBeforeMount`); failures fall back to manual entry with the error surfaced inline. The manual-entry fallback isn't dead UI — it's the recovery path for when Shuffle is unreachable or the API response shape changes unexpectedly. Cleanest way to keep the form usable in degraded modes without a separate admin override. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(shuffle-orgs): align ShuffleOrg shape with actual Shuffle response Tested GET /api/v1/orgs against california.shuffler.io and confirmed the response is a flat array (matching the dispatcher's primary expectation). Cleaned up the field mapping based on the real shape: - Drop `org_type` — Shuffle doesn't return that field. Was always None. - Add `description` — present on every org row, useful as a future tooltip. - Add `creator_org` — set to the parent UUID on sub-orgs, empty/None on top-level orgs. The dispatcher normalizes the placeholder "PARENT_ORG_ID" string (seen in the canned docs example) and the literal empty string back to None so the frontend hint stays clean. Frontend dropdown now appends "· sub-org" to the label when `creator_org` is set, so admins can tell at a glance which rows are children of the parent org. Typical Shuffle pattern is one parent per MSP + one sub-org per customer, so this hint matters for selection. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

taylor_socfortress committed Apr 29, 2026 at 15:19 UTC 110159b9df72a7c19894626cae587363d20a94fd
7 files changed +281 -14
backend/app/notifications/routes/notifications.py
+29
@@ -41,6 +41,7 @@ from app.notifications.schema.notifications import ShuffleIntegrationListRespons
41 from app.notifications.schema.notifications import ShuffleIntegrationRead
42 from app.notifications.schema.notifications import ShuffleIntegrationResponse
43 from app.notifications.schema.notifications import ShuffleIntegrationUpdate
44 +from app.notifications.schema.notifications import ShuffleOrgListResponse
45 from app.notifications.schema.notifications import ShuffleVerifyResponse
46 from app.notifications.services import notifications as svc
47
@@ -153,6 +154,34 @@ async def list_dispatch_log_route(
154 )
155
156
157 +# ---------------------------------------------------------------------------
158 +# Deployment-scoped Shuffle helpers (Phase 3)
159 +# ---------------------------------------------------------------------------
160 +
161 +
162 +@notifications_router.get(
163 + "/notifications/shuffle/orgs",
164 + response_model=ShuffleOrgListResponse,
165 + description=(
166 + "List every Shuffle org the deployment's admin Bearer key can see. "
167 + "Used by the integration form's org picker so admins choose from a "
168 + "dropdown instead of pasting Org-Ids. Not customer-scoped — each "
169 + "org is later attached to a specific customer via a "
170 + "customer_shuffle_integration row."
171 + ),
172 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
173 +)
174 +async def list_shuffle_orgs_route(
175 + session: AsyncSession = Depends(get_db),
176 +) -> ShuffleOrgListResponse:
177 + orgs = await svc.list_orgs(session)
178 + return ShuffleOrgListResponse(
179 + success=True,
180 + message=f"{len(orgs)} org(s) retrieved",
181 + orgs=orgs,
182 + )
183 +
184 +
185 # ---------------------------------------------------------------------------
186 # Per-customer Shuffle integrations (Phase 2)
187 # ---------------------------------------------------------------------------
backend/app/notifications/schema/notifications.py
+25
@@ -262,6 +262,31 @@ class ShuffleVerifyResponse(BaseModel):
262 error: Optional[str] = None
263
264
265 +class ShuffleOrg(BaseModel):
266 + """One Shuffle org visible to the deployment's admin Bearer.
267 +
268 + Used to populate the integration form's org-picker dropdown so
269 + admins don't have to paste UUIDs. Forwards only the fields the UI
270 + needs — Shuffle's full org payload carries a lot of internal state
271 + (users, billing, region, sync_config) we don't want leaking
272 + through. `creator_org` is empty/falsy on top-level orgs and set to
273 + the parent's UUID on sub-orgs, so the UI can label sub-orgs
274 + distinctly without an extra round-trip.
275 + """
276 +
277 + id: str
278 + name: str
279 + description: Optional[str] = None
280 + role: Optional[str] = None
281 + creator_org: Optional[str] = None
282 +
283 +
284 +class ShuffleOrgListResponse(BaseModel):
285 + success: bool = True
286 + message: str = "Orgs retrieved"
287 + orgs: List[ShuffleOrg]
288 +
289 +
290 class NotificationRouteListResponse(BaseModel):
291 success: bool = True
292 message: str = "Routes retrieved"
backend/app/notifications/services/dispatchers.py
+48
@@ -278,3 +278,51 @@ async def verify_shuffle_org(
278 if not ok:
279 return (False, None, error)
280 return (True, len(apps), None)
281 +
282 +
283 +async def list_shuffle_orgs(
284 + *,
285 + base_url: str,
286 + api_key: str,
287 +) -> Tuple[bool, list, Optional[str]]:
288 + """Fetch the orgs the deployment's admin Bearer can see.
289 +
290 + Used by the integration form so admins pick from a dropdown of real
291 + orgs instead of pasting Org-Ids. Hits `GET /api/v1/orgs` — Shuffle
292 + treats the admin key as having visibility into the parent org plus
293 + any sub-orgs. We deliberately do NOT send an `Org-Id` header: the
294 + request is unscoped so we get the full list back rather than a
295 + single-org view.
296 +
297 + Returns (ok, orgs, error_message). Each org element is a dict with
298 + at least `id` and `name`; the service layer trims it to the shape
299 + the frontend dropdown needs.
300 + """
301 + url = f"{base_url.rstrip('/')}/api/v1/orgs"
302 + headers = {
303 + "Authorization": f"Bearer {api_key}",
304 + "Accept": "application/json",
305 + }
306 + try:
307 + async with httpx.AsyncClient(timeout=_SHUFFLE_TIMEOUT_S, http2=True) as client:
308 + response = await client.get(url, headers=headers)
309 + if response.status_code in (401, 403):
310 + return (False, [], f"Shuffle authentication failed ({response.status_code})")
311 + if response.status_code >= 400:
312 + return (False, [], f"Shuffle returned {response.status_code}: {response.text[:200]}")
313 + try:
314 + data = response.json()
315 + except ValueError:
316 + return (False, [], "Shuffle returned non-JSON response")
317 +
318 + # Shuffle's `/api/v1/orgs` historically returns a flat list of
319 + # org objects; some installs wrap it in `{"orgs": [...]}`. Tolerate
320 + # both shapes so we don't break on an upstream change.
321 + if isinstance(data, dict) and "orgs" in data:
322 + data = data.get("orgs") or []
323 + if not isinstance(data, list):
324 + return (False, [], f"Unexpected Shuffle response shape: {type(data).__name__}")
325 + return (True, data, None)
326 + except Exception as e: # noqa: BLE001
327 + logger.warning(f"Shuffle orgs list failed: {e!r}")
328 + return (False, [], f"{type(e).__name__}: {e}")
backend/app/notifications/services/notifications.py
+47
@@ -41,11 +41,15 @@ from app.notifications.schema.notifications import NotificationTrigger
41 from app.notifications.schema.notifications import ShuffleApp
42 from app.notifications.schema.notifications import ShuffleIntegrationCreate
43 from app.notifications.schema.notifications import ShuffleIntegrationUpdate
44 +from app.notifications.schema.notifications import ShuffleOrg
45 from app.notifications.services.dispatchers import dispatch_shuffle
46 from app.notifications.services.dispatchers import dispatch_smtp_email
47 from app.notifications.services.dispatchers import (
48 list_shuffle_apps as shuffle_apps_client,
49 )
50 +from app.notifications.services.dispatchers import (
51 + list_shuffle_orgs as shuffle_orgs_client,
52 +)
53 from app.notifications.services.dispatchers import (
54 verify_shuffle_org as verify_shuffle_org_client,
55 )
@@ -353,6 +357,49 @@ async def verify_integration(integration_id: int, customer_code: str, session: A
357 }
358
359
360 +async def list_orgs(session: AsyncSession) -> List[ShuffleOrg]:
361 + """List every Shuffle org the deployment's admin Bearer can see.
362 +
363 + Used by the integration form's org-picker dropdown so admins pick
364 + a real org instead of pasting a UUID. Not customer-scoped — the
365 + caller's auth gate (admin/analyst scope) is the only access check;
366 + each org is then attached to a specific customer via the
367 + integration row at create time.
368 + """
369 + base_url, api_key = await _get_shuffle_connector(session)
370 + ok, orgs_raw, error = await shuffle_orgs_client(base_url=base_url, api_key=api_key)
371 + if not ok:
372 + raise HTTPException(
373 + status_code=502,
374 + detail=f"Failed to fetch orgs from Shuffle: {error}",
375 + )
376 + # Forward only the fields the UI needs. Shuffle's full org payload
377 + # carries internal billing/users/region state we don't want leaking
378 + # through.
379 + orgs: List[ShuffleOrg] = []
380 + for raw in orgs_raw:
381 + if not isinstance(raw, dict):
382 + continue
383 + if not raw.get("id") or not raw.get("name"):
384 + continue
385 + # `creator_org` is set on sub-orgs to the parent's UUID and
386 + # empty/None on top-level orgs. We forward it as-is so the UI
387 + # can render a "(sub-org)" hint without re-querying.
388 + creator_org = raw.get("creator_org")
389 + if creator_org in ("", "PARENT_ORG_ID"): # ignore placeholder fixtures
390 + creator_org = None
391 + orgs.append(
392 + ShuffleOrg(
393 + id=str(raw.get("id")),
394 + name=str(raw.get("name")),
395 + description=raw.get("description") or None,
396 + role=raw.get("role") or None,
397 + creator_org=creator_org,
398 + ),
399 + )
400 + return orgs
401 +
402 +
403 # ---------------------------------------------------------------------------
404 # Dispatch log (read-only)
405 # ---------------------------------------------------------------------------
frontend/src/api/endpoints/notifications.ts
+10
@@ -7,6 +7,7 @@ import type {
7 ShuffleIntegration,
8 ShuffleIntegrationPayload,
9 ShuffleIntegrationUpdatePayload,
10 + ShuffleOrg,
11 ShuffleVerifyResult
12 } from "@/types/notifications.d"
13 import type { FlaskBaseResponse } from "@/types/flask.d"
@@ -91,5 +92,14 @@ export default {
92 return HttpClient.get<FlaskBaseResponse & ShuffleVerifyResult>(
93 `/customers/${customerCode}/shuffle_integrations/${integrationId}/verify`
94 )
95 + },
96 +
97 + // Phase 3a — deployment-scoped org listing for the integration form's
98 + // dropdown picker. Not customer-scoped; the admin Bearer (Shuffle
99 + // connector) has access to every org we can attach.
100 + listShuffleOrgs() {
101 + return HttpClient.get<FlaskBaseResponse & { orgs: ShuffleOrg[] }>(
102 + `/notifications/shuffle/orgs`
103 + )
104 }
105 }
frontend/src/components/customers/aiNotifications/CustomerShuffleIntegrationForm.vue
+113 -14
@@ -27,17 +27,51 @@
27 />
28 </n-form-item>
29
30 - <n-form-item label="Shuffle Org-Id" path="shuffle_org_id">
31 - <n-input
32 - v-model:value="form.shuffle_org_id"
33 - placeholder="6b6f65a4-d8f8-48ef-b02f-23a4a5f73e4a"
34 - :maxlength="64"
35 - />
30 + <!--
31 + Shuffle Org picker. Phase 3a: dropdown of orgs the deployment's
32 + admin Bearer can see, populated from /api/notifications/shuffle/orgs.
33 + Manual entry stays as a fallback for offline use, restricted
34 + networks, or when an org is too new to appear in the listing yet.
35 + -->
36 + <n-form-item label="Shuffle org" path="shuffle_org_id">
37 + <div class="flex w-full flex-col gap-2">
38 + <n-select
39 + v-model:value="form.shuffle_org_id"
40 + :options="orgOptions"
41 + :loading="loadingOrgs"
42 + :disabled="manualEntry"
43 + filterable
44 + placeholder="Pick a Shuffle org"
45 + @update:value="onOrgPicked"
46 + />
47 +
48 + <div class="flex items-center gap-3 text-xs">
49 + <n-button size="tiny" quaternary :disabled="loadingOrgs" @click="loadOrgs(true)">
50 + <template #icon>
51 + <Icon :name="RefreshIcon" :size="12" />
52 + </template>
53 + Refresh list
54 + </n-button>
55 + <n-checkbox v-model:checked="manualEntry" size="small">
56 + Don't see your org? Enter the ID manually
57 + </n-checkbox>
58 + </div>
59 +
60 + <n-input
61 + v-if="manualEntry"
62 + v-model:value="form.shuffle_org_id"
63 + placeholder="6b6f65a4-d8f8-48ef-b02f-23a4a5f73e4a"
64 + :maxlength="64"
65 + />
66 +
67 + <div v-if="orgsError" class="text-error text-xs">
68 + Couldn't fetch orgs from Shuffle: {{ orgsError }}. Use manual entry above.
69 + </div>
70 + </div>
71 <template #feedback>
72 <span class="text-tertiary text-xs">
38 - Find this on the customer's Shuffle org settings page. Sent as the
39 - <code>Org-Id</code> header on each dispatch — scopes the Shuffle call
40 - to the right org's authenticated apps.
73 + Sent as the <code>Org-Id</code> header on every dispatch — scopes the
74 + Shuffle call to the right org's authenticated apps.
75 </span>
76 </template>
77 </n-form-item>
@@ -56,10 +90,10 @@
90 </template>
91
92 <script setup lang="ts">
59 -import type { ShuffleIntegration, ShuffleIntegrationPayload } from "@/types/notifications.d"
93 +import type { ShuffleIntegration, ShuffleIntegrationPayload, ShuffleOrg } from "@/types/notifications.d"
94 import type { FormInst, FormRules } from "naive-ui"
61 -import { NButton, NCheckbox, NForm, NFormItem, NInput, useMessage } from "naive-ui"
62 -import { computed, reactive, ref } from "vue"
95 +import { NButton, NCheckbox, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
96 +import { computed, onBeforeMount, reactive, ref } from "vue"
97 import Api from "@/api"
98 import Icon from "@/components/common/Icon.vue"
99 import { getApiErrorMessage } from "@/utils"
@@ -75,6 +109,7 @@ const emit = defineEmits<{
109 }>()
110
111 const CloseIcon = "carbon:close"
112 +const RefreshIcon = "carbon:renew"
113
114 const message = useMessage()
115 const formRef = ref<FormInst | null>(null)
@@ -88,12 +123,72 @@ const form = reactive<ShuffleIntegrationPayload>({
123 enabled: props.editingIntegration?.enabled ?? true
124 })
125
126 +// Org-picker state. We default to dropdown mode; manual entry is a
127 +// one-checkbox escape hatch for cases where the Shuffle listing call
128 +// fails or the desired org doesn't appear in the list.
129 +const orgs = ref<ShuffleOrg[]>([])
130 +const loadingOrgs = ref(false)
131 +const orgsError = ref<string | null>(null)
132 +const manualEntry = ref(false)
133 +
134 +const orgOptions = computed(() =>
135 + orgs.value.map(o => {
136 + // Show the name with a short Org-Id suffix so admins can disambiguate
137 + // when two orgs share a display name. Sub-orgs get an extra hint so
138 + // it's obvious which rows are children of the parent (typical Shuffle
139 + // pattern: one parent org per MSP, one sub-org per customer).
140 + const idHint = `(${o.id.slice(0, 8)}…)`
141 + const subOrgHint = o.creator_org ? " · sub-org" : ""
142 + return {
143 + label: `${o.name} ${idHint}${subOrgHint}`,
144 + value: o.id
145 + }
146 + })
147 +)
148 +
149 +async function loadOrgs(force = false) {
150 + if (loadingOrgs.value) return
151 + loadingOrgs.value = true
152 + orgsError.value = null
153 + try {
154 + const res = await Api.notifications.listShuffleOrgs()
155 + if (res.data.success) {
156 + orgs.value = res.data.orgs
157 + // If we're editing and the existing org_id isn't in the list,
158 + // fall through to manual entry so the form stays usable.
159 + if (
160 + editing.value &&
161 + form.shuffle_org_id &&
162 + !orgs.value.some(o => o.id === form.shuffle_org_id)
163 + ) {
164 + manualEntry.value = true
165 + }
166 + } else {
167 + orgsError.value = res.data.message || "Unknown error"
168 + manualEntry.value = true
169 + }
170 + } catch (err: unknown) {
171 + orgsError.value = getApiErrorMessage(err as never) || "Network error"
172 + manualEntry.value = true
173 + } finally {
174 + loadingOrgs.value = false
175 + }
176 + if (force) {
177 + message.success(`${orgs.value.length} org(s) loaded`)
178 + }
179 +}
180 +
181 +function onOrgPicked(_orgId: string | null) {
182 + // No-op for now — kept as a hook in case Phase 3b wants to chain
183 + // the picker into automatic display-name population.
184 +}
185 +
186 const rules: FormRules = {
187 display_name: { required: true, message: "Name is required", trigger: ["input", "blur"] },
188 shuffle_org_id: {
189 required: true,
95 - message: "Shuffle Org-Id is required",
96 - trigger: ["input", "blur"]
190 + message: "Pick a Shuffle org or enter an Org-Id manually",
191 + trigger: ["input", "change", "blur"]
192 }
193 }
194
@@ -126,4 +221,8 @@ async function submit() {
221 submitting.value = false
222 }
223 }
224 +
225 +onBeforeMount(() => {
226 + loadOrgs()
227 +})
228 </script>
frontend/src/types/notifications.d.ts
+9
@@ -89,6 +89,15 @@ export interface ShuffleApp {
89 large_image: string | null
90 }
91
92 +export interface ShuffleOrg {
93 + id: string
94 + name: string
95 + description: string | null
96 + role: string | null
97 + // Parent org UUID on sub-orgs, null/empty on top-level orgs.
98 + creator_org: string | null
99 +}
100 +
101 export interface ShuffleVerifyResult {
102 success: boolean
103 message: string