// copyRoleHelper — creates the per-merchant AD_Role. // // IMPORTANT: this iDempiere instance AUTO-PROVISIONS a new org-level role on creation — // a custom server-side validator clones the full Fulfillment-Customer access set // (AD_Window/Process/Form/Workflow/Document_Action/InfoWindow_Access ≈ 1269 rows), sets // FrontendMenu = "Fulfillment Customer", and grants org-access to the role's own org. This // was verified on prod: a freshly created role already carries the identical access (same // window set, same Created timestamp/by) as the template — with NO copying from us. // // Therefore we must NOT copy access rows ourselves. Doing so (the previous approach) only: // - inserted ~1269 duplicates → every one failed on the unique constraints, and // - flooded the iDempiere REST server with a burst of requests → 502 Bad Gateway storms // that also broke unrelated traffic (chat, SSE) and the very next wizard step. // So this helper just creates the role record and makes sure org-access to the new org // exists (idempotent — iDempiere usually creates it already). type ApiCall = (url: string, method: string, body: any) => Promise const extractErr = (e: any): string => { const d = e?.data ?? e return d?.detail || d?.title || d?.summary || d?.message || e?.message || 'Unknown error' } interface CopyRoleParams { sourceRoleId: string | number newOrgId: string | number newRoleName: string } export default async function copyRoleHelper(apiCall: ApiCall, params: CopyRoleParams) { const { sourceRoleId, newOrgId, newRoleName } = params // Read the template role to mirror its configuration scalars/flags. const src: any = await apiCall(`models/ad_role/${sourceRoleId}`, 'GET', null) if (!src?.id) throw new Error(`Template role ${sourceRoleId} not found`) // Create the new role. (Keep the payload close to the template's config; iDempiere's // validator then auto-fills FrontendMenu + the full access set + own-org access.) const rolePayload: any = { AD_Org_ID: { id: newOrgId, tableName: 'AD_Org' }, isActive: true, name: newRoleName, description: src.Description || '', amtApproval: src.AmtApproval ?? 0, isManual: src.IsManual ?? false, isPersonalAccess: src.IsPersonalAccess ?? false, isShowAcct: src.IsShowAcct ?? false, isPersonalLock: src.IsPersonalLock ?? false, isCanReport: src.IsCanReport ?? false, isCanExport: src.IsCanExport ?? false, isCanApproveOwnDoc: src.IsCanApproveOwnDoc ?? false, isAccessAllOrgs: src.IsAccessAllOrgs ?? false, isChangeLog: src.IsChangeLog ?? false, overwritePriceLimit: src.OverwritePriceLimit ?? false, isUseUserOrgAccess: false, confirmQueryRecords: src.ConfirmQueryRecords ?? 0, maxQueryRecords: src.MaxQueryRecords ?? 0, isDiscountUptoLimitPrice: src.IsDiscountUptoLimitPrice ?? false, isDiscountAllowedOnTotal: src.IsDiscountAllowedOnTotal ?? false, isMenuAutoExpand: src.IsMenuAutoExpand ?? false, isMasterRole: src.IsMasterRole ?? false, isAccessAdvanced: src.IsAccessAdvanced ?? false, isClientAdministrator: src.IsClientAdministrator ?? false, amtApprovalAccum: src.AmtApprovalAccum ?? 0, daysApprovalAccum: src.DaysApprovalAccum ?? 0, tableName: 'AD_Role', } if (src.UserLevel?.id != null) rolePayload.UserLevel = { id: src.UserLevel.id } if (src.PreferenceType?.id != null) rolePayload.PreferenceType = { id: src.PreferenceType.id } if (src.RoleType?.id != null) rolePayload.RoleType = { id: src.RoleType.id } if (src.C_Currency_ID?.id) rolePayload.C_Currency_ID = { id: src.C_Currency_ID.id, tableName: 'C_Currency' } if (src.AD_Tree_Menu_ID?.id) rolePayload.AD_Tree_Menu_ID = { id: src.AD_Tree_Menu_ID.id, tableName: 'AD_Tree' } if (src.AD_Tree_Org_ID?.id) rolePayload.AD_Tree_Org_ID = { id: src.AD_Tree_Org_ID.id, tableName: 'AD_Tree' } const newRole: any = await apiCall('models/ad_role', 'POST', rolePayload) const newRoleId = newRole?.id if (!newRoleId) throw new Error('Role record could not be created') // Ensure org-access to the new org exists (idempotent — iDempiere normally creates it on // role creation; insert only if missing to avoid a duplicate-key error). let orgAccessOk = false let orgAccessError = '' try { const existing: any = await apiCall( `models/ad_role_orgaccess?$filter=AD_Role_ID eq ${newRoleId} and AD_Org_ID eq ${newOrgId}&$top=1`, 'GET', null ) const has = (existing?.['row-count'] ?? existing?.records?.length ?? 0) > 0 if (has) { orgAccessOk = true } else { await apiCall('models/ad_role_orgaccess', 'POST', { AD_Org_ID: { id: newOrgId, tableName: 'AD_Org' }, AD_Role_ID: { id: newRoleId, tableName: 'AD_Role' }, isReadOnly: false, isActive: true, tableName: 'AD_Role_OrgAccess', }) orgAccessOk = true } } catch (e: any) { orgAccessError = extractErr(e) } return { newRoleId, newRoleName, sourceRoleId, orgAccessOk, orgAccessError, autoProvisioned: true } }