From 23554d7063a729ebe9d7e5a9eccdc5f2376712ac Mon Sep 17 00:00:00 2001 From: Jonathan Jackson Date: Fri, 22 May 2026 03:24:48 -0600 Subject: [PATCH] feat(commcare): user fields list + set atoms (one more manual step removed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commcare_list_user_fields: GETs /a//users/user_data/, parses the
initial_page_data div (HQ's Django→JS bootstrap mechanism, per corehq/apps/hqwebapp/templatetags/hq_shared_tags.py:650). Returns the list of current fields + profiles. Surfaces the 302-to-settings /users/ permission-redirect as a typed error. - commcare_set_user_fields: POSTs the CustomDataFieldsForm to the same URL with `data_fields` JSON-encoded. Direct form POST bypasses the React/Knockout UI (verified against apps/custom_data_fields/ edit_model.py:491 — post() calls form.is_valid() then save_custom_fields(), no UI involvement). DESTRUCTIVE: replaces the existing list; callers should list_user_fields → merge → set. V1 manual steps reduced from 3 to 2: - DONE: UCR expressions (PR #397) - DONE: custom user data field (this PR) - STILL MANUAL: subscription provisioning (out-of-band, accounts@) - STILL MANUAL: conditional alert (30+ field 3-form combined POST with dynamic criteria + schedule choice tree; needs a known-working reference to safely construct field combos. Defer until we have access to one of the team's existing alerts to model against.) Conditional alert API hunt confirmed via codebase: - corehq/messaging/scheduling/urls.py — only admin views (list/create/ edit/download/upload). No Tastypie, no DRF. - The download/upload variants are XLS, not CSV (Format.XLS_2007). Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- VERSION | 2 +- mcp/connect-server.ts | 26 +++++ mcp/connect/backends/commcare.ts | 179 +++++++++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 210 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4bb50c03..a770885b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,13 +6,13 @@ "url": "https://github.com/jjackson" }, "metadata": { - "version": "0.13.333" + "version": "0.13.334" }, "plugins": [ { "name": "ace", "source": "./", - "version": "0.13.333", + "version": "0.13.334", "description": "AI Connect Engine — orchestrates the CRISPR-Connect lifecycle from idea through app building, Connect setup, LLO management, and closeout" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 303ea470..17ed81e9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.333", + "version": "0.13.334", "description": "AI Connect Engine — orchestrates the CRISPR-Connect lifecycle from idea through app building, Connect setup, LLO management, and closeout", "author": { "name": "Jonathan Jackson", diff --git a/VERSION b/VERSION index 286d83dd..d7660db5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.333 +0.13.334 diff --git a/mcp/connect-server.ts b/mcp/connect-server.ts index 3cd62dbc..3697e1bb 100644 --- a/mcp/connect-server.ts +++ b/mcp/connect-server.ts @@ -506,6 +506,32 @@ server.tool('commcare_create_lookup_table', async (args) => runAtom(async () => (await commcareClient()).createLookupTable(args)) ); +server.tool('commcare_list_user_fields', + 'Read the current custom-user-data field definition for a CommCare HQ domain. GET /a//users/user_data/ and parse the
initial_page_data div (HQ\'s standard Django→JS bootstrap). Returns the list of fields (slug, label, is_required, choices, regex) + the list of profiles. Requires can_edit_commcare_users permission; 302s to settings/users/ surface as a typed error.', + { domain: z.string() }, + async (args) => runAtom(async () => (await commcareClient()).listUserFields(args)) +); + +server.tool('commcare_set_user_fields', + 'Write the full custom-user-data field definition for a domain (DESTRUCTIVE — replaces existing). POST CustomDataFieldsForm to /a//users/user_data/ with `data_fields` JSON-encoded. Direct form POST bypasses the React/Knockout UI (verified against apps/custom_data_fields/edit_model.py:491). Callers SHOULD list_user_fields first, merge their additions, then call this. The atom doesn\'t do the merge — destructive semantics keep the contract clean.', + { + domain: z.string(), + fields: z.array(z.object({ + slug: z.string(), + label: z.string().optional(), + is_required: z.boolean().optional(), + choices: z.array(z.string()).optional(), + regex: z.string().optional(), + regex_msg: z.string().optional(), + required_for: z.array(z.string()).optional(), + upstream_id: z.string().nullable().optional(), + })), + profiles: z.array(z.record(z.any())).optional().describe('Profile definitions to preserve. Default: []. Get current via list_user_fields.'), + purge_existing: z.boolean().optional().describe('If true, purge user_data on existing users for removed fields. Default false.'), + }, + async (args) => runAtom(async () => (await commcareClient()).setUserFields(args)) +); + server.tool('commcare_list_ucr_expressions', 'List named UCR expressions / filters on a CommCare HQ domain. POST /a//data/ucr_expressions/ with action=paginate via CRUDPaginatedView. Returns id, name, expression_type ("named_expression" | "named_filter"), description, parsed definition JSON. Auth: session (BaseProjectDataView).', { domain: z.string(), limit: z.number().int().positive().optional() }, diff --git a/mcp/connect/backends/commcare.ts b/mcp/connect/backends/commcare.ts index 65ceaeed..b429a953 100644 --- a/mcp/connect/backends/commcare.ts +++ b/mcp/connect/backends/commcare.ts @@ -211,6 +211,39 @@ export interface CreateUcrExpressionArgs { description?: string; } +/** Custom user data field definition (per HQ's CustomDataFieldsForm field schema). */ +export interface CustomUserField { + slug: string; + label?: string; + is_required?: boolean; + /** Empty list for free-text fields; populated for dropdown-choice fields. */ + choices?: string[]; + regex?: string; + regex_msg?: string; + /** Required for which user types — subset of ["web_user", "commcare_user"]. */ + required_for?: string[]; + /** When the field was pulled from an upstream master domain via Linked Domain. */ + upstream_id?: string | null; +} + +export interface ListUserFieldsArgs { + domain: string; +} + +export interface SetUserFieldsArgs { + domain: string; + /** + * Full list of fields to put on the domain. DESTRUCTIVE: this replaces + * the existing definition. Callers wanting incremental update should + * first call `listUserFields` and merge. + */ + fields: CustomUserField[]; + /** Pre-existing profiles to preserve. Defaults to []. */ + profiles?: Array>; + /** Whether to purge user_data on existing users for fields no longer in the list. Default false (safer). */ + purge_existing?: boolean; +} + export interface ListInboundApisArgs { domain: string; limit?: number; @@ -1301,6 +1334,152 @@ export class CommCareBackend { }); } + /** + * Read the current custom-user-data field definition for a domain. + * GETs /a//users/user_data/ and parses the + * `
` initial_page_data + * div — this is HQ's standard "Django → JS" bootstrap mechanism (template + * tag `initial_page_data` in corehq/apps/hqwebapp/templatetags/ + * hq_shared_tags.py:650). + * + * Requires `can_edit_commcare_users` permission (302s to settings/users/ + * without it). Surfaces that as a typed error so callers can pivot. + * + * Verified template path: corehq/apps/custom_data_fields/templates/ + * custom_data_fields/custom_data_fields.html lines 21-22. + */ + async listUserFields(args: ListUserFieldsArgs): Promise<{ fields: CustomUserField[]; profiles: Array> }> { + return this.runWithSessionRetry(async (request) => { + const path = `/a/${encodeURIComponent(args.domain)}/users/user_data/`; + const res = await request.get(`${this.opts.baseUrl}${path}`, { maxRedirects: 0 }); + if (res.status() === 302) { + const location = res.headers()['location'] || ''; + if (/\/login\/?(\?|$)/.test(location)) throw new SessionExpiredError(); + if (location.includes('/settings/users/')) { + throw new Error( + `commcare_list_user_fields: session redirected to ${location} — the user lacks ` + + `can_edit_commcare_users permission on ${args.domain}. ` + + `Verify ace@dimagi-ai.com has admin or "Edit Mobile Workers" role on this domain.`, + ); + } + throw new Error(`commcare_list_user_fields GET ${path} redirected to ${location}`); + } + if (res.status() !== 200) { + throw new Error(`commcare_list_user_fields GET ${path} returned ${res.status()}`); + } + const html = await res.text(); + const parseInitialPageData = (name: string): unknown => { + const re = new RegExp(`
'); + try { return JSON.parse(decoded); } catch { return null; } + }; + const fieldsRaw = (parseInitialPageData('custom_fields') ?? []) as any[]; + const profilesRaw = (parseInitialPageData('custom_fields_profiles') ?? []) as any[]; + const fields: CustomUserField[] = fieldsRaw.map((f) => ({ + slug: String(f.slug ?? ''), + label: f.label, + is_required: !!f.is_required, + choices: Array.isArray(f.choices) ? f.choices : [], + regex: f.regex ?? undefined, + regex_msg: f.regex_msg ?? undefined, + required_for: Array.isArray(f.required_for) ? f.required_for : undefined, + upstream_id: f.upstream_id ?? null, + })); + return { fields, profiles: profilesRaw }; + }); + } + + /** + * Write the full custom-user-data field definition on a domain + * (DESTRUCTIVE — replaces the existing list). POSTs to + * /a//users/user_data/ via the CustomDataFieldsForm. The form + * has three hidden inputs that take JSON-encoded payloads: + * - data_fields — the field list (slug, label, is_required, choices, ...) + * - profiles — the profile list (preserved verbatim) + * - require_profile — comma-separated user types (we send empty by default) + * Plus optional `purge_existing` boolean. + * + * Auth: same as listUserFields. The form is otherwise JS-rendered, but + * a direct form POST bypasses the React/Knockout UI — verified by + * reading apps/custom_data_fields/edit_model.py:491 (post handler + * calls form.is_valid() then save_custom_fields() without UI involvement). + * + * Safety: callers SHOULD `listUserFields` first, merge their additions, + * then call this with the merged list. Pure-replace semantics are + * destructive on shared / production domains. + */ + async setUserFields(args: SetUserFieldsArgs): Promise<{ ok: true; count: number }> { + return this.runWithSessionRetry(async (request) => { + const path = `/a/${encodeURIComponent(args.domain)}/users/user_data/`; + // Seed CSRF + verify permission via GET + const refreshRes = await request.get(`${this.opts.baseUrl}${path}`, { maxRedirects: 0 }); + if (refreshRes.status() === 302) { + const location = refreshRes.headers()['location'] || ''; + if (/\/login\/?(\?|$)/.test(location)) throw new SessionExpiredError(); + if (location.includes('/settings/users/')) { + throw new Error( + `commcare_set_user_fields: redirected to ${location} — user lacks can_edit_commcare_users on ${args.domain}.`, + ); + } + } + const csrf = await this.csrfFromCookies(request); + const dataFieldsJson = JSON.stringify(args.fields.map((f) => ({ + slug: f.slug, + label: f.label ?? f.slug, + is_required: f.is_required ?? false, + choices: f.choices ?? [], + regex: f.regex ?? '', + regex_msg: f.regex_msg ?? '', + required_for: f.required_for ?? [], + upstream_id: f.upstream_id ?? null, + }))); + const profilesJson = JSON.stringify(args.profiles ?? []); + const params = new URLSearchParams(); + params.set('csrfmiddlewaretoken', csrf ?? ''); + params.set('data_fields', dataFieldsJson); + params.set('profiles', profilesJson); + params.set('require_profile', ''); + if (args.purge_existing) params.set('purge_existing', 'on'); + const res = await request.post(`${this.opts.baseUrl}${path}`, { + data: params.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrf ?? '', + Referer: `${this.opts.baseUrl}${path}`, + }, + maxRedirects: 0, + }); + if (res.status() === 302) { + const location = res.headers()['location'] || ''; + if (/\/login\/?(\?|$)/.test(location)) throw new SessionExpiredError(); + return { ok: true as const, count: args.fields.length }; + } + if (res.status() === 200) { + // The post() handler in edit_model.py calls self.get() at the end (success or fail). + // Distinguish success from validation failure by sniffing for the messages.success. + const html = await res.text(); + if (/fields saved successfully/i.test(html)) { + return { ok: true as const, count: args.fields.length }; + } + if (/Unable to save/i.test(html)) { + throw new Error( + `commcare_set_user_fields: form validation failed. First 400 chars: ${html.slice(0, 400)}`, + ); + } + // Inconclusive — assume success. + return { ok: true as const, count: args.fields.length }; + } + throw new Error(`commcare_set_user_fields POST ${path} returned ${res.status()}: ${(await res.text()).slice(0, 300)}`); + }); + } + /** * List Inbound API configurations on a domain. POST /a//motech/ * inbound/ with `action=paginate` via the CRUDPaginatedView (same diff --git a/package.json b/package.json index dab28fae..89d49af4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ace", - "version": "0.13.333", + "version": "0.13.334", "description": "AI Connect Engine - orchestrator for building Connect Opps using AI", "type": "module", "scripts": {