import { getNamespaceAndKey } from "@applicaster/zapp-react-native-utils/appUtils/contextKeysManager/utils"; import { log_error, log_info } from "./logger"; /** * Boolean expressions over storage keys, used by the General Content Screen * hook adapter to decide whether a hook should be skipped. * * The `skip_hook_storage_key` rule normally holds a comma-separated list of * keys, meaning "skip when any of these exists". That covers presence only, so * a condition such as "the user is logged in but has not picked a profile yet" * cannot be expressed. To keep the manifest field untouched, an expression may * be packed into the very same string as JSON: anything starting with `{` is * parsed as an expression, anything else keeps the legacy behaviour. * * The expression evaluates to the SKIP condition — true means "do not present * the hook" — matching the name of the rule it is configured in. * * Keys are written as `namespace.key` and resolved exactly like the legacy * format: everything before the LAST dot is the namespace, so * `com.applicaster.feature.someKey` reads `someKey` from `com.applicaster.feature`. * A key with no dot falls back to the default namespace. * * Operators: * * "ns.key" shorthand for { "exists": "ns.key" } * { "exists": "ns.key" } the key holds a truthy value * { "missing": "ns.key" } the key holds no value * { "equals": { "key": "ns.key", "value": "kids" } } * { "all": [ …operands ] } AND, short-circuits on the first false * { "any": [ …operands ] } OR, short-circuits on the first true * { "not": operand } negation * * @example Show the profile selector to a logged-in user who has no profile yet * ```json * { * "any": [ * { "missing": "quick-brick-login-flow.access_token" }, * { "exists": "user_account.profile" } * ] * } * ``` * As it is stored in the `skip_hook_storage_key` string field: * ```json * "skip_hook_storage_key": "{\"any\":[{\"missing\":\"quick-brick-login-flow.access_token\"},{\"exists\":\"user_account.profile\"}]}" * ``` * * @example Skip an onboarding hook once it has been seen on a subscribed device * ```json * { * "all": [ * "onboarding.completed", * { "equals": { "key": "user_account.plan", "value": "premium" } } * ] * } * ``` * * Every failure mode — malformed JSON, an unknown operator, an operand of the * wrong type, a storage read that throws — evaluates to false, so a broken * configuration presents the hook rather than silently hiding a screen. */ export type SkipExpression = | string | { exists: string } | { missing: string } | { equals: { key: string; value: string } } | { all: SkipExpression[] } | { any: SkipExpression[] } | { not: SkipExpression }; /** Reads a single storage key. Injected so evaluation stays storage-agnostic. */ export type ReadKey = (key: string, namespace?: string) => Promise; const isPlainObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const hasOperator = (node: Record, operator: string) => Object.prototype.hasOwnProperty.call(node, operator); /** * Whether a `skip_hook_storage_key` value is meant as an expression at all. * * Deliberately a shape check rather than a successful parse: a value that opens * with `{` was written as an expression even when its JSON is broken, and must * never fall back to being read as a list of storage keys. */ export const looksLikeSkipExpression = (input: unknown): boolean => isPlainObject(input) || (typeof input === "string" && input.trim().startsWith("{")); /** * Reads a `skip_hook_storage_key` value as an expression, or returns null when * it is not one — an empty value, a legacy comma-separated key list, or an * expression whose JSON is malformed. */ export const parseSkipExpression = (input: unknown): SkipExpression | null => { if (isPlainObject(input)) { return input as SkipExpression; } if (!looksLikeSkipExpression(input)) { return null; } const trimmed = (input as string).trim(); try { const parsed = JSON.parse(trimmed); if (!isPlainObject(parsed)) { log_error( `parseSkipExpression: Expression must be an object, got: ${trimmed}` ); return null; } return parsed as SkipExpression; } catch (error) { log_error( `parseSkipExpression: Malformed expression: ${trimmed}. Error: ${error.message}`, { error } ); return null; } }; const keyIsTruthy = async (key: string, readKey: ReadKey): Promise => { const { namespace, key: name } = getNamespaceAndKey(key); return Boolean(await readKey(name, namespace)); }; const evaluateEquals = async ( operand: { key: string; value: string }, readKey: ReadKey ): Promise => { const { namespace, key: name } = getNamespaceAndKey(operand.key); const value = await readKey(name, namespace); return value == null ? false : String(value) === String(operand.value); }; const evaluateNode = async ( node: SkipExpression, readKey: ReadKey ): Promise => { try { if (typeof node === "string") { return await keyIsTruthy(node, readKey); } if (!isPlainObject(node)) { log_error( `evaluateSkipExpression: Not an operator: ${JSON.stringify(node)}` ); return false; } // The node came from configuration JSON, so every operand is read back as // unknown and type-checked here rather than trusted from the union. const operator = node as Record; if ( hasOperator(operator, "exists") && typeof operator.exists === "string" ) { return await keyIsTruthy(operator.exists, readKey); } if ( hasOperator(operator, "missing") && typeof operator.missing === "string" ) { return !(await keyIsTruthy(operator.missing, readKey)); } if (hasOperator(operator, "equals") && isPlainObject(operator.equals)) { return await evaluateEquals( operator.equals as { key: string; value: string }, readKey ); } if (hasOperator(operator, "all") && Array.isArray(operator.all)) { for (const operand of operator.all) { if (!(await evaluateNode(operand, readKey))) { return false; } } // An empty operand list carries no condition, so it stays false rather // than skipping the hook on the vacuous truth of an empty AND. return operator.all.length > 0; } if (hasOperator(operator, "any") && Array.isArray(operator.any)) { for (const operand of operator.any) { if (await evaluateNode(operand, readKey)) { return true; } } return false; } if (hasOperator(operator, "not")) { return !(await evaluateNode(operator.not as SkipExpression, readKey)); } log_error( `evaluateSkipExpression: Unknown or malformed operator: ${JSON.stringify( node )}` ); return false; } catch (error) { log_error( `evaluateSkipExpression: Error: ${error.message} evaluating: ${JSON.stringify( node )}`, { error } ); return false; } }; /** * Evaluates a parsed expression to the skip decision. A null expression — the * field held no expression — is not a condition, so nothing is skipped. */ export const evaluateSkipExpression = async ( expression: SkipExpression | null, readKey: ReadKey ): Promise => { if (!expression) { return false; } const shouldSkip = await evaluateNode(expression, readKey); log_info( `evaluateSkipExpression: Expression evaluated to ${shouldSkip}, ${ shouldSkip ? "skipping hook" : "proceeding with hook" }` ); return shouldSkip; };