#!/usr/bin/env python3 """Apply the runtime Credits / Credits Top-up approval seed directly to TiDB. Uses idempotent direct-SQL shape, adapted for the Credits approval chain. Differences from the Quote seed: * two target objects (``Credits__c`` and ``Credits_Topup__c``), so ``tableEnumOrId`` is per-definition, not a single constant; * approvers are ``relatedUserField`` (resolved per record from stamped approver fields), so there is no static approver-user remap and no ``approverUsername``; * ``EntryFilter`` is a plain simple-token string (e.g. ``Credit_Type__c equals PoC``) or empty, NOT the structured Quote-style object, so it is written verbatim rather than JSON-encoded. Idempotent: reuses the production row ids, so on an environment that already carries the hand-patched chain (staging/prod) this converges to a no-op; on a fresh bootstrap it materialises the chain. Refuses to overwrite a row that a different pack already owns. Since 2026-07-13 the seed also carries the Phase 3 item-2 notification wiring: ``emailTemplates`` rows (upserted into ``_System_EmailTemplate``) and ``ProcessAction`` rows with ``ActionType='Flow'`` that bind the pack's ``Credits_Notify_*`` approval-notification Flows (which pair ``sendEmail`` with the ``sendNotification`` in-app actionCall from upstream NX PR#3627) to the Submit / StepApproval / FinalApproval / FinalRejection lifecycle events. """ from __future__ import annotations import argparse import base64 import json import os import ssl from pathlib import Path from typing import Any CREDITS_TABLES = {"Credits__c", "Credits_Topup__c"} # OwnerPack values on an existing Credits ProcessDefinition that this seed is # allowed to adopt (converge onto) rather than treat as a foreign pack's row: # "" — ownerless (Quote-style; prod's current state) # "salesforce-crm-standard" — the owner stamped by the checked-in Credits # patch bundles (20260531/007, 20260602/001), which # is exactly the hand-patched state this seed exists # to take over. Refusing it would hard-fail the very # staging/prod convergence this seed is for. # The seed itself writes OwnerPack='' (it does not claim ownership — the chain is # not pack-owned yet, pending upstream NX#2187), so there is no self-marker to # allow. Any other owner is a genuinely different pack's row -> refuse. ADOPTABLE_OWNER_PACKS = {"", "salesforce-crm-standard"} # Credits status lifecycle: FieldUpdate actions write Approval_Status__c (not # Quote's Status), and Recall resets to NotSubmitted (not Quote's Draft). Every # process must carry exactly one of each. STATUS_FIELD = "Approval_Status__c" REQUIRED_STATUS_ACTIONS = { "Submit": "Pending", "FinalApproval": "Approved", "FinalRejection": "Rejected", "Recall": "NotSubmitted", } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Apply the runtime Credits approval seed directly to TiDB." ) parser.add_argument("--seed-file", required=True, type=Path) parser.add_argument("--validate-only", action="store_true") return parser.parse_args() def tls_enabled(raw: str) -> bool: return str(raw).strip().lower() in {"", "1", "true", "yes", "on"} def required_env(name: str) -> str: value = os.environ.get(name, "").strip() if not value: raise RuntimeError(f"{name} is required") return value def connect(): import pymysql kwargs: dict[str, Any] = { "host": required_env("TIDB_HOST"), "port": int(required_env("TIDB_PORT")), "user": required_env("TIDB_USER"), "password": os.environ.get("TIDB_PASSWORD", ""), "database": required_env("TIDB_DATABASE"), "charset": "utf8mb4", "autocommit": False, "connect_timeout": 10, "read_timeout": 60, "write_timeout": 60, "cursorclass": pymysql.cursors.DictCursor, } if tls_enabled(os.environ.get("TIDB_TLS", "true")): kwargs["ssl"] = ssl.create_default_context() return pymysql.connect(**kwargs) def bool_int(value: Any) -> int: return 1 if bool(value) else 0 def nullable_string(value: Any) -> str | None: if value is None: return None text = str(value) return text if text else None def entry_filter_text(definition: dict[str, Any]) -> str | None: # Credits EntryFilter is a plain simple-token string; write it verbatim. # (The Quote seed JSON-encodes a structured object here — do NOT copy that, # it would wrap the Credits filter in quotes.) # # Preserve the empty-string vs NULL distinction: prod's Credits_Topup row # carries an empty-string ("") match-all EntryFilter. Collapsing "" to NULL # would mutate that row away from its captured value on every deploy (not the # intended no-op) and can break approval lookups that treat '' and NULL # differently. Only a genuinely absent field maps to NULL. entry_filter = definition.get("entryFilter") if entry_filter is None: return None if not isinstance(entry_filter, str): raise RuntimeError( f"Credits entryFilter must be a simple-token string, got " f"{type(entry_filter).__name__}: {entry_filter!r}" ) return entry_filter def metadata_text(definition: dict[str, Any]) -> str: return base64.b64decode(definition["metadataBase64"]).decode("utf-8") def validate_seed(seed: dict[str, Any]) -> None: definitions = seed.get("processDefinitions") or [] nodes = seed.get("processNodes") or [] actions = seed.get("processActions") or [] developer_names = { str(d.get("developerName") or "").strip() for d in definitions } errors: list[str] = [] if "" in developer_names: errors.append("processDefinitions contains an empty developerName") developer_names.discard("") # developerName is the match key for existing_process; a duplicate would make # two definitions collapse onto one row at apply time (second silently # clobbers the first), so reject it up front. dev_name_list = [str(d.get("developerName") or "").strip() for d in definitions] dup_dev_names = sorted({n for n in dev_name_list if n and dev_name_list.count(n) > 1}) if dup_dev_names: errors.append(f"duplicate processDefinition developerName(s): {dup_dev_names}") for definition in definitions: table = str(definition.get("tableEnumOrId") or "").strip() if table not in CREDITS_TABLES: errors.append( f"processDefinition {definition.get('developerName')!r} has " f"unexpected tableEnumOrId {table!r} (expected one of {sorted(CREDITS_TABLES)})" ) if not str(definition.get("id") or "").strip(): errors.append( f"processDefinition {definition.get('developerName')!r} is missing an id" ) try: metadata_text(definition) except Exception as exc: # noqa: BLE001 - surface any decode failure errors.append( f"processDefinition {definition.get('developerName')!r} metadataBase64 " f"is not decodable: {exc}" ) # entryFilter must be applier-writable (a simple-token string or empty), # checked here so `--validate-only` catches a mis-shaped filter before a # real deploy reaches entry_filter_text mid-transaction. entry_filter = definition.get("entryFilter") if entry_filter not in (None, "") and not isinstance(entry_filter, str): errors.append( f"processDefinition {definition.get('developerName')!r} entryFilter " f"must be a simple-token string, got {type(entry_filter).__name__}" ) nodes_by_process: dict[str, int] = {} for node in nodes: process_developer_name = str(node.get("processDeveloperName") or "").strip() if process_developer_name not in developer_names: errors.append( f"processNode {node.get('id')!r} references unknown " f"processDeveloperName {process_developer_name!r}" ) else: nodes_by_process[process_developer_name] = ( nodes_by_process.get(process_developer_name, 0) + 1 ) # The applier hardcodes ApproverUserId=NULL, so it can ONLY materialize # relatedUserField approvers; reject any other approverType rather than # silently write a node with no assignee. approver_type = str(node.get("approverType") or "").strip() if approver_type != "relatedUserField": errors.append( f"processNode {node.get('id')!r} has approverType {approver_type!r}; " f"applier only supports 'relatedUserField'" ) elif not str(node.get("approverRelatedField") or "").strip(): errors.append( f"processNode {node.get('id')!r} is relatedUserField but has no " f"approverRelatedField" ) for developer_name in sorted(developer_names): if nodes_by_process.get(developer_name, 0) == 0: errors.append(f"{developer_name} has no approval steps (0 ProcessNode rows)") for action in actions: action_id = str(action.get("id") or "").strip() or "" process_developer_name = str(action.get("processDeveloperName") or "").strip() if process_developer_name not in developer_names: errors.append( f"processAction {action_id} references unknown " f"processDeveloperName {process_developer_name!r}" ) action_type = str(action.get("actionType") or "").strip() if action_type == "Flow": # ApiName carries the approval-notification Flow binding; a blank # one fails at runtime with 'ApiName is required for approval Flow # notification binding' — catch it in validation instead. if not str(action.get("apiName") or "").strip(): errors.append( f"processAction {action_id} ({process_developer_name}/" f"{action.get('actionEvent')}) is a Flow action without apiName" ) continue if action_type != "FieldUpdate": continue if not str(action.get("fieldApiName") or "").strip() or not str( action.get("fieldValue") or "" ).strip(): errors.append( f"processAction {action_id} ({process_developer_name}/" f"{action.get('actionEvent')}) is a FieldUpdate without " "fieldApiName/fieldValue" ) # Every process must carry the complete status machine (one FieldUpdate per # lifecycle event), so a deploy never materializes a half-wired chain. for developer_name in sorted(developer_names): for event, value in REQUIRED_STATUS_ACTIONS.items(): matching = [ action for action in actions if str(action.get("processDeveloperName") or "").strip() == developer_name and str(action.get("actionType") or "").strip() == "FieldUpdate" and str(action.get("actionEvent") or "").strip() == event and str(action.get("fieldApiName") or "").strip() == STATUS_FIELD and str(action.get("fieldValue") or "").strip() == value ] if len(matching) != 1: errors.append( f"{developer_name} must have exactly one {event} " f"{STATUS_FIELD}={value} FieldUpdate; found {len(matching)}" ) # A duplicated (process, event, flow) binding would run the Flow once per # row — double email + double bell on that event. The row-id uniqueness # check below cannot catch a copy-paste with a fresh id, so guard the # binding tuple explicitly. flow_bindings = [ ( str(a.get("processDeveloperName") or "").strip(), str(a.get("actionEvent") or "").strip(), str(a.get("apiName") or "").strip(), ) for a in actions if str(a.get("actionType") or "").strip() == "Flow" ] dup_bindings = sorted({b for b in flow_bindings if flow_bindings.count(b) > 1}) if dup_bindings: errors.append(f"duplicate Flow binding(s) (process, event, flow): {dup_bindings}") # Email templates are referenced by the notification Flows via their row # ids; each row needs id/name/developerName/subject and at least one body # form (name is read unconditionally by upsert_email_templates and the # column is NOT NULL). templates = seed.get("emailTemplates") or [] for template in templates: template_id = str(template.get("id") or "").strip() or "" if not str(template.get("name") or "").strip(): errors.append(f"emailTemplate {template_id} is missing a name") if not str(template.get("developerName") or "").strip(): errors.append(f"emailTemplate {template_id} is missing a developerName") if not str(template.get("subject") or "").strip(): errors.append(f"emailTemplate {template_id} is missing a subject") if not str(template.get("body") or "").strip() and not str( template.get("htmlValue") or "" ).strip(): errors.append(f"emailTemplate {template_id} has neither body nor htmlValue") # Row ids become primary keys: every child row needs one (definition id # presence is checked above), and a duplicate would abort the deploy # mid-INSERT — or, for a definition, silently collapse onto another row. for label, rows, check_presence in ( ("processDefinition", definitions, False), ("processNode", nodes, True), ("processAction", actions, True), ("emailTemplate", templates, True), ): ids = [str(r.get("id") or "").strip() for r in rows] if check_presence and any(not i for i in ids): errors.append(f"{label} row(s) missing an id") present = [i for i in ids if i] dupes = sorted({i for i in present if present.count(i) > 1}) if dupes: errors.append(f"duplicate {label} id(s): {dupes}") if errors: raise RuntimeError("seed validation failed:\n - " + "\n - ".join(errors)) def load_seed(path: Path) -> dict[str, Any]: seed = json.loads(path.read_text()) if seed.get("version") != 1: raise RuntimeError(f"unsupported seed version in {path}: {seed.get('version')}") validate_seed(seed) return seed def existing_process(cur, developer_name: str) -> dict[str, Any] | None: cur.execute( """ SELECT Id, TableEnumOrId, OwnerPack FROM ProcessDefinition WHERE DeveloperName = %s LIMIT 1 FOR UPDATE """, (developer_name,), ) return cur.fetchone() def upsert_process_definition(cur, definition: dict[str, Any]) -> str: developer_name = definition["developerName"] table = definition["tableEnumOrId"] existing = existing_process(cur, developer_name) process_id = definition["id"] if existing: existing_table = str(existing.get("TableEnumOrId") or "").strip() if existing_table != table: raise RuntimeError( f"approval process developer name collision: {developer_name} " f"belongs to {existing_table!r}, seed targets {table!r}" ) owner_pack = str(existing.get("OwnerPack") or "").strip() if owner_pack not in ADOPTABLE_OWNER_PACKS: raise RuntimeError( f"refusing to overwrite approval process owned by another pack " f"{table}/{developer_name}: OwnerPack={owner_pack!r} " f"(adoptable: {sorted(ADOPTABLE_OWNER_PACKS)})" ) process_id = str(existing["Id"]) cur.execute( """ UPDATE ProcessDefinition SET Name = %s, Description = %s, State = %s, TableEnumOrId = %s, LockType = %s, EntryFilter = %s, FinalApprovalRecordLock = %s, FinalRejectionRecordLock = %s, AllowRecall = %s, IsActive = %s, Metadata = %s, ProcessOrder = %s, OwnerPack = '', LastModifiedDate = CURRENT_TIMESTAMP WHERE Id = %s """, ( definition["name"], nullable_string(definition.get("description")), definition["state"], table, nullable_string(definition.get("lockType")), entry_filter_text(definition), bool_int(definition.get("finalApprovalRecordLock")), bool_int(definition.get("finalRejectionRecordLock")), bool_int(definition.get("allowRecall")), bool_int(definition.get("isActive")), metadata_text(definition), definition.get("processOrder") or None, process_id, ), ) return process_id cur.execute( """ INSERT INTO ProcessDefinition (Id, Name, DeveloperName, Description, State, TableEnumOrId, LockType, EntryFilter, FinalApprovalRecordLock, FinalRejectionRecordLock, AllowRecall, IsActive, Metadata, ProcessOrder, OwnerPack, CreatedDate, LastModifiedDate) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """, ( process_id, definition["name"], developer_name, nullable_string(definition.get("description")), definition["state"], table, nullable_string(definition.get("lockType")), entry_filter_text(definition), bool_int(definition.get("finalApprovalRecordLock")), bool_int(definition.get("finalRejectionRecordLock")), bool_int(definition.get("allowRecall")), bool_int(definition.get("isActive")), metadata_text(definition), definition.get("processOrder") or None, ), ) return process_id # Children are UPSERTed (not delete-then-reinsert): an existing ProcessNode row # is updated in place, never DELETEd, so the `ProcessInstanceHistory.ProcessNodeId # -> ProcessNode ON DELETE SET NULL` FK never fires for a still-present node. # That keeps completed/in-flight approval history links intact across every # re-apply. Only genuinely-removed children (in the DB, absent from the seed) are # deleted, by delete_orphan_children — where nulling the history link is correct. def upsert_nodes(cur, nodes: list[dict[str, Any]], process_ids: dict[str, str]) -> None: for node in nodes: process_id = process_ids[node["processDeveloperName"]] cur.execute( """ INSERT INTO ProcessNode (Id, Name, DeveloperName, ProcessDefinitionId, Description, ApproverType, ApproverUserId, ApproverRelatedField, AllowComments, RequireComments, StepOrder, RejectBehavior, IsDeleted, CreatedDate, LastModifiedDate) VALUES (%s, %s, %s, %s, %s, %s, NULL, %s, %s, %s, %s, %s, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE Name=VALUES(Name), DeveloperName=VALUES(DeveloperName), ProcessDefinitionId=VALUES(ProcessDefinitionId), Description=VALUES(Description), ApproverType=VALUES(ApproverType), ApproverUserId=VALUES(ApproverUserId), ApproverRelatedField=VALUES(ApproverRelatedField), AllowComments=VALUES(AllowComments), RequireComments=VALUES(RequireComments), StepOrder=VALUES(StepOrder), RejectBehavior=VALUES(RejectBehavior), IsDeleted=0, LastModifiedDate=CURRENT_TIMESTAMP """, ( node["id"], node["name"], node["developerName"], process_id, nullable_string(node.get("description")), nullable_string(node.get("approverType")), nullable_string(node.get("approverRelatedField")), bool_int(node.get("allowComments")), bool_int(node.get("requireComments")), int(node.get("stepOrder") or 0), nullable_string(node.get("rejectBehavior")), ), ) def upsert_actions(cur, actions: list[dict[str, Any]], process_ids: dict[str, str]) -> None: for action in actions: cur.execute( """ INSERT INTO ProcessAction (Id, Name, ProcessDefinitionId, ProcessNodeId, ActionType, ActionEvent, FieldApiName, FieldValue, EmailTemplateId, ApiName, `Order`, IsDeleted, CreatedDate, LastModifiedDate) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE Name=VALUES(Name), ProcessDefinitionId=VALUES(ProcessDefinitionId), ProcessNodeId=VALUES(ProcessNodeId), ActionType=VALUES(ActionType), ActionEvent=VALUES(ActionEvent), FieldApiName=VALUES(FieldApiName), FieldValue=VALUES(FieldValue), EmailTemplateId=VALUES(EmailTemplateId), ApiName=VALUES(ApiName), `Order`=VALUES(`Order`), IsDeleted=0, LastModifiedDate=CURRENT_TIMESTAMP """, ( action["id"], action["name"], process_ids[action["processDeveloperName"]], nullable_string(action.get("processNodeId")), nullable_string(action.get("actionType")), nullable_string(action.get("actionEvent")), nullable_string(action.get("fieldApiName")), nullable_string(action.get("fieldValue")), nullable_string(action.get("emailTemplateId")), nullable_string(action.get("apiName")), int(action.get("order") or 0), ), ) def upsert_email_templates(cur, templates: list[dict[str, Any]]) -> None: # Notification email templates live in the _System_EmailTemplate system # table and are referenced from the Credits_Notify_* Flows by row id, so # the ids must stay deterministic. Same upsert-by-id discipline as the # approval children: an existing row is updated in place, never deleted. for template in templates: cur.execute( """ INSERT INTO _System_EmailTemplate (Id, Name, DeveloperName, Subject, Body, HtmlValue, Description, IsActive, IsDeleted, CreatedDate, LastModifiedDate) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE Name=VALUES(Name), DeveloperName=VALUES(DeveloperName), Subject=VALUES(Subject), Body=VALUES(Body), HtmlValue=VALUES(HtmlValue), Description=VALUES(Description), IsActive=VALUES(IsActive), IsDeleted=0, LastModifiedDate=CURRENT_TIMESTAMP """, ( template["id"], template["name"], template["developerName"], template["subject"], nullable_string(template.get("body")), nullable_string(template.get("htmlValue")), nullable_string(template.get("description")), bool_int(template.get("isActive", True)), ), ) def delete_orphan_email_templates(cur, seed_template_ids: list[str]) -> None: # Symmetric cleanup with the approval children: templates this seed once # managed (deterministic 'emailtmpl_credits_' prefix) but no longer declares # are removed, so an id re-mint (e.g. a content redesign dating a new id) # cannot strand a stale IsActive row in the admin template inventory. Rows # outside the prefix are never touched. if seed_template_ids: keep_ph = ",".join(["%s"] * len(seed_template_ids)) cur.execute( "DELETE FROM _System_EmailTemplate WHERE Id LIKE 'emailtmpl_credits_%%' " f"AND Id NOT IN ({keep_ph})", tuple(seed_template_ids), ) else: cur.execute("DELETE FROM _System_EmailTemplate WHERE Id LIKE 'emailtmpl_credits_%%'") def delete_orphan_children( cur, process_ids: list[str], seed_node_ids: list[str], seed_action_ids: list[str], ) -> None: """Remove children under these definitions that the seed no longer declares. Deleting a genuinely-removed ProcessNode legitimately nulls any history link to it (the step no longer exists). Still-present nodes are never deleted, so their history links survive. """ if not process_ids: return pid_ph = ",".join(["%s"] * len(process_ids)) for table, seed_ids in (("ProcessAction", seed_action_ids), ("ProcessNode", seed_node_ids)): if seed_ids: keep_ph = ",".join(["%s"] * len(seed_ids)) cur.execute( f"DELETE FROM {table} WHERE ProcessDefinitionId IN ({pid_ph}) " f"AND Id NOT IN ({keep_ph})", (*process_ids, *seed_ids), ) else: cur.execute( f"DELETE FROM {table} WHERE ProcessDefinitionId IN ({pid_ph})", tuple(process_ids), ) def apply_seed(seed: dict[str, Any]) -> dict[str, int]: conn = connect() try: with conn.cursor() as cur: process_ids: dict[str, str] = {} for definition in seed["processDefinitions"]: process_ids[definition["developerName"]] = upsert_process_definition( cur, definition ) upsert_nodes(cur, seed["processNodes"], process_ids) upsert_actions(cur, seed["processActions"], process_ids) upsert_email_templates(cur, seed.get("emailTemplates") or []) delete_orphan_email_templates( cur, [t["id"] for t in seed.get("emailTemplates") or []] ) delete_orphan_children( cur, list(process_ids.values()), [node["id"] for node in seed["processNodes"]], [action["id"] for action in seed["processActions"]], ) conn.commit() return { "process_definitions": len(seed["processDefinitions"]), "process_nodes": len(seed["processNodes"]), "process_actions": len(seed["processActions"]), "email_templates": len(seed.get("emailTemplates") or []), } except Exception: conn.rollback() raise finally: conn.close() def main() -> int: args = parse_args() seed = load_seed(args.seed_file) if args.validate_only: print( "credits approval seed valid: " f"{len(seed['processDefinitions'])} definitions, " f"{len(seed['processNodes'])} nodes, " f"{len(seed['processActions'])} actions, " f"{len(seed.get('emailTemplates') or [])} email templates" ) return 0 counts = apply_seed(seed) print( "applied credits approval seed: " f"{counts['process_definitions']} definitions, " f"{counts['process_nodes']} nodes, " f"{counts['process_actions']} actions, " f"{counts['email_templates']} email templates" ) return 0 if __name__ == "__main__": raise SystemExit(main())