Loading
Salesforce Enforces New Security Requirements in Summer 2026Read More
Example: Validate Visit Limits

Example: Validate Visit Limits

This example script enforces visit limits and uses custom metadata and permission sets. It checks per-rep and per-product visit limits, returns warnings and errors based on the user's permission set, and validates child attendee visits.

Required Editions

Available in: Lightning Experience
Available in: Enterprise and Unlimited Editions with Life Sciences Cloud, Life Sciences Cloud for Customer Engagement Add-on license, and the Life Sciences Customer Engagement managed package.

Validation Rules

The example script contains three validation rules.

Rule Use Triggers Error Message On Success
perRepVisitLimit Enforces the per-rep visit limit for how many visits an individual sales rep can log for a given HCP (healthcare professional) or HCO (healthcare organization) account. Only when the action is eligible (default Submit, Sign) and an active per-rep limit exists for the account and rep. This visit will exceed the <region> per-rep limit (no remaining). Warning when one visit remains: This visit will reach the <region> per-rep limit (last visit allowed). Silent within limits.
perProductVisitLimit Enforces the per-product visit limit for how many visits can detail a given product for an account across all sales reps. Regional product variants roll up to a shared parent limit. Only when the visit details at least one product, the action is eligible, and an active product limit exists for the account and product. This visit will exceed the <region> product limit for <product> (no remaining). Warning when one visit remains: This visit will reach the <region> product limit for <product> (last visit allowed). Silent within limits.
attendeeVisitLimits Applies both the per-rep and per-product limits to each child attendee visit so that adding attendees doesn't bypass the limits. Only when a parent visit is saved on an eligible action and has attendee visits. On Submit, an attendee with no products inherits the parent's products for the check. Same messages as per-rep and per-product rules, prefixed with the child visit label. Silent within limits.

Helper Methods

The example script contains reusable helper methods.

  • rowField(row, apiName)
    • Reads a field value from a query row, tolerant of platform key casing
    • Checks Salesforce API casing first (web), then lowercased keys (mobile)
    • Returns undefined when the field is absent
  • formatLabelTranslation(key, defaultValue, args)
    • Resolves a Salesforce Custom Label with positional {0} substitution
    • On web, returns a structured label object that the framework unpacks
    • On mobile, returns a pre-translated plain string
    • Falls back to JS-side substitution when the label wrapper is unavailable
  • labelResult(result, visitLabel)
    • Prefixes the message for a result with a label identifying which visit it came from, for example, "Parent visit" or "Child visit 2"
    • On web, injects the prefix into the structured label object that the framework unpacks
    • On mobile, prepends the prefix to the plain string message

Example Script

(() => {

    // ===========================================================================
    // Sample visit action validation script for visit limit validation.
    //
    // Enforces per-rep and per-product visit limits when a sales rep saves,
    // submits, or signs a visit. Runs on Salesforce web and the Life Sciences Cloud mobile
    // app (offline) from this single source. Read-only; it inspects limit records
    // and returns success, warning, and error results. It never writes data.
    //
    // High-level flow (see runLimitChecksAllVisits):
    //   1. Read configurable settings (which actions count, parent on/off).
    //   2. Resolve the per-region enforcement mode (soft / hard / default).
    //   3. For the visit being saved and any child visits: Compare the
    //      remaining limit against the per-rep (UserLimit) and per-product
    //      (ProductLimit) caps and produce a tiered result.
    //
    // Adapt for your org: Replace the object/field API names below with your own,
    // and adjust the limit model to match your data.
    // ===========================================================================

    // ---------------------------------------------------------------------------
    // Field-name helper. If your visit limit objects/fields are installed under a
    // managed package namespace, set NAMESPACE and namespaceNeededForCustomObject
    // so ns() prefixes custom names. If your objects are local (no namespace),
    // leave the flag false and ns() is a no-op.
    //   • Web ignores this; the platform resolves the namespace server-side, so
    //     ns() returns the bare name on web.
    //   • Mobile addresses fields by their on-device name, so prefixing is 
    //     applied there when the flag is true.
    // Standard objects/fields (User, PermissionSet, Account, Visit, Id, Name, …)
    // are never namespaced and must NOT be passed through ns().
    // ---------------------------------------------------------------------------
    const namespaceNeededForCustomObject = false;
    const NAMESPACE = 'lsc4ce__';
    const isMobilePlatform = (function () {
        try {
            if (!record || typeof record.getContextData !== 'function') return true;
            const raw = record.getContextData();
            const ctx = (typeof raw === 'string') ? JSON.parse(raw) : (raw || {});
            return ctx['ProviderVisit'] === undefined;
        } catch (e) {
            return true; // on detection error, treat as mobile (drives row-key handling below)
        }
    })();
    function ns(name) {
        if (!isMobilePlatform) return name;                 // web resolves the namespace itself
        if (!namespaceNeededForCustomObject || !name) return name;
        if (name.indexOf(NAMESPACE) === 0) return name;
        return NAMESPACE + name;
    }

    // Fail-safe defaults for the configurable settings (see resolveSettings).
    // Used when the settings record can't be read (not deployed, read error, or a
    // blank field). "Fail safe" here means "fail enforcing": a missing or
    // unreadable settings record keeps limits running with these defaults rather
    // than silently disabling enforcement.
    const DEFAULT_ALLOWED_ACTIONS = ['Submit', 'Sign'];
    const DEFAULT_ACTIVE = true;

    // When a parent visit with attendees is saved with one of these actions, the
    // platform copies the parent's product details onto any child visit
    // that has none of its own, but only AFTER validation runs. So while
    // validating the parent on these actions, a child with no product details of
    // its own is treated as if it will inherit the parent's products; otherwise
    // the script would under-count product-limit breaches. Other actions leave an
    // empty child empty (no product-limit checks for it).
    const actionsThatCloneParentProductDetailsToChildren = ['Submit'];

    /** Read env.actionName ('Save' / 'Submit' / 'Sign'). */
    function getActionName(env) {
        try {
            if (env && typeof env.getOption === 'function') {
                return env.getOption('actionName') || '';
            }
        } catch (e) { /* fall through */ }
        return '';
    }

    /**
     * Resolve a Salesforce Custom Label with positional substitution. The
     * return shape differs by platform because the framework's
     * CustomScriptResult.title contract differs:
     *   - On desktop, env.formatCustomLabel returns a structured object
     *     { key, defaultValue, argumentsList } and the validation-result
     *     handler unpacks it (resolves the label, substitutes args). Pass
     *     that object straight through as title; do NOT coerce to a string
     *     (string concat would render as '[object Object]').
     *   - On mobile (iOS), env.formatCustomLabel returns a pre-translated
     *     plain string (the JS sandbox wrapper does localizedString +
     *     String(format:) at script-time). Title must be a plain string
     *     because the framework calls JSValue.toString() on it.
     * Falls back to JS-side {0} substitution when the wrapper isn't
     * available, returning a plain string. Either way, the result is the
     * appropriate shape for the current platform.
     */
    function formatLabelTranslation(key, defaultValue, args) {
        const a = args || [];
        try {
            if (env && typeof env.formatCustomLabel === 'function') {
                return env.formatCustomLabel(key, defaultValue, a);
            }
        } catch (e) { /* fall through to JS-side substitution */ }
        let out = defaultValue;
        for (let i = 0; i < a.length; i++) {
            out = out.split('{' + i + '}').join(String(a[i]));
        }
        return out;
    }

    /**
     * Read configurable eligibility settings from the VisitLimitSettings__mdt
     * 'Default' custom-metadata record — drives the validation from configuration
     * instead of hardcoding:
     *   EligibleActions__c (text) → comma-separated actions that count toward
     *                               limits, e.g. "Save,Submit,Sign"
     *   IsActive__c        (bool) → master on/off switch for the whole check
     *
     * Works on both web and mobile. The custom metadata is readable on-device when
     * it is registered for offline sync. If the record can't be read (not
     * deployed, read error, or a blank field), this returns the DEFAULT_* values.
     * The custom-metadata type and its custom fields go through ns();
     * DeveloperName is a standard field and is never prefixed.
     * Fails safe (defaults) on any error, empty result, or blank field.
     */
    async function resolveSettings(db) {
        try {

            // The settings type and its custom fields go through ns();
            // DeveloperName is a standard field, never prefixed. The running user
            // needs read access to the settings type for this query to resolve.
            const rows = await db.query(
                ns('VisitLimitSettings__mdt'),
                await new ConditionBuilder(
                    ns('VisitLimitSettings__mdt'),
                    new FieldCondition('DeveloperName', '=', 'Default')
                ).build(),
                ['DeveloperName', ns('EligibleActions__c'), ns('IsActive__c')]
            );
            const list = rows || [];
            if (list.length === 0) {
                return { allowedActions: DEFAULT_ALLOWED_ACTIONS, active: DEFAULT_ACTIVE };
            }

            // EligibleActions__c → array (trim, drop blanks). Blank → default.
            let allowedActions = DEFAULT_ALLOWED_ACTIONS;
            const raw = list[0].stringValue(ns('EligibleActions__c'));
            if (raw) {
                const parsed = [];
                const parts = raw.split(',');
                for (let i = 0; i < parts.length; i++) {
                    const a = (parts[i] || '').trim();
                    if (a) parsed.push(a);
                }
                if (parsed.length > 0) allowedActions = parsed;
            }

            // IsActive__c → boolean. Only an explicit false disables; null/undefined
            // (e.g. FLS-hidden) defaults to true (fail enforcing).
            let active = DEFAULT_ACTIVE;
            let activeVal;
            try { activeVal = list[0].boolValue(ns('IsActive__c')); }
            catch (inner) { activeVal = list[0].stringValue(ns('IsActive__c')); }
            if (activeVal === false || activeVal === 'false') active = false;
            return { allowedActions: allowedActions, active: active };
        } catch (e) {

            // Any read error (web or mobile) lands here → enforce with hardcoded
            // defaults, never disable, never throw.
            return { allowedActions: DEFAULT_ALLOWED_ACTIONS, active: DEFAULT_ACTIVE };
        }
    }

    /** Safe JSON parse of record.getContextData(). */
    function parseContextData(record) {
        try {
            if (!record || typeof record.getContextData !== 'function') return {};
            const ctx = record.getContextData();
            if (typeof ctx === 'string') return JSON.parse(ctx);
            if (typeof ctx === 'object' && ctx !== null) return ctx;
        } catch (e) { /* fall through */ }
        return {};
    }

    /**
     * Web uses dotted relationship paths ("ProviderVisitProdDetailing.VisitId").
     * Mobile uses flat object names ("ProviderVisitProdDetailing"). Check both.
     */
    function getFieldData(ctx, baseFieldName) {
        const webField = baseFieldName + '.VisitId';
        return ctx[webField] || ctx[baseFieldName];
    }

    /** Resolve the AccountId of the visit's primary HCP/HCO. */
    function resolveAccountId(record) {
        try {
            const direct = record.stringValue('AccountId');
            if (direct) return direct;
        } catch (e) { /* fall through */ }
        const ctx = parseContextData(record);
        return (ctx.ProviderVisit && ctx.ProviderVisit.AccountId)
            || (ctx.Visit && ctx.Visit.AccountId)
            || ctx.AccountId
            || null;
    }

    /**
     * True when THIS visit has already been processed by the limit engine — i.e.
     * its Visit_Limit_Status__c is already populated (non-empty). Such a visit's
     * outcome was decided on a prior pass, so we skip re-validating it (e.g. on a
     * re-save / edit) to avoid re-prompting or double-counting it. An empty/blank
     * status means "not yet processed" → validate normally.
     *
     * Reads the parent visit's value via record.stringValue first (custom field →
     * ns()), falling back to the parsed context (ctx.Visit / ctx.ProviderVisit) 
     * so it works on both web and mobile, same robustness pattern as resolveAccountId.
     */
    function visitAlreadyProcessed(record) {
        let val;
        try { val = record.stringValue(ns('Visit_Limit_Status__c')); } catch (e) { /* fall through */ }
        if (val === undefined || val === null || val === '') {
            const ctx = parseContextData(record);
            val = (ctx.Visit && (rowField(ctx.Visit, 'Visit_Limit_Status__c')))
                || (ctx.ProviderVisit && (rowField(ctx.ProviderVisit, 'Visit_Limit_Status__c')))
                || null;
        }
        return !!(val && String(val).trim() !== '');
    }

    /** As visitAlreadyProcessed, but for a child visit's context row. */
    function childVisitAlreadyProcessed(childRow) {
        const val = rowField(childRow, 'Visit_Limit_Status__c');
        return !!(val && String(val).trim() !== '');
    }

    /**
     * Resolve the running rep's UserId.
     * Prefers env.getOption('userId') (always present, never throws). Falls back
     * to the user record's 'uid' pseudo-field, then 'Id'. Reading 'uid' is more
     * robust than 'Id' because it is always accessible regardless of the running
     * user's field-level security on User.Id.
     */
    function resolveUserId(env, user) {
        try {
            if (env && typeof env.getOption === 'function') {
                const fromEnv = env.getOption('userId');
                if (fromEnv) return fromEnv;
            }
        } catch (e) { /* fall through */ }
        try {
            const uid = user.stringValue('uid');
            if (uid) return uid;
        } catch (e) { /* fall through */ }
        try {
            return user.stringValue('Id');
        } catch (e) {
            return null;
        }
    }

    /**
     * Read a row's value tolerant of mobile (lowercased keys) vs web (API casing).
     * DataRow lowercases every key on iOS serialization; web preserves Salesforce
     * API casing. Always check API casing first, fall back to lower.
     */
    function rowField(row, apiName) {
        if (!row || !apiName) return undefined;
        if (row[apiName] !== undefined) return row[apiName];
        return row[apiName.toLowerCase()];
    }

    /**
     * Pull product IDs out of a list of detail rows. Used for both the parent
     * visit (rows from context-data top level) and child visits (rows from a DB
     * query — see resolveChildProductIds). Indexed loop for Proxy-array safety.
     */
    function productIdsFromDetailRows(rows) {
        const productIds = [];
        for (let i = 0; i < rows.length; i++) {
            const d = rows[i];
            const productId = d && rowField(d, 'ProductId');
            if (productId) productIds.push(productId);
        }
        return productIds;
    }

    /**
     * Extract the Product2 IDs being detailed on this (parent) visit (may be
     * empty).
     */
    function resolveProductIds(record) {
        const ctx = parseContextData(record);
        const details = getFieldData(ctx, 'ProviderVisitProdDetailing') || [];
        return productIdsFromDetailRows(details);
    }

    /**
     * Extract the array of child-visit context objects from the parent record.
     * Cross-platform: web ships the children under the 'Visit.ParentVisitId'
     * relationship dot-path; mobile flattens them to a top-level 'ChildVisit'
     * array. Returns [] when the visit has no children (or the context object
     * IS the child — in which case validation runs once for that child as a
     * normal Save and the iteration over [] is a no-op).
     */
    function extractChildVisits(ctx) {
        if (!ctx) return [];
        const fromWeb = ctx['Visit.ParentVisitId'];
        const fromMobile = ctx['ChildVisit'];
        return (Array.isArray(fromWeb) && fromWeb.length > 0) ? fromWeb
             : (Array.isArray(fromMobile) && fromMobile.length > 0) ? fromMobile
             : [];
    }

    /** Resolve the AccountId of one child-visit row (lowercased on iOS). */
    function resolveChildAccountId(childRow) {
        return rowField(childRow, 'AccountId') || null;
    }

    /**
     * Resolve product-detail rows for one child visit by local DB query.
     * Child product details are never nested under the parent payload on either
     * platform, so we query ProviderVisitProdDetailing for the child visit.
     *
     * The attendee child rows ARE Visit records (their Id is a Visit Id), and
     * ProviderVisitProdDetailing links to a Visit via its VisitId field (it also
     * has a separate ProviderVisitId → ProviderVisit). So we match
     * ProviderVisitProdDetailing.VisitId = <child Visit Id>. The child's own Id
     * is the Visit Id; a 'VisitId' field on the row (when present) is the parent
     * link, so we prefer Id and only fall back to VisitId.
     *
     * For brand-new unsaved children whose detail rows haven't reached the DB
     * yet, this returns nothing and the orchestrator applies the
     * actionsThatCloneParentProductDetailsToChildren rule. Fails open to [].
     */
    async function resolveChildProductIds(childRow) {
        const childVisitId = rowField(childRow, 'Id') || rowField(childRow, 'VisitId');
        if (!childVisitId) return [];
        try {
            const rows = await db.query(
                'ProviderVisitProdDetailing',
                await new ConditionBuilder(
                    'ProviderVisitProdDetailing',
                    new FieldCondition('VisitId', '=', childVisitId)
                ).build(),
                ['Id', 'ProductId', 'VisitId']
            );
            const list = rows || [];
            const productIds = [];
            for (let i = 0; i < list.length; i++) {
                const pid = list[i].stringValue('ProductId');
                if (pid) productIds.push(pid);
            }
            return productIds;
        } catch (e) {
            return [];
        }
    }

    /**
     * Convert a remaining count into a validation result.
     *   remaining > 1  → success (silent)
     *   remaining = 1  → warning (last visit allowed — Continue/Cancel popup)
     *   remaining <= 0 → error   (blocks save)
     *
     * Localization: the warning and error titles are pre-translated via
     * formatLabelTranslation, which resolves the Custom Label key and
     * substitutes {0} positionally. The framework expects title to be a
     * plain string (CustomScriptResult.toString is called on the JS value);
     * returning a structured object renders as '[object Object]' on iOS.
     * Success stays a plain English string because it's silent.
     */
    function evaluateLimit(remaining, label, verb) {
        if (remaining > 1) {
            return { status: 'success', title: label + ' — within limits' };
        }
        if (remaining === 1) {
            return {
                status: 'warning',
                title: formatLabelTranslation(
                    'VisitLimitWarning',
                    'This visit will reach the {0} (last visit allowed).',
                    [label]
                )
            };
        }
        return {
            status: 'error',
            title: formatLabelTranslation(
                'VisitLimitError',
                'This visit will exceed the {0} (no remaining).',
                [label]
            )
        };
    }

    /**
     * Resolve the enforcement mode from the rep's assigned permission sets. This
     * lets one profile span regions while enforcement differs by region, driven
     * purely by permission set assignment. Rename these to match your org:
     *   'hard' — "VisitLimits_UK" assigned → breaches block (warning → error)
     *   'soft' — "VisitLimits_IT" assigned → breaches advise (error → warning)
     *   null   — neither assigned → default tiering
     * Permission sets are read as ordinary queries; no special API needed.
     * Fails open (returns null) on any query error, so a configuration or
     * permission issue never blocks the rep.
     */
    async function resolveEnforcementMode(userId) {
        if (!userId) return null;
        try {

            // (1) name -> id for our two control permission sets.
            let itId = null, ukId = null;
            const psRows = await db.query(
                'PermissionSet',
                await new ConditionBuilder(
                    'PermissionSet',
                    new SetCondition('Name', 'IN', ['VisitLimits_IT', 'VisitLimits_UK'])
                ).build(),
                ['Id', 'Name']
            );
            const psList = psRows || [];
            for (let i = 0; i < psList.length; i++) {
                const nm = psList[i].stringValue('Name');
                const id = psList[i].stringValue('Id');
                if (nm === 'VisitLimits_IT') itId = id;
                if (nm === 'VisitLimits_UK') ukId = id;
            }
            if (!itId && !ukId) return null;

            // (2) the rep's assigned permission-set ids (flat field works).
            const psaRows = await db.query(
                'PermissionSetAssignment',
                await new ConditionBuilder(
                    'PermissionSetAssignment',
                    new FieldCondition('AssigneeId', '=', userId)
                ).build(),
                ['PermissionSetId']
            );
            const assignedIds = {};
            const psaList = psaRows || [];
            for (let i = 0; i < psaList.length; i++) {
                const pid = psaList[i].stringValue('PermissionSetId');
                if (pid) assignedIds[pid] = true;
            }

            // hard wins if a rep somehow has both.
            if (ukId && assignedIds[ukId]) return 'hard';
            if (itId && assignedIds[itId]) return 'soft';
            return null;
        } catch (e) {
            return null;
        }
    }

    /**
     * Apply the enforcement mode to one result.
     *   'soft' — downgrades errors → warnings (rep can proceed past a breach)
     *   'hard' — upgrades warnings → errors (any breach blocks the save)
     * success is never changed.
     */
    function applyEnforcementMode(result, mode) {
        if (!result || !mode) return result;
        if (mode === 'soft' && result.status === 'error') {
            return { status: 'warning', title: result.title };
        }
        if (mode === 'hard' && result.status === 'warning') {
            return { status: 'error', title: result.title };
        }
        return result;
    }

    /**
     * Prefix a result's title with which visit it came from, so reps can tell a
     * parent breach from a specific attendee's breach (e.g. "Parent visit — …"
     * vs "Child visit 2 (acct …) — …"). Only changes the title; preserves status.
     *
     * Title shape depends on platform — see formatLabelTranslation:
     *   - Web: structured { key, defaultValue, argumentsList }. Prepend the
     *     visit label into defaultValue (the resolved value when the label
     *     isn't deployed) AND prepend a literal '{ visit } — ' prefix into
     *     a copy of the structured object so the validation-result handler
     *     sees it. We can't string-concat — that produces '[object Object]'.
     *   - Mobile: plain string. Direct concat works.
     */
    function labelResult(result, visitLabel) {
        if (!result || !visitLabel) return result;
        const t = result.title;
        if (t && typeof t === 'object' && t.key) {
            return {
                status: result.status,
                title: {
                    key: t.key,
                    defaultValue: visitLabel + ' — ' + t.defaultValue,
                    argumentsList: t.argumentsList
                }
            };
        }
        return { status: result.status, title: visitLabel + ' — ' + t };
    }

    /**
     * Find Visit rows for an account that have not yet been processed by the
     * engine (Visit_Limit_Status__c is null or empty), excluding the visits we
     * are validating in this very run. Used to add pending in-flight visits to
     * the limit check so we don't under-count breaches between save-time and
     * engine-job-time. excludeVisitIds is parent + every child Id from the
     * current validation context, so a re-save of an already-saved-but-unprocessed
     * visit doesn't double-count itself.
     *
     * Counts every visit with an empty Visit_Limit_Status__c as pending,
     * regardless of its Status (including Completed). This matters for mobile
     * offline: the engine does not debit the used count until the visit syncs, so
     * a visit submitted offline is Completed yet still genuinely uncounted. An
     * empty Visit_Limit_Status__c is the single "not yet processed" signal.
     *
     * Returns [{id, ownerId}]. Fails open to [] on any query error.
     *
     * Note: the exclude-list clause is omitted when the list is empty, because an
     * empty "NOT IN ()" predicate is invalid on the mobile (SQLite) query path.
     */
    async function findPendingVisitsForAccount(accountId, excludeVisitIds) {
        if (!accountId) return [];
        const exclude = excludeVisitIds || [];
        try {

            // The exclude-list field differs by platform: mobile rows are keyed
            // by 'uid' (always populated, even for local pre-sync visits), web
            // rows by 'Id' (the server-side record Id).
            const excludeField = isMobilePlatform ? 'uid' : 'Id';
            const statusEmpty = new OrCondition()
                .add(new FieldCondition(ns('Visit_Limit_Status__c'), '=', null))
                .add(new FieldCondition(ns('Visit_Limit_Status__c'), '=', ''));
            const condition = new AndCondition()
                .add(new FieldCondition('AccountId', '=', accountId))
                .add(statusEmpty);
            if (exclude.length > 0) {
                condition.add(new SetCondition(excludeField, 'NOT IN', exclude));
            }
            const rows = await db.query(
                'Visit',
                await new ConditionBuilder('Visit', condition).build(),
                ['Id', 'OwnerId']
            );
            const list = rows || [];
            const out = [];
            for (let i = 0; i < list.length; i++) {

                // Prefer 'uid' on mobile (local-only visits have a uid but no Id),
                // fall back to 'Id' on web (no uid column there).
                const id = isMobilePlatform
                    ? (list[i].stringValue('uid') || list[i].stringValue('Id'))
                    : list[i].stringValue('Id');
                const ownerId = list[i].stringValue('OwnerId');
                if (id) out.push({ id: id, ownerId: ownerId });
            }
            return out;
        } catch (e) {
            return [];
        }
    }

    /**
     * Given a set of pending Visit Ids (from findPendingVisitsForAccount) and
     * the product Ids in scope (the rolled-up list from perProductVisitLimit),
     * return { productId: count-of-distinct-visit-ids-detailing-that-product }.
     *
     * Counts DISTINCT VisitId per product, so a pending visit with two detail
     * rows for the same product still adds 1 to that product's pending count
     * (one visit consumes one slot per product, regardless of how many detail
     * lines it has — matches the engine's eventual behavior).
     *
     * Fails open to {} on any query error.
     */
    async function countPendingVisitsByProduct(pendingVisitIds, productIds) {
        const out = {};
        if (!pendingVisitIds || pendingVisitIds.length === 0) return out;
        if (!productIds || productIds.length === 0) return out;
        try {
            const condition = new AndCondition()
                .add(new SetCondition('VisitId', 'IN', pendingVisitIds))
                .add(new SetCondition('ProductId', 'IN', productIds));
            const rows = await db.query(
                'ProviderVisitProdDetailing',
                await new ConditionBuilder('ProviderVisitProdDetailing', condition).build(),
                ['VisitId', 'ProductId']
            );
            const list = rows || [];
            const seen = {}; // productId -> { visitId: true } so we de-dup multi-detail rows
            for (let i = 0; i < list.length; i++) {
                const vid = list[i].stringValue('VisitId');
                const pid = list[i].stringValue('ProductId');
                if (!vid || !pid) continue;
                if (!seen[pid]) seen[pid] = {};
                if (!seen[pid][vid]) {
                    seen[pid][vid] = true;
                    out[pid] = (out[pid] || 0) + 1;
                }
            }
            return out;
        } catch (e) {
            return {};
        }
    }

    /**
     * UserLimit check — per-rep cap for (Account, User).
     * We filter by User__c = userId, which naturally isolates UserLimit-record-type
     * rows: ProductLimit rows are cross-rep and leave User__c blank, so they can't
     * match a specific userId. This keeps the query to proven-safe flat-field
     * conditions (no RecordType relationship-path filter needed).
     *
     * pendingForThisRep is the count of in-flight visits owned by this rep on
     * this account that the engine hasn't processed yet (status null/empty),
     * EXCLUDING the visits in the current validation context. We subtract it
     * from each row's RemainingCount__c so the rep sees the correct effective
     * remaining (RemainingCount__c lags behind the engine's eventual UsedCount).
     *
     * Fails open (returns []) on any query error.
     */
    async function perRepVisitLimit(accountId, userId, verb, pendingForThisRep) {
        if (!accountId || !userId) return [];
        try {
            const condition = new AndCondition()
                .add(new FieldCondition(ns('Account__c'), '=', accountId))
                .add(new FieldCondition(ns('User__c'), '=', userId))
                .add(new FieldCondition(ns('IsActive__c'), '=', true));
            const rows = await db.query(
                ns('ProviderVisitLimit__c'),
                await new ConditionBuilder(ns('ProviderVisitLimit__c'), condition).build(),
                ['Id', ns('Region__c'), ns('LimitCount__c'), ns('UsedCount__c'), ns('RemainingCount__c')]
            );
            const pendingDeduction = (typeof pendingForThisRep === 'number') ? pendingForThisRep : 0;
            const results = [];
            const list = rows || [];
            for (let i = 0; i < list.length; i++) {
                const remainingRaw = list[i].numValue(ns('RemainingCount__c'));
                const remaining = (typeof remainingRaw === 'number') ? (remainingRaw - pendingDeduction) : remainingRaw;
                const region = list[i].stringValue(ns('Region__c')) || 'region';
                results.push(evaluateLimit(remaining, region + ' per-rep limit', verb));
            }
            return results;
        } catch (e) {
            return [];
        }
    }

    /**
     * Roll product variants up to their parent products via ProductLimitMap__c,
     * then dedupe. For example, two regional variants of a product can both roll
     * up to the same parent so they share one limit. Returns a deduped array of
     * (parent-or-self) product IDs. Fails open (returns the inputs un-rolled).
     */
    async function rollUpProducts(productIds) {
        if (!productIds || productIds.length === 0) return [];

        // Map of childId -> parentId from ProductLimitMap__c.
        const parentOf = {};
        try {
            const rows = await db.query(
                ns('ProductLimitMap__c'),
                await new ConditionBuilder(
                    ns('ProductLimitMap__c'),
                    new SetCondition(ns('Product__c'), 'IN', productIds)
                ).build(),
                [ns('Product__c'), ns('ParentProduct__c')]
            );
            const list = rows || [];
            for (let i = 0; i < list.length; i++) {
                const childId = list[i].stringValue(ns('Product__c'));
                const parentId = list[i].stringValue(ns('ParentProduct__c'));
                if (childId && parentId) parentOf[childId] = parentId;
            }
        } catch (e) {

            // No map / query failed → every product is its own parent.\
        }

        // Roll up + dedupe with a seen-object accumulator.
        const seen = {};
        const unique = [];
        for (let i = 0; i < productIds.length; i++) {
            const rolled = parentOf[productIds[i]] || productIds[i];
            if (!seen[rolled]) {
                seen[rolled] = true;
                unique.push(rolled);
            }
        }
        return unique;
    }

    /**
     * Build an id → Name map for a set of LifeSciMarketableProduct ids, so limit
     * messages can show the product name instead of the raw id. Fails open to an
     * empty map (the caller then falls back to showing the id).
     */
    async function resolveProductNames(productIds) {
        const nameOf = {};
        if (!productIds || productIds.length === 0) return nameOf;
        try {
            const rows = await db.query(
                'LifeSciMarketableProduct',
                await new ConditionBuilder(
                    'LifeSciMarketableProduct',
                    new SetCondition('Id', 'IN', productIds)
                ).build(),
                ['Id', 'Name']
            );
            const list = rows || [];
            for (let i = 0; i < list.length; i++) {
                const id = list[i].stringValue('Id');
                const nm = list[i].stringValue('Name');
                if (id && nm) nameOf[id] = nm;
            }
        } catch (e) {

            // Fail open — caller falls back to showing the raw id.
        }
        return nameOf;
    }

    /**
     * ProductLimit check — cross-rep cap for (Account, Product); counts across all
     * reps (not filtered by user). Matching on Product__c naturally selects only
     * ProductLimit rows, since per-rep UserLimit rows leave Product__c blank.
     *
     * pendingByProduct: { productId: count of distinct in-flight visits detailing
     * that product for THIS account }, keyed by the rolled-up product, subtracted
     * from each row's remaining so the rep sees the correct effective remaining.
     */
    async function perProductVisitLimit(accountId, productIds, verb, pendingByProduct) {
        if (!accountId || !productIds || productIds.length === 0) return [];
        const rolledProductIds = await rollUpProducts(productIds);
        if (rolledProductIds.length === 0) return [];
        try {
            const condition = new AndCondition()
                .add(new FieldCondition(ns('Account__c'), '=', accountId))
                .add(new SetCondition(ns('Product__c'), 'IN', rolledProductIds))
                .add(new FieldCondition(ns('IsActive__c'), '=', true));
            const rows = await db.query(
                ns('ProviderVisitLimit__c'),
                await new ConditionBuilder(ns('ProviderVisitLimit__c'), condition).build(),
                ['Id', ns('Product__c'), ns('Region__c'), ns('LimitCount__c'), ns('UsedCount__c'), ns('RemainingCount__c')]
            );
            const list = rows || [];

            // Resolve product names for friendlier messages.
            const limitProductIds = [];
            for (let i = 0; i < list.length; i++) {
                const pid = list[i].stringValue(ns('Product__c'));
                if (pid) limitProductIds.push(pid);
            }
            const nameOf = await resolveProductNames(limitProductIds);
            const pending = pendingByProduct || {};
            const results = [];
            for (let i = 0; i < list.length; i++) {
                const remainingRaw = list[i].numValue(ns('RemainingCount__c'));
                const region = list[i].stringValue(ns('Region__c')) || 'region';
                const productId = list[i].stringValue(ns('Product__c'));
                const productName = nameOf[productId] || productId;
                const pendingDeduction = pending[productId] || 0;
                const remaining = (typeof remainingRaw === 'number') ? (remainingRaw - pendingDeduction) : remainingRaw;
                results.push(evaluateLimit(remaining, region + ' product limit for ' + productName, verb));
            }
            return results;
        } catch (e) {
            return [];
        }
    }

    /**
     * Run UserLimit + ProductLimit for ONE visit (parent OR child), tagged with
     * visitLabel so the rep can tell which visit each result came from. The
     * enforcement mode is resolved once at the top level and passed in, so we
     * don't re-query PermissionSet for every child. Returns a (possibly empty)
     * array of labelled result objects.
     */
    async function runLimitChecksForVisit(visitLabel, accountId, userId, productIds, verb, mode, excludeVisitIds) {
        if (!accountId) return [];

        // Pending in-flight visits for THIS visit's account (status null/empty,
        // not in the current validation context). Derive per-rep count for
        // UserLimit and per-product distinct-VisitId map for ProductLimit.
        const pendingVisits = await findPendingVisitsForAccount(accountId, excludeVisitIds || []);
        const pendingVisitIds = [];
        let pendingForThisRep = 0;
        for (let i = 0; i < pendingVisits.length; i++) {
            pendingVisitIds.push(pendingVisits[i].id);
            if (userId && pendingVisits[i].ownerId === userId) pendingForThisRep += 1;
        }
        const rolledProductIds = (productIds && productIds.length > 0) ? await rollUpProducts(productIds) : [];
        const pendingByProduct = await countPendingVisitsByProduct(pendingVisitIds, rolledProductIds);
        let rawResults = [];
        const userResults = await perRepVisitLimit(accountId, userId, verb, pendingForThisRep);
        rawResults = rawResults.concat(userResults);
        const productResults = await perProductVisitLimit(accountId, productIds, verb, pendingByProduct);
        rawResults = rawResults.concat(productResults);
        const labeled = [];
        for (let i = 0; i < rawResults.length; i++) {
            labeled.push(labelResult(applyEnforcementMode(rawResults[i], mode), visitLabel));
        }
        return labeled;
    }

    /**
     * Top-level orchestrator. On a parent Save/Sign/Submit with attendees,
     * validates the parent AND every child visit; on a child Save (no ChildVisit
     * array in context) it validates just that one visit (the loop over [] is a
     * no-op). Settings kill-switch + action eligibility are evaluated once here,
     * up front, before any per-visit work.
     *
     * Child product details are NOT in the parent payload on either platform, so
     * we fetch them per child via local DB query (resolveChildProductIds). On the
     * actions in actionsThatCloneParentProductDetailsToChildren, the engine clones
     * the parent's products onto a detail-empty child AFTER validation — so to
     * avoid under-counting, an empty child inherits the parent's productIds for
     * the validation pass (skipped when the parent has no products to clone).
     */
    async function runLimitChecksAllVisits(parentRecord, parentAccountId, userId, parentProductIds, verb, actionName) {

        // (1) Settings: master kill-switch + action gating. Read from __mdt on
        // web and on mobile when the CMT is registered for sync; hardcoded
        // defaults otherwise. Only an explicit IsActive__c=false disables — any
        // read failure defaults to enforcing, never silently off.
        const settings = await resolveSettings(db);
        if (settings.active === false) {
            return [{ status: 'success', title: 'Visit-limit enforcement disabled by settings' }];
        }
        if (settings.allowedActions.indexOf(actionName) === -1) {
            return [{ status: 'success', title: 'Visit-limit check skipped — action is "' + actionName + '"' }];
        }

        // (2) Per-country enforcement mode — resolved once, applied to every result.
        const mode = await resolveEnforcementMode(userId);

        // (2b) Build the excludeVisitIds list once — every visit Id being
        // validated in this run (parent + each child). The pending-visit queries
        // subtract these so a re-save of an already-saved-but-unprocessed visit
        // doesn't double-count itself in its own limit check.
        const ctx = parseContextData(parentRecord);
        const childRows = extractChildVisits(ctx);

        // Resolve the parent visit's own Id robustly across platforms. We try, in
        // order: the record's 'uid' (mobile), then the Visit / ProviderVisit
        // context Id (web), then the record's 'Id'. First non-empty wins.
        const excludeVisitIds = [];
        let parentVisitId = null;
        try { parentVisitId = parentRecord && typeof parentRecord.stringValue === 'function' ? parentRecord.stringValue('uid') : null; } catch (e) { /* fall through */ }
        if (!parentVisitId && ctx && ctx.Visit) parentVisitId = rowField(ctx.Visit, 'VisitId') || rowField(ctx.Visit, 'Id');
        if (!parentVisitId && ctx && ctx.ProviderVisit) parentVisitId = rowField(ctx.ProviderVisit, 'VisitId') || rowField(ctx.ProviderVisit, 'Id');
        if (!parentVisitId) {
            try { parentVisitId = parentRecord && typeof parentRecord.stringValue === 'function' ? parentRecord.stringValue('Id') : null; } catch (e) { /* fall through */ }
        }
        if (parentVisitId) excludeVisitIds.push(parentVisitId);
        for (let i = 0; i < childRows.length; i++) {
            const cid = rowField(childRows[i], 'Id') || rowField(childRows[i], 'VisitId');
            if (cid) excludeVisitIds.push(cid);
        }
        const allResults = [];

        // (3) Parent visit (or the lone visit, if we arrived via a child Save).
        // Skip if this visit was already processed (Visit_Limit_Status__c set) —
        // its outcome was decided on a prior pass; re-validating a re-save would
        // re-prompt / double-count it.
        if (!visitAlreadyProcessed(parentRecord)) {
            const parentResults = await runLimitChecksForVisit(
                'Parent visit', parentAccountId, userId, parentProductIds, verb, mode, excludeVisitIds);
            for (let i = 0; i < parentResults.length; i++) allResults.push(parentResults[i]);
        }

        // (4) Child visits — only present when the parent is the visit being saved.
        const cloneTriggered =
            actionsThatCloneParentProductDetailsToChildren.indexOf(actionName) !== -1
            && parentProductIds.length > 0;
        for (let i = 0; i < childRows.length; i++) {
            const child = childRows[i];

            // Skip already-processed children too (same reason as the parent).
            if (childVisitAlreadyProcessed(child)) continue;
            const childAccountId = resolveChildAccountId(child);
            let childProductIds = await resolveChildProductIds(child);
            const ownProductCount = childProductIds.length;
            const willClone = (ownProductCount === 0 && cloneTriggered);
            if (willClone) {
                childProductIds = parentProductIds;
            }
            const childLabel = 'Child visit ' + (i + 1) +
                (childAccountId ? ' (acct ' + childAccountId + ')' : '');
            const childResults = await runLimitChecksForVisit(
                childLabel, childAccountId, userId, childProductIds, verb, mode, excludeVisitIds);
            for (let j = 0; j < childResults.length; j++) allResults.push(childResults[j]);
        }

        // No matching limits anywhere → explicit success (never [] / null, which
        // the framework treats as a generic failure).
        if (allResults.length === 0) {
            allResults.push({ status: 'success', title: 'No active visit limits configured for this account' });
        }
        return allResults;
    }

    // ─── Entry point ────────────────────────────────────────────────────────
    if (record && user && env && db) {
        const actionName = getActionName(env);

        // Action-aware verb wording for messages.
        const verbByAction = {
            Save:   'Saving',
            Submit: 'Submitting',
            Sign:   'Capturing signature for'
        };
        const verb = verbByAction[actionName] || 'Logging';

        // Parse the visit context (the parent visit; children are resolved inside
        // the orchestrator). Settings and action-eligibility are evaluated there.
        const accountId = resolveAccountId(record);
        const userId = resolveUserId(env, user);
        const productIds = resolveProductIds(record);

        // The two surfaces expect a different return shape, so we detect which one
        // we're on and return accordingly. Web awaits a promise; mobile evaluates
        // synchronously and expects a plain array. We detect the surface from the
        // context (web exposes a top-level "ProviderVisit" key, mobile "Visit"),
        // which lets the same source run on both.
        const ctxData = parseContextData(record) || {};
        const hasWebField = ctxData['ProviderVisit'] !== undefined;
        try {
            if (hasWebField) {

                // Web — return [promise] so the framework can await it.
                const resultsPromise = runLimitChecksAllVisits(record, accountId, userId, productIds, verb, actionName)
                    .then(function (results) {
                        return results;
                    })
                    .catch(function (error) {
                        return [{ status: 'success', title: 'Visit-limit check skipped — technical error' }];
                    });
                return [resultsPromise];
            } else {

                // Mobile — resolves to an array directly.
                const results = runLimitChecksAllVisits(record, accountId, userId, productIds, verb, actionName);
                return results;
            }
        } catch (error) {
            return [{ status: 'success', title: 'Visit-limit check skipped — technical error' }];
        }
    }
    return [];
})();
 
Loading
Salesforce Help | Article