10868 lines
348 KiB
JavaScript
10868 lines
348 KiB
JavaScript
(function () {
|
|
const root = document.getElementById("chd-admin-root");
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
const overviewUrl = root.dataset.overviewUrl || "";
|
|
const connectorRoutingConfigUrl =
|
|
root.dataset.connectorRoutingConfigUrl || "";
|
|
const saveConnectorRoutingConfigUrl =
|
|
root.dataset.saveConnectorRoutingConfigUrl || "";
|
|
const saveConnectorAdminTokenUrl =
|
|
root.dataset.saveConnectorAdminTokenUrl || "";
|
|
const saveBillingAutoFulfillmentUrl =
|
|
root.dataset.saveBillingAutoFulfillmentUrl || "";
|
|
const runBillingAutoFulfillmentUrl =
|
|
root.dataset.runBillingAutoFulfillmentUrl || "";
|
|
const backfillBillingAutoFulfillmentUrl =
|
|
root.dataset.backfillBillingAutoFulfillmentUrl || "";
|
|
const connectorSummaryUrl = root.dataset.connectorSummaryUrl || "";
|
|
const connectorInstancesUrl = root.dataset.connectorInstancesUrl || "";
|
|
const connectorInstanceDetailUrl =
|
|
root.dataset.connectorInstanceDetailUrl || "";
|
|
const paymenterClientsUrl = root.dataset.paymenterClientsUrl || "";
|
|
const paymenterOrdersUrl = root.dataset.paymenterOrdersUrl || "";
|
|
const paymenterSnapshotUrl = root.dataset.paymenterSnapshotUrl || "";
|
|
const paymenterSourceSyncUrl = root.dataset.paymenterSourceSyncUrl || "";
|
|
const paymenterBackupsUrl = root.dataset.paymenterBackupsUrl || "";
|
|
const paymenterInvoicesUrl = root.dataset.paymenterInvoicesUrl || "";
|
|
const paymenterInvoiceItemsUrl = root.dataset.paymenterInvoiceItemsUrl || "";
|
|
const saveConnectorInstanceTargetUrl =
|
|
root.dataset.saveConnectorInstanceTargetUrl || "";
|
|
const saveConnectorInstanceMetadataUrl =
|
|
root.dataset.saveConnectorInstanceMetadataUrl || "";
|
|
const saveConnectorInstanceStateUrl =
|
|
root.dataset.saveConnectorInstanceStateUrl || "";
|
|
const instanceOperationsUrl = root.dataset.instanceOperationsUrl || "";
|
|
const saveInstanceOperationUrl = root.dataset.saveInstanceOperationUrl || "";
|
|
const updateInstanceOperationStatusUrl =
|
|
root.dataset.updateInstanceOperationStatusUrl || "";
|
|
const introGrantTaskActionUrl = root.dataset.introGrantTaskActionUrl || "";
|
|
const connectorCatalogConfigUrl =
|
|
root.dataset.connectorCatalogConfigUrl || "";
|
|
const saveConnectorCatalogConfigUrl =
|
|
root.dataset.saveConnectorCatalogConfigUrl || "";
|
|
const qappsUrl = root.dataset.qappsUrl || "/apps/qortal_integration/qapps";
|
|
const qappsRequestUrl = root.dataset.qappsRequestUrl || "";
|
|
const qappsApproveUrl = root.dataset.qappsApproveUrl || "";
|
|
const qappsUnlockUrl = root.dataset.qappsUnlockUrl || "";
|
|
const qappsUnlockStatusUrl = root.dataset.qappsUnlockStatusUrl || "";
|
|
const gatewayProxyTemplate =
|
|
root.dataset.gatewayProxyTemplate ||
|
|
"/apps/qortal_integration/gateway/__PATH__";
|
|
const chdcQappAddress = root.dataset.chdcQappAddress || "qortal://CHDC";
|
|
|
|
const refreshButton = document.getElementById("chd-admin-refresh");
|
|
const connectorRefreshButton = document.getElementById(
|
|
"chd-admin-connector-refresh"
|
|
);
|
|
const instancesRefreshButton = document.getElementById(
|
|
"chd-admin-instances-refresh"
|
|
);
|
|
const instanceLoadButton = document.getElementById("chd-admin-instance-load");
|
|
const instanceSelectEl = document.getElementById("chd-admin-instance-select");
|
|
const overviewEl = document.getElementById("chd-admin-overview");
|
|
const overviewSummaryEl = document.getElementById(
|
|
"chd-admin-overview-summary"
|
|
);
|
|
const overviewStatusEl = document.getElementById("chd-admin-overview-status");
|
|
const overviewToggleButton = document.getElementById(
|
|
"chd-admin-overview-toggle"
|
|
);
|
|
const overviewAllButton = document.getElementById("chd-admin-overview-all");
|
|
const connectorRoutingProfileSelectEl = document.getElementById(
|
|
"chd-connector-routing-profile-select"
|
|
);
|
|
const connectorRoutingTestUrlEl = document.getElementById(
|
|
"chd-connector-routing-test-url"
|
|
);
|
|
const connectorRoutingProductionUrlEl = document.getElementById(
|
|
"chd-connector-routing-production-url"
|
|
);
|
|
const connectorRoutingSaveButton = document.getElementById(
|
|
"chd-connector-routing-save"
|
|
);
|
|
const connectorRoutingStatusEl = document.getElementById(
|
|
"chd-connector-routing-status"
|
|
);
|
|
const connectorSummaryEl = document.getElementById(
|
|
"chd-admin-connector-summary"
|
|
);
|
|
const connectorSummaryViewEl = document.getElementById(
|
|
"chd-admin-connector-summary-view"
|
|
);
|
|
const connectorStatusEl = document.getElementById(
|
|
"chd-admin-connector-status"
|
|
);
|
|
const connectorToggleButton = document.getElementById(
|
|
"chd-admin-connector-toggle"
|
|
);
|
|
const connectorAllButton = document.getElementById("chd-admin-connector-all");
|
|
const instancesListEl = document.getElementById("chd-admin-instances-list");
|
|
const instancesSummaryEl = document.getElementById(
|
|
"chd-admin-instances-summary"
|
|
);
|
|
const instancesCompactEl = document.getElementById(
|
|
"chd-admin-instances-compact"
|
|
);
|
|
const instancesToggleButton = document.getElementById(
|
|
"chd-admin-instances-toggle"
|
|
);
|
|
const instancesAllButton = document.getElementById("chd-admin-instances-all");
|
|
const instanceDetailEl = document.getElementById("chd-admin-instance-detail");
|
|
const instanceDetailSummaryEl = document.getElementById(
|
|
"chd-admin-instance-detail-summary"
|
|
);
|
|
const instanceDetailStatusEl = document.getElementById(
|
|
"chd-admin-instance-detail-status"
|
|
);
|
|
const instanceDetailToggleButton = document.getElementById(
|
|
"chd-admin-instance-detail-toggle"
|
|
);
|
|
const instanceDetailAllButton = document.getElementById(
|
|
"chd-admin-instance-detail-all"
|
|
);
|
|
const instanceConnectorProfileEl = document.getElementById(
|
|
"chd-admin-instance-connector-profile"
|
|
);
|
|
const instanceConnectorUrlEl = document.getElementById(
|
|
"chd-admin-instance-connector-url"
|
|
);
|
|
const instanceConnectorSaveButton = document.getElementById(
|
|
"chd-admin-instance-connector-save"
|
|
);
|
|
const instanceRemoveButton = document.getElementById(
|
|
"chd-admin-instance-remove"
|
|
);
|
|
const instanceConnectorStatusEl = document.getElementById(
|
|
"chd-admin-instance-connector-status"
|
|
);
|
|
const instanceMspContactNameEl = document.getElementById(
|
|
"chd-admin-instance-msp-contact-name"
|
|
);
|
|
const instanceMspContactEmailEl = document.getElementById(
|
|
"chd-admin-instance-msp-contact-email"
|
|
);
|
|
const instancePluginAdminEmailEl = document.getElementById(
|
|
"chd-admin-instance-plugin-admin-email"
|
|
);
|
|
const instanceAdminQortalAddressEl = document.getElementById(
|
|
"chd-admin-instance-admin-qortal-address"
|
|
);
|
|
const instanceMetadataSaveButton = document.getElementById(
|
|
"chd-admin-instance-metadata-save"
|
|
);
|
|
const instanceMetadataStatusEl = document.getElementById(
|
|
"chd-admin-instance-metadata-status"
|
|
);
|
|
const removedInstancesSummaryEl = document.getElementById(
|
|
"chd-admin-removed-instances-summary"
|
|
);
|
|
const removedInstancesListEl = document.getElementById(
|
|
"chd-admin-removed-instances-list"
|
|
);
|
|
const userOrdersSummaryEl = document.getElementById(
|
|
"chd-admin-user-orders-summary"
|
|
);
|
|
const userOrdersListEl = document.getElementById(
|
|
"chd-admin-user-orders-list"
|
|
);
|
|
const identityIndexSummaryEl = document.getElementById(
|
|
"chd-admin-identity-summary"
|
|
);
|
|
const identityIndexListEl = document.getElementById(
|
|
"chd-admin-identity-list"
|
|
);
|
|
const paymenterProfileViewEl = document.getElementById(
|
|
"chd-admin-paymenter-profile"
|
|
);
|
|
const paymenterStatusEl = document.getElementById(
|
|
"chd-admin-paymenter-status"
|
|
);
|
|
const paymenterClientsRefreshButton = document.getElementById(
|
|
"chd-admin-paymenter-clients-refresh"
|
|
);
|
|
const paymenterClientsSummaryEl = document.getElementById(
|
|
"chd-admin-paymenter-clients-summary"
|
|
);
|
|
const paymenterClientsListEl = document.getElementById(
|
|
"chd-admin-paymenter-clients-list"
|
|
);
|
|
const paymenterOrdersRefreshButton = document.getElementById(
|
|
"chd-admin-paymenter-orders-refresh"
|
|
);
|
|
const paymenterOrdersSummaryEl = document.getElementById(
|
|
"chd-admin-paymenter-orders-summary"
|
|
);
|
|
const paymenterOrdersListEl = document.getElementById(
|
|
"chd-admin-paymenter-orders-list"
|
|
);
|
|
const paymenterSnapshotRefreshButton = document.getElementById(
|
|
"chd-admin-paymenter-snapshot-refresh"
|
|
);
|
|
const paymenterSnapshotSummaryEl = document.getElementById(
|
|
"chd-admin-paymenter-snapshot-summary"
|
|
);
|
|
const paymenterSnapshotStatusEl = document.getElementById(
|
|
"chd-admin-paymenter-snapshot-status"
|
|
);
|
|
const paymenterSnapshotEl = document.getElementById(
|
|
"chd-admin-paymenter-snapshot"
|
|
);
|
|
const paymenterSnapshotToggleButton = document.getElementById(
|
|
"chd-admin-paymenter-snapshot-toggle"
|
|
);
|
|
const paymenterSnapshotAllButton = document.getElementById(
|
|
"chd-admin-paymenter-snapshot-all"
|
|
);
|
|
const paymenterSyncDryRunButton = document.getElementById(
|
|
"chd-admin-paymenter-sync-dry-run"
|
|
);
|
|
const paymenterSyncRunButton = document.getElementById(
|
|
"chd-admin-paymenter-sync-run"
|
|
);
|
|
const paymenterBackupsRefreshButton = document.getElementById(
|
|
"chd-admin-paymenter-backups-refresh"
|
|
);
|
|
const paymenterBackupsSummaryEl = document.getElementById(
|
|
"chd-admin-paymenter-backups-summary"
|
|
);
|
|
const paymenterBackupsListEl = document.getElementById(
|
|
"chd-admin-paymenter-backups-list"
|
|
);
|
|
const paymenterBackupsEl = document.getElementById(
|
|
"chd-admin-paymenter-backups"
|
|
);
|
|
const paymenterBackupsToggleButton = document.getElementById(
|
|
"chd-admin-paymenter-backups-toggle"
|
|
);
|
|
const paymenterBackupsAllButton = document.getElementById(
|
|
"chd-admin-paymenter-backups-all"
|
|
);
|
|
const paymenterInvoicesRefreshButton = document.getElementById(
|
|
"chd-admin-paymenter-invoices-refresh"
|
|
);
|
|
const paymenterInvoicesSummaryEl = document.getElementById(
|
|
"chd-admin-paymenter-invoices-summary"
|
|
);
|
|
const paymenterInvoicesListEl = document.getElementById(
|
|
"chd-admin-paymenter-invoices-list"
|
|
);
|
|
const paymenterInvoiceItemsRefreshButton = document.getElementById(
|
|
"chd-admin-paymenter-invoice-items-refresh"
|
|
);
|
|
const paymenterInvoiceItemsSummaryEl = document.getElementById(
|
|
"chd-admin-paymenter-invoice-items-summary"
|
|
);
|
|
const paymenterInvoiceItemsListEl = document.getElementById(
|
|
"chd-admin-paymenter-invoice-items-list"
|
|
);
|
|
const billingAutoFulfillmentEnabledEl = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-enabled"
|
|
);
|
|
const billingAutoFulfillmentSaveButton = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-save"
|
|
);
|
|
const billingAutoFulfillmentRunButton = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-run"
|
|
);
|
|
const billingAutoFulfillmentBackfillSinceEl = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-backfill-since"
|
|
);
|
|
const billingAutoFulfillmentBackfillInvoiceEl = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-backfill-invoice"
|
|
);
|
|
const billingAutoFulfillmentBackfillButton = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-backfill"
|
|
);
|
|
const billingAutoFulfillmentStatusEl = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-status"
|
|
);
|
|
const billingAutoFulfillmentSummaryEl = document.getElementById(
|
|
"chd-admin-billing-auto-fulfillment-summary"
|
|
);
|
|
const connectorAdminTokenCardEl = document.getElementById(
|
|
"chd-admin-connector-admin-token-card"
|
|
);
|
|
const connectorAdminTokenInputEl = document.getElementById(
|
|
"chd-admin-connector-admin-token"
|
|
);
|
|
const connectorAdminTokenSaveButton = document.getElementById(
|
|
"chd-admin-connector-admin-token-save"
|
|
);
|
|
const connectorAdminTokenStatusEl = document.getElementById(
|
|
"chd-admin-connector-admin-token-status"
|
|
);
|
|
const operationsRefreshButton = document.getElementById(
|
|
"chd-admin-operations-refresh"
|
|
);
|
|
const operationsSummaryEl = document.getElementById(
|
|
"chd-admin-operations-summary"
|
|
);
|
|
const operationsListEl = document.getElementById("chd-admin-operations-list");
|
|
const operationsFormEl = document.getElementById("chd-admin-operations-form");
|
|
const operationsUserSelectEl = document.getElementById("chd-admin-ops-user");
|
|
const operationsTypeSelectEl = document.getElementById("chd-admin-ops-type");
|
|
const operationsTitleInputEl = document.getElementById("chd-admin-ops-title");
|
|
const operationsCodeInputEl = document.getElementById("chd-admin-ops-code");
|
|
const operationsNotesInputEl = document.getElementById("chd-admin-ops-notes");
|
|
const catalogConfigStatusEl = document.getElementById(
|
|
"chd-admin-catalog-config-status"
|
|
);
|
|
const catalogConfigRefreshButton = document.getElementById(
|
|
"chd-admin-catalog-config-refresh"
|
|
);
|
|
const catalogConfigSaveButton = document.getElementById(
|
|
"chd-admin-catalog-config-save"
|
|
);
|
|
const paymenterProfileSelectEl = document.getElementById(
|
|
"chd-paymenter-profile-select"
|
|
);
|
|
const catalogScopeProfileSelectEl = document.getElementById(
|
|
"chd-catalog-scope-profile-select"
|
|
);
|
|
const paymenterProfileStatusEl = document.getElementById(
|
|
"chd-paymenter-profile-status"
|
|
);
|
|
const connectorRuntimeDiagnosticsEl = document.getElementById(
|
|
"chd-connector-runtime-diagnostics"
|
|
);
|
|
const packageListEl = document.getElementById("chd-package-list");
|
|
const packageAddButton = document.getElementById("chd-package-add-row");
|
|
const addonListEl = document.getElementById("chd-addon-list");
|
|
const addonAddButton = document.getElementById("chd-addon-add-row");
|
|
const oneTimeListEl = document.getElementById("chd-onetime-list");
|
|
const oneTimeAddButton = document.getElementById("chd-onetime-add-row");
|
|
const audienceSelectEl = document.getElementById("chd-audience-select");
|
|
const audienceCreateNameEl = document.getElementById(
|
|
"chd-audience-create-name"
|
|
);
|
|
const audienceListEl = document.getElementById("chd-audience-list");
|
|
const audienceAddButton = document.getElementById("chd-audience-add-row");
|
|
const audienceRemoveCurrentButton = document.getElementById(
|
|
"chd-audience-remove-current"
|
|
);
|
|
const audienceManageStatusEl = document.getElementById(
|
|
"chd-audience-manage-status"
|
|
);
|
|
const audienceEditorEl = document.getElementById("chd-audience-editor");
|
|
const extraOffersListEl = document.getElementById("chd-extra-offers-list");
|
|
const extraOffersAddButton = document.getElementById(
|
|
"chd-extra-offers-add-row"
|
|
);
|
|
const previewAudienceEl = document.getElementById("chd-preview-audience");
|
|
const previewRefreshButton = document.getElementById("chd-preview-refresh");
|
|
const catalogPreviewEl = document.getElementById("chd-admin-catalog-preview");
|
|
const catalogPreviewRenderEl = document.getElementById(
|
|
"chd-admin-catalog-preview-render"
|
|
);
|
|
const chdcOpenLink = document.getElementById("chd-admin-chdc-open");
|
|
const approvalModal = document.getElementById("chd-admin-qortal-approval");
|
|
const approvalRequestEl = document.getElementById(
|
|
"chd-admin-qortal-approval-request"
|
|
);
|
|
const approvalScopeEl = document.getElementById(
|
|
"chd-admin-qortal-approval-scope"
|
|
);
|
|
const approvalWalletStatusEl = document.getElementById(
|
|
"chd-admin-qortal-approval-wallet-status"
|
|
);
|
|
const approvalUnlockFieldsEl = document.getElementById(
|
|
"chd-admin-qortal-approval-unlock-fields"
|
|
);
|
|
const approvalPasswordEl = document.getElementById(
|
|
"chd-admin-qortal-approval-password"
|
|
);
|
|
const approvalTtlEl = document.getElementById(
|
|
"chd-admin-qortal-approval-ttl"
|
|
);
|
|
const approvalErrorEl = document.getElementById(
|
|
"chd-admin-qortal-approval-error"
|
|
);
|
|
const approvalConfirmButton = document.getElementById(
|
|
"chd-admin-qortal-approval-confirm"
|
|
);
|
|
const approvalCancelButton = document.getElementById(
|
|
"chd-admin-qortal-approval-cancel"
|
|
);
|
|
const infoPanels = {
|
|
overview: createInfoPanel(
|
|
overviewEl,
|
|
overviewToggleButton,
|
|
overviewAllButton
|
|
),
|
|
connectorSummary: createInfoPanel(
|
|
connectorSummaryEl,
|
|
connectorToggleButton,
|
|
connectorAllButton
|
|
),
|
|
instances: createInfoPanel(
|
|
instancesListEl,
|
|
instancesToggleButton,
|
|
instancesAllButton
|
|
),
|
|
instanceDetail: createInfoPanel(
|
|
instanceDetailEl,
|
|
instanceDetailToggleButton,
|
|
instanceDetailAllButton
|
|
),
|
|
paymenterSnapshot: createInfoPanel(
|
|
paymenterSnapshotEl,
|
|
paymenterSnapshotToggleButton,
|
|
paymenterSnapshotAllButton
|
|
),
|
|
paymenterBackups: createInfoPanel(
|
|
paymenterBackupsEl,
|
|
paymenterBackupsToggleButton,
|
|
paymenterBackupsAllButton
|
|
),
|
|
};
|
|
const OPERATION_STATUS_ORDER = {
|
|
pending: 0,
|
|
in_progress: 1,
|
|
blocked: 2,
|
|
completed: 3,
|
|
};
|
|
let connectorInstancesCache = [];
|
|
let activeInstanceId = "";
|
|
let activeInstanceKnownUsers = [];
|
|
let activeInstanceDetailData = null;
|
|
let activeInstanceOperations = [];
|
|
let availableConnectorProfiles = [];
|
|
let paymenterClientsCache = [];
|
|
let connectorIdentityIndexCache = [];
|
|
let connectorRoutingState = {
|
|
activeProfile: "",
|
|
profiles: {
|
|
test: { baseUrl: "" },
|
|
production: { baseUrl: "" },
|
|
},
|
|
effectiveBaseUrl: "",
|
|
fallbackBaseUrl: "",
|
|
};
|
|
let catalogConfigState = {
|
|
selectedCatalogScopeProfile: "",
|
|
profileConfigs: {},
|
|
packageRows: [],
|
|
addonRows: [],
|
|
oneTimeRows: [],
|
|
audienceRows: [],
|
|
extraOffers: [],
|
|
selectedAudienceKey: "",
|
|
runtime: {
|
|
activePaymenterProfile: "",
|
|
availablePaymenterProfiles: [],
|
|
availableCatalogConfigProfiles: [],
|
|
availableConnectorProfiles: [],
|
|
currentPaymenterBaseUrl: "",
|
|
diagnostics: {},
|
|
},
|
|
};
|
|
let connectorCatalogPreview = { subscriptions: [], addons: [] };
|
|
const DEFAULT_AUDIENCES = [
|
|
{
|
|
key: "NuQloud-individual",
|
|
description:
|
|
"Users on existing NuQloud instances who are not on shared team billing.",
|
|
},
|
|
{
|
|
key: "NuQloud-team",
|
|
description:
|
|
"Existing NuQloud teams with more than one paid member under the same monthly package.",
|
|
},
|
|
{
|
|
key: "NuQloud-Branded",
|
|
description:
|
|
"Organizations or communities already running fully branded hosted NuQloud instances.",
|
|
},
|
|
];
|
|
let defaultPackageRows = [
|
|
{ code: "nuqloud_starter", label: "Starter" },
|
|
{ code: "nuqloud_advanced", label: "Advanced" },
|
|
{ code: "nuqloud_pro", label: "Pro" },
|
|
{ code: "nuqloud_team_starter", label: "Team Starter" },
|
|
{ code: "nuqloud_team_advanced", label: "Team Advanced" },
|
|
{ code: "nuqloud_team_pro", label: "Team Pro" },
|
|
{ code: "nuqloud_branded", label: "NuQloud Branded" },
|
|
{ code: "nuqloud_branded_pro", label: "Branded Pro" },
|
|
{ code: "nuqloud_branded_enterprise", label: "Branded Enterprise" },
|
|
];
|
|
let walletLockState = {
|
|
known: false,
|
|
unlocked: null,
|
|
expiresAtMs: null,
|
|
};
|
|
const qdnLoadingMarkers = [
|
|
"loading from qdn",
|
|
"fetching content from qdn",
|
|
"qdn is still building",
|
|
];
|
|
const qdnStatusTitles = {
|
|
NOT_STARTED: "Request queued",
|
|
BLOCKED: "Blocked",
|
|
BUILDING: "Building",
|
|
BUILD_FAILED: "Build failed",
|
|
DOWNLOADING: "Downloading",
|
|
MISSING_DATA: "Waiting for chunk data",
|
|
DOWNLOADED: "Downloaded",
|
|
PUBLISHED: "Published",
|
|
READY: "Ready",
|
|
UNSUPPORTED: "Unsupported",
|
|
NOT_PUBLISHED: "Not published",
|
|
};
|
|
const qdnStatusNumericMap = {
|
|
0: "NOT_STARTED",
|
|
1: "BLOCKED",
|
|
2: "BUILDING",
|
|
3: "BUILD_FAILED",
|
|
4: "DOWNLOADING",
|
|
5: "MISSING_DATA",
|
|
6: "DOWNLOADED",
|
|
7: "PUBLISHED",
|
|
8: "READY",
|
|
9: "UNSUPPORTED",
|
|
10: "NOT_PUBLISHED",
|
|
};
|
|
const chdcQdnRetryDelayMs = 4000;
|
|
const chdcQdnReloadFallbackDelayMs = 7000;
|
|
const chdcQdnRetryMaxAttempts = 240;
|
|
let chdcQdnRetryTimer = null;
|
|
let chdcQdnRetryAttempts = 0;
|
|
let chdcQdnResourceTarget = null;
|
|
let chdcQdnStatusFailures = 0;
|
|
let chdcQdnLastSnapshot = "";
|
|
|
|
function getRequestHeaders(contentType) {
|
|
const headers = {};
|
|
if (contentType) {
|
|
headers["Content-Type"] = contentType;
|
|
}
|
|
if (typeof OC !== "undefined" && OC.requestToken) {
|
|
headers.requesttoken = OC.requestToken;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function buildLegacyPayloadBody(payload) {
|
|
const body = new URLSearchParams();
|
|
body.set("payload", JSON.stringify(payload || {}));
|
|
return body.toString();
|
|
}
|
|
|
|
async function requestJson(url, options) {
|
|
const response = await fetch(url, options);
|
|
const responseText = await response.text();
|
|
let payload = null;
|
|
if (responseText) {
|
|
try {
|
|
payload = JSON.parse(responseText);
|
|
} catch (_error) {
|
|
payload = null;
|
|
}
|
|
}
|
|
if (!response.ok || !payload || payload.ok === false) {
|
|
const errorMessage =
|
|
payload && payload.error
|
|
? payload.error
|
|
: "HTTP " + String(response.status);
|
|
const error = new Error(errorMessage);
|
|
error.status = response.status;
|
|
error.details = payload && payload.details ? payload.details : null;
|
|
error.payload = payload;
|
|
throw error;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
async function qortalRequest(action, payload) {
|
|
if (!qappsRequestUrl) {
|
|
throw new Error("Qortal request endpoint is not configured.");
|
|
}
|
|
const response = await fetch(qappsRequestUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
requestType: String(action || "")
|
|
.trim()
|
|
.toUpperCase(),
|
|
payload: payload || {},
|
|
}),
|
|
});
|
|
const json = await response.json();
|
|
if (!response.ok || json.ok === false || json.error) {
|
|
const error = new Error(json.error || "qortal_request_failed");
|
|
error.status = response.status;
|
|
error.details = json.details || null;
|
|
error.payload = json;
|
|
throw error;
|
|
}
|
|
return json.data !== undefined ? json.data : json;
|
|
}
|
|
|
|
function normalizeApprovalScope(scope, requestType) {
|
|
const normalizedScope = String(scope || "").trim();
|
|
if (normalizedScope) {
|
|
return normalizedScope;
|
|
}
|
|
return String(requestType || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
}
|
|
|
|
function isApprovalRequiredError(error) {
|
|
if (!error) {
|
|
return false;
|
|
}
|
|
const message = error.message || "";
|
|
return typeof message === "string" && message.includes("approval_required");
|
|
}
|
|
|
|
function isWalletLockedError(error) {
|
|
if (!error) {
|
|
return false;
|
|
}
|
|
const message = error.message || "";
|
|
if (typeof message === "string" && message.includes("wallet_locked")) {
|
|
return true;
|
|
}
|
|
const details = error.details;
|
|
if (details && typeof details === "object") {
|
|
if (details.error === "wallet_locked") {
|
|
return true;
|
|
}
|
|
const nestedDetail =
|
|
details.data && details.data.detail ? details.data.detail : null;
|
|
if (
|
|
nestedDetail &&
|
|
typeof nestedDetail === "object" &&
|
|
nestedDetail.error === "wallet_locked"
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function extractApprovalContext(error, requestType) {
|
|
const context = {
|
|
scope: "",
|
|
walletId: "",
|
|
};
|
|
|
|
function captureFromValue(value, depth) {
|
|
if (depth > 3 || value === null || value === undefined) {
|
|
return;
|
|
}
|
|
if (typeof value === "string") {
|
|
if (!context.scope) {
|
|
const scopeMatch = value.match(/"scope"\s*:\s*"([^"]+)"/);
|
|
if (scopeMatch && scopeMatch[1]) {
|
|
context.scope = scopeMatch[1];
|
|
}
|
|
}
|
|
if (!context.walletId) {
|
|
const walletMatch = value.match(/"walletId"\s*:\s*"([^"]+)"/);
|
|
if (walletMatch && walletMatch[1]) {
|
|
context.walletId = walletMatch[1];
|
|
}
|
|
}
|
|
const jsonStart = value.indexOf("{");
|
|
const jsonEnd = value.lastIndexOf("}");
|
|
if (jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart) {
|
|
try {
|
|
captureFromValue(
|
|
JSON.parse(value.slice(jsonStart, jsonEnd + 1)),
|
|
depth + 1
|
|
);
|
|
} catch (_error) {
|
|
// Ignore parse failures from mixed broker strings.
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (typeof value !== "object") {
|
|
return;
|
|
}
|
|
if (!context.scope && typeof value.scope === "string") {
|
|
context.scope = value.scope;
|
|
}
|
|
if (!context.walletId && typeof value.walletId === "string") {
|
|
context.walletId = value.walletId;
|
|
}
|
|
if (value.detail !== undefined) {
|
|
captureFromValue(value.detail, depth + 1);
|
|
}
|
|
if (value.data !== undefined) {
|
|
captureFromValue(value.data, depth + 1);
|
|
}
|
|
if (value.error !== undefined) {
|
|
captureFromValue(value.error, depth + 1);
|
|
}
|
|
}
|
|
|
|
captureFromValue(error && error.details ? error.details : null, 0);
|
|
captureFromValue(error && error.message ? error.message : "", 0);
|
|
context.scope = normalizeApprovalScope(context.scope, requestType);
|
|
return context;
|
|
}
|
|
|
|
function parseUnlockStatus(value) {
|
|
let candidate = value;
|
|
for (let index = 0; index < 4; index += 1) {
|
|
if (
|
|
!candidate ||
|
|
typeof candidate !== "object" ||
|
|
candidate.data === undefined ||
|
|
candidate.data === null
|
|
) {
|
|
break;
|
|
}
|
|
if (typeof candidate.data !== "object") {
|
|
break;
|
|
}
|
|
candidate = candidate.data;
|
|
}
|
|
if (
|
|
!candidate ||
|
|
typeof candidate !== "object" ||
|
|
typeof candidate.unlocked !== "boolean"
|
|
) {
|
|
return null;
|
|
}
|
|
const rawExpires =
|
|
candidate.expiresAt !== undefined
|
|
? candidate.expiresAt
|
|
: candidate.expires_at;
|
|
let expiresAtMs = null;
|
|
if (
|
|
typeof rawExpires === "number" &&
|
|
Number.isFinite(rawExpires) &&
|
|
rawExpires > 0
|
|
) {
|
|
expiresAtMs = rawExpires < 10000000000 ? rawExpires * 1000 : rawExpires;
|
|
} else if (
|
|
typeof rawExpires === "string" &&
|
|
/^\d+$/.test(rawExpires.trim())
|
|
) {
|
|
const parsed = Number(rawExpires.trim());
|
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
expiresAtMs = parsed < 10000000000 ? parsed * 1000 : parsed;
|
|
}
|
|
}
|
|
return {
|
|
unlocked: candidate.unlocked,
|
|
expiresAtMs: expiresAtMs,
|
|
};
|
|
}
|
|
|
|
function markWalletUnknown() {
|
|
walletLockState = { known: false, unlocked: null, expiresAtMs: null };
|
|
}
|
|
|
|
function markWalletLocked() {
|
|
walletLockState = { known: true, unlocked: false, expiresAtMs: null };
|
|
}
|
|
|
|
function markWalletUnlocked(expiresAtMs) {
|
|
walletLockState = {
|
|
known: true,
|
|
unlocked: true,
|
|
expiresAtMs: typeof expiresAtMs === "number" ? expiresAtMs : null,
|
|
};
|
|
}
|
|
|
|
function walletStatusPresentation() {
|
|
if (!walletLockState.known) {
|
|
return {
|
|
text: "Wallet lock: unknown (password may be required)",
|
|
className: "chd-wallet-status-unknown",
|
|
showUnlockFields: true,
|
|
};
|
|
}
|
|
if (!walletLockState.unlocked) {
|
|
return {
|
|
text: "Wallet lock: LOCKED (password required)",
|
|
className: "chd-wallet-status-locked",
|
|
showUnlockFields: true,
|
|
};
|
|
}
|
|
if (
|
|
walletLockState.expiresAtMs &&
|
|
walletLockState.expiresAtMs > Date.now()
|
|
) {
|
|
const minutes = Math.max(
|
|
1,
|
|
Math.ceil((walletLockState.expiresAtMs - Date.now()) / 60000)
|
|
);
|
|
return {
|
|
text: "Wallet lock: UNLOCKED for ~" + minutes + " minute(s)",
|
|
className: "chd-wallet-status-ok",
|
|
showUnlockFields: false,
|
|
};
|
|
}
|
|
return {
|
|
text: "Wallet lock: UNLOCKED",
|
|
className: "chd-wallet-status-ok",
|
|
showUnlockFields: false,
|
|
};
|
|
}
|
|
|
|
function updateApprovalWalletStatus() {
|
|
const status = walletStatusPresentation();
|
|
if (approvalWalletStatusEl) {
|
|
approvalWalletStatusEl.textContent = status.text;
|
|
approvalWalletStatusEl.classList.remove(
|
|
"chd-wallet-status-ok",
|
|
"chd-wallet-status-locked",
|
|
"chd-wallet-status-unknown"
|
|
);
|
|
approvalWalletStatusEl.classList.add(status.className);
|
|
}
|
|
if (approvalUnlockFieldsEl) {
|
|
approvalUnlockFieldsEl.classList.toggle(
|
|
"chd-hidden",
|
|
!status.showUnlockFields
|
|
);
|
|
}
|
|
if (!status.showUnlockFields && approvalPasswordEl) {
|
|
approvalPasswordEl.value = "";
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function showApprovalError(message) {
|
|
if (!approvalErrorEl) {
|
|
return;
|
|
}
|
|
approvalErrorEl.textContent = message || "";
|
|
approvalErrorEl.classList.toggle("chd-hidden", !message);
|
|
}
|
|
|
|
async function refreshWalletLockState(walletId) {
|
|
const id = String(walletId || "").trim();
|
|
if (!id || !qappsUnlockStatusUrl) {
|
|
markWalletUnknown();
|
|
return false;
|
|
}
|
|
try {
|
|
const joiner = qappsUnlockStatusUrl.indexOf("?") === -1 ? "?" : "&";
|
|
const response = await fetch(
|
|
qappsUnlockStatusUrl + joiner + "walletId=" + encodeURIComponent(id),
|
|
{
|
|
method: "GET",
|
|
headers: getRequestHeaders(),
|
|
credentials: "same-origin",
|
|
}
|
|
);
|
|
const payload = await response.json();
|
|
if (!response.ok || (payload && payload.error)) {
|
|
markWalletUnknown();
|
|
return false;
|
|
}
|
|
const parsed = parseUnlockStatus(payload);
|
|
if (!parsed) {
|
|
markWalletUnknown();
|
|
return false;
|
|
}
|
|
if (!parsed.unlocked) {
|
|
markWalletLocked();
|
|
return true;
|
|
}
|
|
markWalletUnlocked(parsed.expiresAtMs);
|
|
return true;
|
|
} catch (_error) {
|
|
markWalletUnknown();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function closeApprovalModal() {
|
|
if (!approvalModal) {
|
|
return;
|
|
}
|
|
approvalModal.classList.add("chd-hidden");
|
|
showApprovalError("");
|
|
if (approvalPasswordEl) {
|
|
approvalPasswordEl.value = "";
|
|
approvalPasswordEl.onkeydown = null;
|
|
}
|
|
if (approvalConfirmButton) {
|
|
approvalConfirmButton.disabled = false;
|
|
approvalConfirmButton.textContent = "Approve and Send";
|
|
approvalConfirmButton.onclick = null;
|
|
}
|
|
if (approvalCancelButton) {
|
|
approvalCancelButton.disabled = false;
|
|
approvalCancelButton.onclick = null;
|
|
}
|
|
}
|
|
|
|
async function showApprovalModal(context) {
|
|
if (!approvalModal || !approvalConfirmButton || !approvalCancelButton) {
|
|
return { action: "cancel" };
|
|
}
|
|
if (approvalRequestEl) {
|
|
approvalRequestEl.textContent = String(context.requestType || "ACTION");
|
|
}
|
|
if (approvalScopeEl) {
|
|
approvalScopeEl.textContent = String(context.scope || "ACTION");
|
|
}
|
|
showApprovalError("");
|
|
updateApprovalWalletStatus();
|
|
approvalModal.classList.remove("chd-hidden");
|
|
|
|
if (
|
|
approvalUnlockFieldsEl &&
|
|
!approvalUnlockFieldsEl.classList.contains("chd-hidden") &&
|
|
approvalPasswordEl
|
|
) {
|
|
approvalPasswordEl.focus();
|
|
approvalPasswordEl.onkeydown = function (event) {
|
|
if (event.key === "Enter" && !approvalConfirmButton.disabled) {
|
|
event.preventDefault();
|
|
approvalConfirmButton.click();
|
|
}
|
|
};
|
|
} else {
|
|
approvalConfirmButton.focus();
|
|
}
|
|
|
|
return new Promise(function (resolve) {
|
|
approvalConfirmButton.onclick = function () {
|
|
const status = walletStatusPresentation();
|
|
const password = approvalPasswordEl
|
|
? approvalPasswordEl.value.trim()
|
|
: "";
|
|
if (status.showUnlockFields && !password) {
|
|
showApprovalError(
|
|
"Wallet password is required because the wallet appears locked."
|
|
);
|
|
return;
|
|
}
|
|
approvalConfirmButton.disabled = true;
|
|
approvalConfirmButton.textContent = "Approving...";
|
|
approvalCancelButton.disabled = true;
|
|
resolve({
|
|
action: "approve",
|
|
password: password,
|
|
ttlSeconds: approvalTtlEl && approvalTtlEl.checked ? 600 : undefined,
|
|
});
|
|
};
|
|
approvalCancelButton.onclick = function () {
|
|
closeApprovalModal();
|
|
resolve({ action: "cancel" });
|
|
};
|
|
});
|
|
}
|
|
|
|
async function unlockWallet(walletId, password, ttlSeconds) {
|
|
if (!qappsUnlockUrl) {
|
|
throw new Error("Unlock endpoint is not configured.");
|
|
}
|
|
const response = await fetch(qappsUnlockUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
walletId: walletId,
|
|
password: password,
|
|
...(ttlSeconds ? { ttlSeconds: ttlSeconds } : {}),
|
|
}),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || payload.ok === false || payload.error) {
|
|
markWalletLocked();
|
|
throw new Error(payload.error || "wallet_unlock_failed");
|
|
}
|
|
const expiresAtMs = ttlSeconds
|
|
? Date.now() + Number(ttlSeconds) * 1000
|
|
: null;
|
|
markWalletUnlocked(expiresAtMs);
|
|
return payload;
|
|
}
|
|
|
|
async function approveOnce(walletId, scope, requestType) {
|
|
if (!qappsApproveUrl) {
|
|
throw new Error("Approval endpoint is not configured.");
|
|
}
|
|
const response = await fetch(qappsApproveUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
walletId: walletId,
|
|
scope: scope,
|
|
app: "qortal://NC/CHD_ADMIN",
|
|
requestType: requestType,
|
|
}),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || payload.ok === false || payload.error) {
|
|
throw new Error(payload.error || "approval_failed");
|
|
}
|
|
const token =
|
|
payload.data && payload.data.approvalToken
|
|
? payload.data.approvalToken
|
|
: payload.approvalToken;
|
|
if (!token) {
|
|
throw new Error("approval_token_missing");
|
|
}
|
|
return token;
|
|
}
|
|
|
|
async function sendCoinWithApproval(recipientAddress, amountQort, feeQort) {
|
|
const basePayload = {
|
|
coin: "QORT",
|
|
recipient: String(recipientAddress || "").trim(),
|
|
amount: amountQort,
|
|
fee: feeQort,
|
|
};
|
|
let workingPayload = Object.assign({}, basePayload);
|
|
let attempt = 0;
|
|
|
|
while (attempt < 4) {
|
|
try {
|
|
const result = await qortalRequest("SEND_COIN", workingPayload);
|
|
const txSignature = extractSignatureFromResult(result);
|
|
if (!txSignature) {
|
|
throw new Error("SEND_COIN did not return a transaction signature.");
|
|
}
|
|
closeApprovalModal();
|
|
return {
|
|
result: result,
|
|
txSignature: txSignature,
|
|
};
|
|
} catch (error) {
|
|
const approvalRequired = isApprovalRequiredError(error);
|
|
const walletLocked = isWalletLockedError(error);
|
|
if (!approvalRequired && !walletLocked) {
|
|
throw error;
|
|
}
|
|
const approvalContext = extractApprovalContext(error, "SEND_COIN");
|
|
const walletId = approvalContext.walletId;
|
|
if (!walletId) {
|
|
throw error;
|
|
}
|
|
await refreshWalletLockState(walletId);
|
|
updateApprovalWalletStatus();
|
|
const decision = await showApprovalModal({
|
|
requestType: "SEND_COIN",
|
|
scope: approvalContext.scope || "SEND_COIN",
|
|
});
|
|
if (!decision || decision.action === "cancel") {
|
|
throw new Error("approval_cancelled");
|
|
}
|
|
if (decision.password) {
|
|
try {
|
|
await unlockWallet(
|
|
walletId,
|
|
decision.password,
|
|
decision.ttlSeconds
|
|
);
|
|
} catch (unlockError) {
|
|
showApprovalError(unlockError.message || "wallet_unlock_failed");
|
|
throw unlockError;
|
|
}
|
|
}
|
|
const approvalToken = await approveOnce(
|
|
walletId,
|
|
normalizeApprovalScope(approvalContext.scope, "SEND_COIN"),
|
|
"SEND_COIN"
|
|
);
|
|
workingPayload = Object.assign({}, basePayload, {
|
|
walletId: walletId,
|
|
approvalToken: approvalToken,
|
|
});
|
|
closeApprovalModal();
|
|
attempt += 1;
|
|
}
|
|
}
|
|
|
|
throw new Error("approval_failed");
|
|
}
|
|
|
|
function extractSignatureFromResult(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return "";
|
|
}
|
|
if (
|
|
typeof payload.signature === "string" &&
|
|
payload.signature.trim() !== ""
|
|
) {
|
|
return payload.signature.trim();
|
|
}
|
|
const nestedCandidates = [
|
|
payload.result,
|
|
payload.data,
|
|
payload.tx,
|
|
payload.transaction,
|
|
];
|
|
for (let index = 0; index < nestedCandidates.length; index += 1) {
|
|
const signature = extractSignatureFromResult(nestedCandidates[index]);
|
|
if (signature !== "") {
|
|
return signature;
|
|
}
|
|
}
|
|
if (Array.isArray(payload.results)) {
|
|
for (let index = 0; index < payload.results.length; index += 1) {
|
|
const signature = extractSignatureFromResult(payload.results[index]);
|
|
if (signature !== "") {
|
|
return signature;
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function encodeGatewayPath(path) {
|
|
return String(path || "")
|
|
.split("/")
|
|
.map(function (segment) {
|
|
return encodeURIComponent(segment);
|
|
})
|
|
.join("/");
|
|
}
|
|
|
|
function buildGatewayProxyUrl(path) {
|
|
const cleanedPath = String(path || "")
|
|
.trim()
|
|
.replace(/^\/+/, "");
|
|
if (!cleanedPath) {
|
|
return "";
|
|
}
|
|
if (gatewayProxyTemplate.indexOf("__PATH__") !== -1) {
|
|
return gatewayProxyTemplate.replace(
|
|
"__PATH__",
|
|
encodeGatewayPath(cleanedPath)
|
|
);
|
|
}
|
|
const separator = gatewayProxyTemplate.endsWith("/") ? "" : "/";
|
|
return gatewayProxyTemplate + separator + encodeGatewayPath(cleanedPath);
|
|
}
|
|
|
|
function cloneJsonValue(value, fallback) {
|
|
try {
|
|
return JSON.parse(JSON.stringify(value));
|
|
} catch (_error) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function detectTheme() {
|
|
const docEl = document.documentElement;
|
|
const body = document.body;
|
|
const dataTheme =
|
|
(docEl && docEl.dataset ? docEl.dataset.theme : "") ||
|
|
(body && body.dataset ? body.dataset.theme : "");
|
|
if (dataTheme) {
|
|
return dataTheme;
|
|
}
|
|
const classList =
|
|
docEl && docEl.classList
|
|
? docEl.classList
|
|
: body && body.classList
|
|
? body.classList
|
|
: null;
|
|
if (classList) {
|
|
if (
|
|
classList.contains("theme-dark") ||
|
|
classList.contains("dark") ||
|
|
classList.contains("theme--dark") ||
|
|
classList.contains("nc-dark")
|
|
) {
|
|
return "dark";
|
|
}
|
|
if (
|
|
classList.contains("theme-light") ||
|
|
classList.contains("light") ||
|
|
classList.contains("theme--light") ||
|
|
classList.contains("nc-light")
|
|
) {
|
|
return "light";
|
|
}
|
|
}
|
|
if (
|
|
window.matchMedia &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
) {
|
|
return "dark";
|
|
}
|
|
return "light";
|
|
}
|
|
|
|
function detectLanguage() {
|
|
return (
|
|
(document.documentElement && document.documentElement.lang) ||
|
|
navigator.language ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function appendThemeParams(url) {
|
|
if (!url) {
|
|
return url;
|
|
}
|
|
try {
|
|
const parsed = new URL(url, window.location.origin);
|
|
if (!parsed.searchParams.has("theme")) {
|
|
parsed.searchParams.set("theme", detectTheme());
|
|
}
|
|
const lang = detectLanguage();
|
|
if (lang && !parsed.searchParams.has("lang")) {
|
|
parsed.searchParams.set("lang", lang);
|
|
}
|
|
return parsed.toString();
|
|
} catch (_error) {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
function splitPathAndSuffix(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw) {
|
|
return { path: "", suffix: "" };
|
|
}
|
|
const queryIndex = raw.indexOf("?");
|
|
const hashIndex = raw.indexOf("#");
|
|
let splitIndex = -1;
|
|
if (queryIndex >= 0 && hashIndex >= 0) {
|
|
splitIndex = Math.min(queryIndex, hashIndex);
|
|
} else if (queryIndex >= 0) {
|
|
splitIndex = queryIndex;
|
|
} else if (hashIndex >= 0) {
|
|
splitIndex = hashIndex;
|
|
}
|
|
if (splitIndex < 0) {
|
|
return { path: raw, suffix: "" };
|
|
}
|
|
return {
|
|
path: raw.slice(0, splitIndex),
|
|
suffix: raw.slice(splitIndex),
|
|
};
|
|
}
|
|
|
|
function normalizeBrowserAddress(address) {
|
|
const raw = String(address || "").trim();
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
if (!raw.toLowerCase().startsWith("qortal://")) {
|
|
return raw;
|
|
}
|
|
const split = splitPathAndSuffix(raw);
|
|
const afterScheme = split.path
|
|
.slice("qortal://".length)
|
|
.replace(/^\/+/, "");
|
|
if (!afterScheme) {
|
|
return raw;
|
|
}
|
|
if (!afterScheme.includes("/")) {
|
|
return "qortal://WEBSITE/" + afterScheme + split.suffix;
|
|
}
|
|
const segments = afterScheme.split("/").filter(function (segment) {
|
|
return Boolean(segment);
|
|
});
|
|
if (segments.length < 2) {
|
|
return raw;
|
|
}
|
|
return "qortal://" + segments.join("/") + split.suffix;
|
|
}
|
|
|
|
function qortalAddressToRenderPath(address) {
|
|
const normalized = normalizeBrowserAddress(address);
|
|
if (!normalized || !normalized.toLowerCase().startsWith("qortal://")) {
|
|
return "";
|
|
}
|
|
const split = splitPathAndSuffix(normalized);
|
|
const rawPath = split.path.slice("qortal://".length).replace(/^\/+/, "");
|
|
const segments = rawPath.split("/").filter(function (segment) {
|
|
return Boolean(segment);
|
|
});
|
|
if (segments.length < 2) {
|
|
return "";
|
|
}
|
|
return "render/" + segments.join("/") + split.suffix;
|
|
}
|
|
|
|
function buildChdcEmbedUrl() {
|
|
if (!qappsUrl) {
|
|
return "";
|
|
}
|
|
const normalizedAddress = normalizeBrowserAddress(chdcQappAddress);
|
|
if (!normalizedAddress) {
|
|
return "";
|
|
}
|
|
try {
|
|
const parsed = new URL(qappsUrl, window.location.origin);
|
|
parsed.searchParams.set("qapp", normalizedAddress);
|
|
parsed.searchParams.set("embed", "1");
|
|
parsed.searchParams.set("chromeless", "1");
|
|
parsed.searchParams.set("lock_qapp", "1");
|
|
return appendThemeParams(parsed.toString());
|
|
} catch (_error) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function updateChdcOpenUrl() {
|
|
if (!chdcOpenLink) {
|
|
return;
|
|
}
|
|
const qappParam = encodeURIComponent(
|
|
normalizeBrowserAddress(chdcQappAddress) || chdcQappAddress
|
|
);
|
|
const separator = qappsUrl.indexOf("?") === -1 ? "?" : "&";
|
|
chdcOpenLink.href = qappsUrl + separator + "qapp=" + qappParam;
|
|
}
|
|
|
|
function coerceFiniteNumber(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function stripQueryAndHash(value) {
|
|
return String(value || "")
|
|
.split("#")[0]
|
|
.split("?")[0];
|
|
}
|
|
|
|
function getQdnStatusLabel(status, description) {
|
|
if (typeof description === "string" && description.trim() !== "") {
|
|
return description.trim();
|
|
}
|
|
const normalizedStatus = String(status || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
if (normalizedStatus && qdnStatusTitles[normalizedStatus]) {
|
|
return qdnStatusTitles[normalizedStatus];
|
|
}
|
|
return "Loading from QDN...";
|
|
}
|
|
|
|
function isTerminalQdnStatus(status) {
|
|
const normalizedStatus = String(status || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
return (
|
|
normalizedStatus === "NOT_PUBLISHED" ||
|
|
normalizedStatus === "UNSUPPORTED" ||
|
|
normalizedStatus === "BLOCKED"
|
|
);
|
|
}
|
|
|
|
function parseQdnStatusCandidate(candidate) {
|
|
if (!candidate || typeof candidate !== "object") {
|
|
return null;
|
|
}
|
|
const nestedStatus =
|
|
candidate.status && typeof candidate.status === "object"
|
|
? candidate.status
|
|
: null;
|
|
let statusCode = "";
|
|
const rawCandidates = [
|
|
candidate.id,
|
|
candidate.status,
|
|
nestedStatus ? nestedStatus.id : null,
|
|
nestedStatus ? nestedStatus.status : null,
|
|
nestedStatus ? nestedStatus.name : null,
|
|
];
|
|
for (let i = 0; i < rawCandidates.length; i += 1) {
|
|
const value = rawCandidates[i];
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
statusCode = value.trim().toUpperCase();
|
|
break;
|
|
}
|
|
}
|
|
if (!statusCode) {
|
|
const numericValue =
|
|
coerceFiniteNumber(candidate.status) ||
|
|
(nestedStatus ? coerceFiniteNumber(nestedStatus.value) : null);
|
|
if (
|
|
numericValue !== null &&
|
|
qdnStatusNumericMap[Math.floor(numericValue)]
|
|
) {
|
|
statusCode = qdnStatusNumericMap[Math.floor(numericValue)];
|
|
}
|
|
}
|
|
if (!statusCode) {
|
|
return null;
|
|
}
|
|
let localChunkCount = coerceFiniteNumber(candidate.localChunkCount);
|
|
if (localChunkCount === null && nestedStatus) {
|
|
localChunkCount = coerceFiniteNumber(nestedStatus.localChunkCount);
|
|
}
|
|
let totalChunkCount = coerceFiniteNumber(candidate.totalChunkCount);
|
|
if (totalChunkCount === null && nestedStatus) {
|
|
totalChunkCount = coerceFiniteNumber(nestedStatus.totalChunkCount);
|
|
}
|
|
let percentLoaded = coerceFiniteNumber(candidate.percentLoaded);
|
|
if (percentLoaded === null && nestedStatus) {
|
|
percentLoaded = coerceFiniteNumber(nestedStatus.percentLoaded);
|
|
}
|
|
if (
|
|
percentLoaded === null &&
|
|
localChunkCount !== null &&
|
|
totalChunkCount !== null &&
|
|
totalChunkCount > 0
|
|
) {
|
|
percentLoaded = (localChunkCount / totalChunkCount) * 100;
|
|
}
|
|
return {
|
|
status: statusCode,
|
|
description:
|
|
(typeof candidate.description === "string" &&
|
|
candidate.description.trim() !== ""
|
|
? candidate.description.trim()
|
|
: "") ||
|
|
(nestedStatus && typeof nestedStatus.description === "string"
|
|
? nestedStatus.description.trim()
|
|
: "") ||
|
|
(typeof candidate.title === "string" ? candidate.title.trim() : ""),
|
|
percentLoaded: percentLoaded,
|
|
localChunkCount: localChunkCount,
|
|
totalChunkCount: totalChunkCount,
|
|
};
|
|
}
|
|
|
|
function parseQdnStatusPayload(payload) {
|
|
const visited = new Set();
|
|
const queue = [payload];
|
|
let depth = 0;
|
|
while (queue.length > 0 && depth < 16) {
|
|
const candidate = queue.shift();
|
|
depth += 1;
|
|
if (!candidate || typeof candidate !== "object") {
|
|
continue;
|
|
}
|
|
if (visited.has(candidate)) {
|
|
continue;
|
|
}
|
|
visited.add(candidate);
|
|
const parsed = parseQdnStatusCandidate(candidate);
|
|
if (parsed) {
|
|
return parsed;
|
|
}
|
|
if (candidate.data && typeof candidate.data === "object") {
|
|
queue.push(candidate.data);
|
|
}
|
|
if (candidate.result && typeof candidate.result === "object") {
|
|
queue.push(candidate.result);
|
|
}
|
|
if (candidate.status && typeof candidate.status === "object") {
|
|
queue.push(candidate.status);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function fetchJsonWithTimeout(url, timeoutMs) {
|
|
const canAbort = typeof window.AbortController === "function";
|
|
const controller = canAbort ? new window.AbortController() : null;
|
|
let timeoutId = null;
|
|
if (controller && timeoutMs > 0) {
|
|
timeoutId = window.setTimeout(function () {
|
|
controller.abort();
|
|
}, timeoutMs);
|
|
}
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
signal: controller ? controller.signal : undefined,
|
|
credentials: "same-origin",
|
|
});
|
|
const rawText = await response.text();
|
|
let json = null;
|
|
if (rawText) {
|
|
try {
|
|
json = JSON.parse(rawText);
|
|
} catch (_error) {
|
|
json = null;
|
|
}
|
|
}
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
json: json,
|
|
};
|
|
} finally {
|
|
if (timeoutId) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseChdcQdnResourceTarget(address) {
|
|
const normalized = normalizeBrowserAddress(address || "");
|
|
if (!normalized || !normalized.toLowerCase().startsWith("qortal://")) {
|
|
return null;
|
|
}
|
|
const rawPath = stripQueryAndHash(
|
|
normalized.slice("qortal://".length)
|
|
).replace(/^\/+/, "");
|
|
if (!rawPath) {
|
|
return null;
|
|
}
|
|
const segments = rawPath.split("/").filter(function (segment) {
|
|
return Boolean(segment);
|
|
});
|
|
if (segments.length < 2) {
|
|
return null;
|
|
}
|
|
return {
|
|
service: String(segments[0] || "")
|
|
.trim()
|
|
.toUpperCase(),
|
|
name: String(segments[1] || "").trim(),
|
|
identifierCandidate:
|
|
segments.length > 2 ? String(segments[2] || "").trim() : "",
|
|
};
|
|
}
|
|
|
|
function buildChdcQdnStatusUrls(target) {
|
|
if (!target || !target.service || !target.name) {
|
|
return [];
|
|
}
|
|
const paths = [];
|
|
const urls = [];
|
|
const pushPath = function (segments) {
|
|
const joined = segments
|
|
.filter(function (segment) {
|
|
return typeof segment === "string" && segment.trim() !== "";
|
|
})
|
|
.map(function (segment) {
|
|
return segment.trim();
|
|
})
|
|
.join("/");
|
|
if (!joined || paths.includes(joined)) {
|
|
return;
|
|
}
|
|
paths.push(joined);
|
|
};
|
|
const base = [
|
|
"arbitrary",
|
|
"resource",
|
|
"status",
|
|
target.service,
|
|
target.name,
|
|
];
|
|
if (target.identifierCandidate) {
|
|
pushPath(base.concat([target.identifierCandidate]));
|
|
}
|
|
pushPath(base);
|
|
paths.forEach(function (path) {
|
|
const baseUrl = appendThemeParams(buildGatewayProxyUrl(path));
|
|
if (!baseUrl) {
|
|
return;
|
|
}
|
|
const withBuild =
|
|
baseUrl + (baseUrl.indexOf("?") === -1 ? "?" : "&") + "build=true";
|
|
if (!urls.includes(withBuild)) {
|
|
urls.push(withBuild);
|
|
}
|
|
if (!urls.includes(baseUrl)) {
|
|
urls.push(baseUrl);
|
|
}
|
|
});
|
|
return urls;
|
|
}
|
|
|
|
async function probeChdcQdnResourceStatus(target) {
|
|
const urls = buildChdcQdnStatusUrls(target);
|
|
for (let i = 0; i < urls.length; i += 1) {
|
|
try {
|
|
const result = await fetchJsonWithTimeout(urls[i], 4500);
|
|
if (!result.ok || !result.json) {
|
|
continue;
|
|
}
|
|
const parsed = parseQdnStatusPayload(result.json);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: String(parsed.status || "").toUpperCase(),
|
|
description: parsed.description || "",
|
|
percentLoaded: coerceFiniteNumber(parsed.percentLoaded),
|
|
localChunkCount: coerceFiniteNumber(parsed.localChunkCount),
|
|
totalChunkCount: coerceFiniteNumber(parsed.totalChunkCount),
|
|
};
|
|
} catch (_error) {
|
|
// try next status endpoint
|
|
}
|
|
}
|
|
return { ok: false };
|
|
}
|
|
|
|
function clearChdcQdnRetry(reason) {
|
|
if (chdcQdnRetryTimer) {
|
|
window.clearTimeout(chdcQdnRetryTimer);
|
|
chdcQdnRetryTimer = null;
|
|
}
|
|
chdcQdnStatusFailures = 0;
|
|
chdcQdnLastSnapshot = "";
|
|
}
|
|
|
|
function scheduleChdcQdnRetry(delayOverrideMs) {
|
|
if (chdcQdnRetryTimer) {
|
|
return;
|
|
}
|
|
if (chdcQdnRetryAttempts >= chdcQdnRetryMaxAttempts) {
|
|
return;
|
|
}
|
|
const nextDelay =
|
|
typeof delayOverrideMs === "number" && Number.isFinite(delayOverrideMs)
|
|
? Math.max(1000, Math.floor(delayOverrideMs))
|
|
: chdcQdnRetryDelayMs;
|
|
chdcQdnRetryTimer = window.setTimeout(function () {
|
|
chdcQdnRetryTimer = null;
|
|
runChdcQdnRetryTick().catch(function () {
|
|
scheduleChdcQdnRetry(chdcQdnReloadFallbackDelayMs);
|
|
});
|
|
}, nextDelay);
|
|
}
|
|
|
|
async function runChdcQdnRetryTick() {
|
|
if (chdcQdnRetryAttempts >= chdcQdnRetryMaxAttempts) {
|
|
return;
|
|
}
|
|
chdcQdnRetryAttempts += 1;
|
|
let shouldReload = true;
|
|
let delayMs = chdcQdnRetryDelayMs;
|
|
if (chdcQdnResourceTarget) {
|
|
const statusResult = await probeChdcQdnResourceStatus(
|
|
chdcQdnResourceTarget
|
|
);
|
|
if (statusResult.ok) {
|
|
chdcQdnStatusFailures = 0;
|
|
const statusSnapshot =
|
|
statusResult.status +
|
|
":" +
|
|
(statusResult.percentLoaded !== null
|
|
? statusResult.percentLoaded
|
|
: "") +
|
|
":" +
|
|
(statusResult.localChunkCount !== null
|
|
? statusResult.localChunkCount
|
|
: "");
|
|
if (statusSnapshot !== chdcQdnLastSnapshot) {
|
|
chdcQdnLastSnapshot = statusSnapshot;
|
|
}
|
|
if (
|
|
statusResult.status === "READY" ||
|
|
isTerminalQdnStatus(statusResult.status)
|
|
) {
|
|
clearChdcQdnRetry("content_loaded");
|
|
chdcQdnRetryAttempts = 0;
|
|
return;
|
|
}
|
|
shouldReload = false;
|
|
} else {
|
|
chdcQdnStatusFailures += 1;
|
|
delayMs = chdcQdnReloadFallbackDelayMs;
|
|
if (chdcQdnStatusFailures <= 3) {
|
|
shouldReload = false;
|
|
}
|
|
}
|
|
}
|
|
if (shouldReload) {
|
|
clearChdcQdnRetry("retry_no_embed");
|
|
return;
|
|
}
|
|
scheduleChdcQdnRetry(delayMs);
|
|
}
|
|
|
|
function cleanString(value) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function isRecord(value) {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function coerceBoolean(value) {
|
|
if (typeof value === "boolean") {
|
|
return value;
|
|
}
|
|
if (typeof value === "number") {
|
|
return value !== 0;
|
|
}
|
|
const normalized = cleanString(value).toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return (
|
|
normalized === "1" ||
|
|
normalized === "true" ||
|
|
normalized === "yes" ||
|
|
normalized === "set" ||
|
|
normalized === "configured" ||
|
|
normalized === "enabled" ||
|
|
normalized === "ok"
|
|
);
|
|
}
|
|
|
|
function firstNonEmptyString(candidates, fallback) {
|
|
const list = Array.isArray(candidates) ? candidates : [];
|
|
for (let i = 0; i < list.length; i += 1) {
|
|
const candidate = cleanString(list[i]);
|
|
if (candidate !== "") {
|
|
return candidate;
|
|
}
|
|
}
|
|
return fallback || "";
|
|
}
|
|
|
|
function firstArray(candidates) {
|
|
const list = Array.isArray(candidates) ? candidates : [];
|
|
for (let i = 0; i < list.length; i += 1) {
|
|
if (Array.isArray(list[i])) {
|
|
return list[i];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function firstObject(candidates) {
|
|
const list = Array.isArray(candidates) ? candidates : [];
|
|
for (let i = 0; i < list.length; i += 1) {
|
|
if (isRecord(list[i])) {
|
|
return list[i];
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function normalizeStatusLevel(level) {
|
|
const normalized = cleanString(level).toLowerCase();
|
|
if (
|
|
normalized === "issues" ||
|
|
normalized === "partial" ||
|
|
normalized === "good"
|
|
) {
|
|
return normalized;
|
|
}
|
|
return "partial";
|
|
}
|
|
|
|
function combineStatusLevel(current, next) {
|
|
const rank = {
|
|
good: 0,
|
|
partial: 1,
|
|
issues: 2,
|
|
};
|
|
const normalizedCurrent = normalizeStatusLevel(current);
|
|
const normalizedNext = normalizeStatusLevel(next);
|
|
return rank[normalizedNext] > rank[normalizedCurrent]
|
|
? normalizedNext
|
|
: normalizedCurrent;
|
|
}
|
|
|
|
function levelTitle(level) {
|
|
const normalized = normalizeStatusLevel(level);
|
|
if (normalized === "good") {
|
|
return "GOOD";
|
|
}
|
|
if (normalized === "issues") {
|
|
return "ISSUES";
|
|
}
|
|
return "PARTIALLY GOOD";
|
|
}
|
|
|
|
function evaluateSyncStatus(value) {
|
|
const statusText = cleanString(value);
|
|
if (!statusText) {
|
|
return { level: "partial", label: "Unknown" };
|
|
}
|
|
const normalized = statusText.toLowerCase();
|
|
if (
|
|
normalized.indexOf("success") !== -1 ||
|
|
normalized.indexOf("ok") !== -1 ||
|
|
normalized.indexOf("ready") !== -1 ||
|
|
normalized.indexOf("healthy") !== -1
|
|
) {
|
|
return { level: "good", label: statusText };
|
|
}
|
|
if (
|
|
normalized.indexOf("pending") !== -1 ||
|
|
normalized.indexOf("warn") !== -1 ||
|
|
normalized.indexOf("partial") !== -1
|
|
) {
|
|
return { level: "partial", label: statusText };
|
|
}
|
|
return { level: "issues", label: statusText };
|
|
}
|
|
|
|
function evaluateDomainStatus(value) {
|
|
const stateText = cleanString(value);
|
|
if (!stateText) {
|
|
return { level: "partial", label: "Unknown" };
|
|
}
|
|
const normalized = stateText.toLowerCase();
|
|
if (
|
|
normalized.indexOf("verified") !== -1 ||
|
|
normalized.indexOf("active") !== -1 ||
|
|
normalized.indexOf("valid") !== -1 ||
|
|
normalized === "ok"
|
|
) {
|
|
return { level: "good", label: stateText };
|
|
}
|
|
if (
|
|
normalized.indexOf("pending") !== -1 ||
|
|
normalized.indexOf("queued") !== -1 ||
|
|
normalized.indexOf("review") !== -1 ||
|
|
normalized.indexOf("requested") !== -1
|
|
) {
|
|
return { level: "partial", label: stateText };
|
|
}
|
|
return { level: "issues", label: stateText };
|
|
}
|
|
|
|
function normalizeOperationStatus(status) {
|
|
const normalized = cleanString(status).toLowerCase();
|
|
if (
|
|
normalized === "complete" ||
|
|
normalized === "done" ||
|
|
normalized === "fulfilled"
|
|
) {
|
|
return "completed";
|
|
}
|
|
if (normalized === "progress" || normalized === "started") {
|
|
return "in_progress";
|
|
}
|
|
if (normalized === "error" || normalized === "failed") {
|
|
return "blocked";
|
|
}
|
|
if (
|
|
normalized === "completed" ||
|
|
normalized === "in_progress" ||
|
|
normalized === "blocked"
|
|
) {
|
|
return normalized;
|
|
}
|
|
return "pending";
|
|
}
|
|
|
|
function operationStatusLabel(status) {
|
|
const normalized = normalizeOperationStatus(status);
|
|
if (normalized === "completed") {
|
|
return "Completed";
|
|
}
|
|
if (normalized === "in_progress") {
|
|
return "In Progress";
|
|
}
|
|
if (normalized === "blocked") {
|
|
return "Blocked";
|
|
}
|
|
return "Pending";
|
|
}
|
|
|
|
function operationStatusLevel(status) {
|
|
const normalized = normalizeOperationStatus(status);
|
|
if (normalized === "completed") {
|
|
return "good";
|
|
}
|
|
if (normalized === "blocked") {
|
|
return "issues";
|
|
}
|
|
return "partial";
|
|
}
|
|
|
|
function operationTaskSortComparator(a, b) {
|
|
const priorityA = operationActionPriority(a);
|
|
const priorityB = operationActionPriority(b);
|
|
if (priorityA !== priorityB) {
|
|
return priorityA - priorityB;
|
|
}
|
|
const statusA = normalizeOperationStatus(a && a.status ? a.status : "");
|
|
const statusB = normalizeOperationStatus(b && b.status ? b.status : "");
|
|
const statusRankA = Object.prototype.hasOwnProperty.call(
|
|
OPERATION_STATUS_ORDER,
|
|
statusA
|
|
)
|
|
? OPERATION_STATUS_ORDER[statusA]
|
|
: 0;
|
|
const statusRankB = Object.prototype.hasOwnProperty.call(
|
|
OPERATION_STATUS_ORDER,
|
|
statusB
|
|
)
|
|
? OPERATION_STATUS_ORDER[statusB]
|
|
: 0;
|
|
if (statusRankA !== statusRankB) {
|
|
return statusRankA - statusRankB;
|
|
}
|
|
const sourceA = cleanString(a && a.source ? a.source : "");
|
|
const sourceB = cleanString(b && b.source ? b.source : "");
|
|
if (sourceA !== sourceB) {
|
|
if (sourceA === "detected") {
|
|
return -1;
|
|
}
|
|
if (sourceB === "detected") {
|
|
return 1;
|
|
}
|
|
}
|
|
const updatedAtA = cleanString(a && a.updatedAt ? a.updatedAt : "");
|
|
const updatedAtB = cleanString(b && b.updatedAt ? b.updatedAt : "");
|
|
if (updatedAtA !== updatedAtB) {
|
|
return updatedAtA > updatedAtB ? -1 : 1;
|
|
}
|
|
const titleA = cleanString(a && a.title ? a.title : "");
|
|
const titleB = cleanString(b && b.title ? b.title : "");
|
|
return titleA.localeCompare(titleB);
|
|
}
|
|
|
|
function operationRequiresAction(task) {
|
|
const status = normalizeOperationStatus(
|
|
task && task.status ? task.status : ""
|
|
);
|
|
if (status === "completed") {
|
|
return false;
|
|
}
|
|
const taskType = cleanString(task && task.taskType ? task.taskType : "");
|
|
if (taskType === "intro_grant") {
|
|
return true;
|
|
}
|
|
return status === "pending" || status === "blocked";
|
|
}
|
|
|
|
function operationActionPriority(task) {
|
|
const status = normalizeOperationStatus(
|
|
task && task.status ? task.status : ""
|
|
);
|
|
const taskType = cleanString(task && task.taskType ? task.taskType : "");
|
|
if (taskType === "intro_grant" && status !== "completed") {
|
|
return 0;
|
|
}
|
|
if (status === "pending") {
|
|
return 1;
|
|
}
|
|
if (status === "blocked") {
|
|
return 2;
|
|
}
|
|
if (status === "in_progress") {
|
|
return 3;
|
|
}
|
|
return 4;
|
|
}
|
|
|
|
function operationActionLabel(task) {
|
|
const status = normalizeOperationStatus(
|
|
task && task.status ? task.status : ""
|
|
);
|
|
const taskType = cleanString(task && task.taskType ? task.taskType : "");
|
|
if (taskType === "intro_grant" && status !== "completed") {
|
|
return "Grant Review";
|
|
}
|
|
if (status === "blocked") {
|
|
return "Needs Review";
|
|
}
|
|
return "Needs Action";
|
|
}
|
|
|
|
function buildOperationTaskPayload(task) {
|
|
const record = isRecord(task) ? task : {};
|
|
return {
|
|
id: cleanString(record.id),
|
|
source: cleanString(record.source) || "manual",
|
|
detectionKey: cleanString(record.detectionKey),
|
|
taskType: cleanString(record.taskType) || "manual",
|
|
title: cleanString(record.title),
|
|
status: normalizeOperationStatus(record.status),
|
|
nextcloudUserId: cleanString(record.nextcloudUserId),
|
|
paymenterCustomerId: cleanString(record.paymenterCustomerId),
|
|
userLabel: cleanString(record.userLabel),
|
|
orderRef: cleanString(record.orderRef),
|
|
productCode: cleanString(record.productCode),
|
|
quantity: cleanString(record.quantity),
|
|
notes: cleanString(record.notes),
|
|
createdAt: cleanString(record.createdAt),
|
|
updatedAt: cleanString(record.updatedAt),
|
|
completedAt: cleanString(record.completedAt),
|
|
meta: cloneJsonValue(isRecord(record.meta) ? record.meta : {}, {}),
|
|
};
|
|
}
|
|
|
|
function summarizeTaskCounts(tasks) {
|
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
let actionRequiredCount = 0;
|
|
let grantRequestCount = 0;
|
|
list.forEach(function (task) {
|
|
if (!isRecord(task)) {
|
|
return;
|
|
}
|
|
if (operationRequiresAction(task)) {
|
|
actionRequiredCount += 1;
|
|
}
|
|
if (
|
|
cleanString(task.taskType) === "intro_grant" &&
|
|
normalizeOperationStatus(task.status) !== "completed"
|
|
) {
|
|
grantRequestCount += 1;
|
|
}
|
|
});
|
|
return {
|
|
taskCount: list.length,
|
|
actionRequiredCount: actionRequiredCount,
|
|
grantRequestCount: grantRequestCount,
|
|
};
|
|
}
|
|
|
|
function normalizeKnownUserEntry(entry) {
|
|
const record = isRecord(entry) ? entry : {};
|
|
return {
|
|
nextcloudUserId: firstNonEmptyString([
|
|
record.nextcloudUserId,
|
|
record.userId,
|
|
record.uid,
|
|
]),
|
|
displayName: firstNonEmptyString([record.displayName, record.name]),
|
|
email: firstNonEmptyString([record.email]),
|
|
qortalAddress: firstNonEmptyString([
|
|
record.qortalAddress,
|
|
record.address,
|
|
]),
|
|
paymenterCustomerId: firstNonEmptyString([
|
|
record.paymenterCustomerId,
|
|
record.customerId,
|
|
]),
|
|
raw: record,
|
|
};
|
|
}
|
|
|
|
function collectKnownUsers(detailData) {
|
|
const data = isRecord(detailData) ? detailData : {};
|
|
const summary = firstObject([data.summary]);
|
|
const users = firstArray([
|
|
summary.knownUsers,
|
|
data.knownUsers,
|
|
data.users,
|
|
summary.users,
|
|
]);
|
|
return users.map(normalizeKnownUserEntry).filter(function (entry) {
|
|
return (
|
|
entry.nextcloudUserId || entry.displayName || entry.paymenterCustomerId
|
|
);
|
|
});
|
|
}
|
|
|
|
function normalizeCommerceItem(entry) {
|
|
const record = isRecord(entry) ? entry : {};
|
|
const statusText = firstNonEmptyString(
|
|
[record.status, record.state, record.orderStatus],
|
|
""
|
|
);
|
|
return {
|
|
orderRef: firstNonEmptyString(
|
|
[record.orderId, record.id, record.reference, record.externalId],
|
|
""
|
|
),
|
|
productCode: firstNonEmptyString(
|
|
[
|
|
record.code,
|
|
record.productCode,
|
|
record.planCode,
|
|
record.serviceCode,
|
|
record.itemCode,
|
|
],
|
|
""
|
|
),
|
|
status: inferTaskStatusFromText(statusText),
|
|
statusLabel: statusText,
|
|
createdAt: firstNonEmptyString([record.createdAt], ""),
|
|
updatedAt: firstNonEmptyString([record.updatedAt], ""),
|
|
completedAt: firstNonEmptyString(
|
|
[record.completedAt, record.fulfilledAt],
|
|
""
|
|
),
|
|
nextcloudUserId: firstNonEmptyString(
|
|
[record.nextcloudUserId, record.userId, record.uid],
|
|
""
|
|
),
|
|
paymenterCustomerId: firstNonEmptyString(
|
|
[record.paymenterCustomerId, record.customerId],
|
|
""
|
|
),
|
|
label: firstNonEmptyString([record.title, record.name, record.label], ""),
|
|
};
|
|
}
|
|
|
|
function collectUserCommerceRows(detailData, knownUsers) {
|
|
const data = isRecord(detailData) ? detailData : {};
|
|
const summary = firstObject([data.summary]);
|
|
const users = Array.isArray(knownUsers) ? knownUsers : [];
|
|
const userRowsByKey = {};
|
|
const userRowsByCustomerId = {};
|
|
const userRowsByNextcloudUserId = {};
|
|
const ensureUserRow = function (user) {
|
|
const entry = isRecord(user) ? user : {};
|
|
const nextcloudUserId = firstNonEmptyString([entry.nextcloudUserId], "");
|
|
const customerId = firstNonEmptyString([entry.paymenterCustomerId], "");
|
|
const displayName = firstNonEmptyString(
|
|
[entry.displayName, nextcloudUserId],
|
|
"Unknown User"
|
|
);
|
|
const key =
|
|
nextcloudUserId ||
|
|
"customer:" + customerId ||
|
|
"user:" + String(Object.keys(userRowsByKey).length + 1);
|
|
if (!Object.prototype.hasOwnProperty.call(userRowsByKey, key)) {
|
|
userRowsByKey[key] = {
|
|
nextcloudUserId: nextcloudUserId,
|
|
displayName: displayName,
|
|
email: firstNonEmptyString([entry.email], ""),
|
|
qortalAddress: firstNonEmptyString([entry.qortalAddress], ""),
|
|
paymenterCustomerId: customerId,
|
|
items: [],
|
|
itemsByKey: {},
|
|
};
|
|
}
|
|
const row = userRowsByKey[key];
|
|
if (customerId) {
|
|
userRowsByCustomerId[customerId] = row;
|
|
}
|
|
if (nextcloudUserId) {
|
|
userRowsByNextcloudUserId[nextcloudUserId] = row;
|
|
}
|
|
return row;
|
|
};
|
|
users.forEach(function (user) {
|
|
ensureUserRow(user);
|
|
});
|
|
const appendItem = function (itemRaw, fallbackUser) {
|
|
const item = normalizeCommerceItem(itemRaw);
|
|
if (!item.orderRef && !item.productCode && !item.label) {
|
|
return;
|
|
}
|
|
let row = null;
|
|
if (
|
|
item.paymenterCustomerId &&
|
|
Object.prototype.hasOwnProperty.call(
|
|
userRowsByCustomerId,
|
|
item.paymenterCustomerId
|
|
)
|
|
) {
|
|
row = userRowsByCustomerId[item.paymenterCustomerId];
|
|
}
|
|
if (
|
|
!row &&
|
|
item.nextcloudUserId &&
|
|
Object.prototype.hasOwnProperty.call(
|
|
userRowsByNextcloudUserId,
|
|
item.nextcloudUserId
|
|
)
|
|
) {
|
|
row = userRowsByNextcloudUserId[item.nextcloudUserId];
|
|
}
|
|
if (!row && fallbackUser) {
|
|
row = ensureUserRow(fallbackUser);
|
|
}
|
|
if (!row) {
|
|
row = ensureUserRow({
|
|
nextcloudUserId: item.nextcloudUserId,
|
|
displayName:
|
|
item.nextcloudUserId ||
|
|
(item.paymenterCustomerId
|
|
? "Customer #" + item.paymenterCustomerId
|
|
: "Unassigned User"),
|
|
paymenterCustomerId: item.paymenterCustomerId,
|
|
});
|
|
}
|
|
const itemKey = [
|
|
cleanString(item.orderRef),
|
|
cleanString(item.productCode),
|
|
cleanString(item.label),
|
|
cleanString(item.createdAt),
|
|
cleanString(item.updatedAt),
|
|
].join("|");
|
|
if (
|
|
!itemKey ||
|
|
Object.prototype.hasOwnProperty.call(row.itemsByKey, itemKey)
|
|
) {
|
|
return;
|
|
}
|
|
row.itemsByKey[itemKey] = true;
|
|
row.items.push(item);
|
|
};
|
|
const flattenedArrays = function (sources) {
|
|
const out = [];
|
|
(Array.isArray(sources) ? sources : []).forEach(function (value) {
|
|
if (!Array.isArray(value)) {
|
|
return;
|
|
}
|
|
value.forEach(function (entry) {
|
|
out.push(entry);
|
|
});
|
|
});
|
|
return out;
|
|
};
|
|
|
|
flattenedArrays([
|
|
summary.orders,
|
|
summary.purchases,
|
|
summary.services,
|
|
summary.subscriptions,
|
|
data.orders,
|
|
data.purchases,
|
|
data.services,
|
|
data.subscriptions,
|
|
]).forEach(function (entry) {
|
|
appendItem(entry, null);
|
|
});
|
|
users.forEach(function (user) {
|
|
const userRaw = isRecord(user.raw) ? user.raw : {};
|
|
flattenedArrays([
|
|
userRaw.orders,
|
|
userRaw.purchases,
|
|
userRaw.services,
|
|
userRaw.subscriptions,
|
|
]).forEach(function (entry) {
|
|
appendItem(entry, user);
|
|
});
|
|
});
|
|
|
|
const rows = Object.keys(userRowsByKey).map(function (key) {
|
|
const row = userRowsByKey[key];
|
|
const items = Array.isArray(row.items) ? row.items.slice() : [];
|
|
items.sort(function (a, b) {
|
|
const updatedA = cleanString(a.updatedAt) || cleanString(a.createdAt);
|
|
const updatedB = cleanString(b.updatedAt) || cleanString(b.createdAt);
|
|
if (updatedA !== updatedB) {
|
|
return updatedA > updatedB ? -1 : 1;
|
|
}
|
|
const codeA = cleanString(a.productCode) || cleanString(a.label);
|
|
const codeB = cleanString(b.productCode) || cleanString(b.label);
|
|
return codeA.localeCompare(codeB);
|
|
});
|
|
let pendingCount = 0;
|
|
let inProgressCount = 0;
|
|
let blockedCount = 0;
|
|
let completedCount = 0;
|
|
const uniqueCodes = [];
|
|
const codeSet = {};
|
|
items.forEach(function (item) {
|
|
const status = normalizeOperationStatus(item.status);
|
|
if (status === "completed") {
|
|
completedCount += 1;
|
|
} else if (status === "in_progress") {
|
|
inProgressCount += 1;
|
|
} else if (status === "blocked") {
|
|
blockedCount += 1;
|
|
} else {
|
|
pendingCount += 1;
|
|
}
|
|
const code = cleanString(item.productCode) || cleanString(item.label);
|
|
if (!code || Object.prototype.hasOwnProperty.call(codeSet, code)) {
|
|
return;
|
|
}
|
|
codeSet[code] = true;
|
|
uniqueCodes.push(code);
|
|
});
|
|
return {
|
|
nextcloudUserId: cleanString(row.nextcloudUserId),
|
|
displayName: cleanString(row.displayName),
|
|
email: cleanString(row.email),
|
|
qortalAddress: cleanString(row.qortalAddress),
|
|
paymenterCustomerId: cleanString(row.paymenterCustomerId),
|
|
totalItems: items.length,
|
|
pendingCount: pendingCount,
|
|
inProgressCount: inProgressCount,
|
|
blockedCount: blockedCount,
|
|
completedCount: completedCount,
|
|
recentCodes: uniqueCodes.slice(0, 4),
|
|
items: items.slice(0, 12),
|
|
};
|
|
});
|
|
rows.sort(function (a, b) {
|
|
const nameA =
|
|
cleanString(a.displayName) || cleanString(a.nextcloudUserId);
|
|
const nameB =
|
|
cleanString(b.displayName) || cleanString(b.nextcloudUserId);
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function buildOperationDetectionKey(instanceId, fields) {
|
|
const parts = [
|
|
cleanString(instanceId).toLowerCase(),
|
|
cleanString(fields.source || "detected").toLowerCase(),
|
|
cleanString(fields.taskType || "").toLowerCase(),
|
|
cleanString(fields.nextcloudUserId || "").toLowerCase(),
|
|
cleanString(fields.paymenterCustomerId || "").toLowerCase(),
|
|
cleanString(fields.orderRef || "").toLowerCase(),
|
|
cleanString(fields.productCode || "").toLowerCase(),
|
|
cleanString(fields.title || "").toLowerCase(),
|
|
];
|
|
return parts.join("|").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function inferTaskStatusFromText(value) {
|
|
const normalized = cleanString(value).toLowerCase();
|
|
if (!normalized) {
|
|
return "pending";
|
|
}
|
|
if (
|
|
normalized.indexOf("complete") !== -1 ||
|
|
normalized.indexOf("fulfilled") !== -1 ||
|
|
normalized.indexOf("paid") !== -1 ||
|
|
normalized.indexOf("active") !== -1 ||
|
|
normalized.indexOf("provisioned") !== -1
|
|
) {
|
|
return "completed";
|
|
}
|
|
if (
|
|
normalized.indexOf("progress") !== -1 ||
|
|
normalized.indexOf("processing") !== -1
|
|
) {
|
|
return "in_progress";
|
|
}
|
|
if (
|
|
normalized.indexOf("block") !== -1 ||
|
|
normalized.indexOf("fail") !== -1 ||
|
|
normalized.indexOf("error") !== -1
|
|
) {
|
|
return "blocked";
|
|
}
|
|
return "pending";
|
|
}
|
|
|
|
function normalizeGrantRequestRecord(entry) {
|
|
const record = isRecord(entry) ? entry : {};
|
|
const requestId = firstNonEmptyString(
|
|
[record.requestId, record.id, record.ref, record.externalRequestId],
|
|
""
|
|
);
|
|
const statusText = firstNonEmptyString([record.status, record.state], "");
|
|
const subject = firstObject([record.billingSubject, record.requester]);
|
|
return {
|
|
requestId: requestId,
|
|
status: inferTaskStatusFromText(statusText),
|
|
statusLabel: statusText,
|
|
nextcloudUserId: firstNonEmptyString(
|
|
[record.nextcloudUserId, record.userId, subject.nextcloudUserId],
|
|
""
|
|
),
|
|
displayName: firstNonEmptyString(
|
|
[
|
|
record.displayName,
|
|
record.userLabel,
|
|
subject.displayName,
|
|
subject.name,
|
|
],
|
|
""
|
|
),
|
|
paymenterCustomerId: firstNonEmptyString(
|
|
[record.paymenterCustomerId, record.customerId],
|
|
""
|
|
),
|
|
qortalAddress: firstNonEmptyString(
|
|
[
|
|
record.qortalAddress,
|
|
record.address,
|
|
subject.qortalAddress,
|
|
subject.address,
|
|
],
|
|
""
|
|
),
|
|
amount: firstNonEmptyString(
|
|
[record.amount, record.grantAmountQort, record.grantRequiredAmount],
|
|
""
|
|
),
|
|
message: firstNonEmptyString([record.message, record.notes], ""),
|
|
createdAt: firstNonEmptyString(
|
|
[record.requestedAt, record.createdAt],
|
|
""
|
|
),
|
|
updatedAt: firstNonEmptyString(
|
|
[record.updatedAt, record.approvedAt, record.rejectedAt],
|
|
""
|
|
),
|
|
completedAt: firstNonEmptyString(
|
|
[record.completedAt, record.approvedAt, record.rejectedAt],
|
|
""
|
|
),
|
|
raw: record,
|
|
};
|
|
}
|
|
|
|
function collectIntroGrantRecords(detailData) {
|
|
const data = isRecord(detailData) ? detailData : {};
|
|
const summary = firstObject([data.summary]);
|
|
const details = firstObject([data.details]);
|
|
const grants = firstObject([data.grants, summary.grants, details.grants]);
|
|
const rawCandidates = [
|
|
firstArray([
|
|
data.introGrants,
|
|
data.grantRequests,
|
|
data.introGrantRequests,
|
|
]),
|
|
firstArray([
|
|
summary.introGrants,
|
|
summary.grantRequests,
|
|
summary.introGrantRequests,
|
|
]),
|
|
firstArray([
|
|
details.introGrants,
|
|
details.grantRequests,
|
|
details.introGrantRequests,
|
|
]),
|
|
firstArray([grants.intro, grants.requests, grants.items]),
|
|
];
|
|
const records = [];
|
|
rawCandidates.forEach(function (candidate) {
|
|
(Array.isArray(candidate) ? candidate : []).forEach(function (entry) {
|
|
const normalized = normalizeGrantRequestRecord(entry);
|
|
if (
|
|
!normalized.requestId &&
|
|
!normalized.nextcloudUserId &&
|
|
!normalized.qortalAddress
|
|
) {
|
|
return;
|
|
}
|
|
records.push(normalized);
|
|
});
|
|
});
|
|
const deduped = {};
|
|
records.forEach(function (entry) {
|
|
const key =
|
|
cleanString(entry.requestId) ||
|
|
[
|
|
cleanString(entry.nextcloudUserId),
|
|
cleanString(entry.qortalAddress),
|
|
cleanString(entry.createdAt),
|
|
].join("|");
|
|
if (!key) {
|
|
return;
|
|
}
|
|
deduped[key] = entry;
|
|
});
|
|
return Object.keys(deduped).map(function (key) {
|
|
return deduped[key];
|
|
});
|
|
}
|
|
|
|
function detectOperationsFromInstanceDetail(instanceId, detailData) {
|
|
const data = isRecord(detailData) ? detailData : {};
|
|
const summary = firstObject([data.summary]);
|
|
const summaryInstance = firstObject([summary.instance, data.instance]);
|
|
const knownUsers = collectKnownUsers(data);
|
|
const userByCustomerId = {};
|
|
knownUsers.forEach(function (entry) {
|
|
const customerId = cleanString(entry.paymenterCustomerId);
|
|
if (customerId) {
|
|
userByCustomerId[customerId] = entry;
|
|
}
|
|
});
|
|
|
|
const detected = [];
|
|
const addDetected = function (candidate) {
|
|
const title = cleanString(candidate.title);
|
|
if (!title) {
|
|
return;
|
|
}
|
|
const normalizedTask = {
|
|
id: cleanString(candidate.id),
|
|
source: "detected",
|
|
detectionKey: buildOperationDetectionKey(instanceId, candidate),
|
|
taskType: cleanString(candidate.taskType) || "manual",
|
|
title: title,
|
|
status: normalizeOperationStatus(candidate.status || "pending"),
|
|
nextcloudUserId: cleanString(candidate.nextcloudUserId),
|
|
paymenterCustomerId: cleanString(candidate.paymenterCustomerId),
|
|
userLabel: cleanString(candidate.userLabel),
|
|
orderRef: cleanString(candidate.orderRef),
|
|
productCode: cleanString(candidate.productCode),
|
|
quantity: cleanString(candidate.quantity),
|
|
notes: cleanString(candidate.notes),
|
|
createdAt: cleanString(candidate.createdAt),
|
|
updatedAt: cleanString(candidate.updatedAt),
|
|
completedAt: cleanString(candidate.completedAt),
|
|
meta: isRecord(candidate.meta) ? candidate.meta : {},
|
|
};
|
|
if (!normalizedTask.detectionKey) {
|
|
return;
|
|
}
|
|
detected.push(normalizedTask);
|
|
};
|
|
|
|
const checkoutType = cleanString(
|
|
summaryInstance.lastCheckoutType || data.lastCheckoutType
|
|
).toLowerCase();
|
|
const checkoutCode = firstNonEmptyString([
|
|
summaryInstance.lastCheckoutOneTimeCode,
|
|
summaryInstance.lastCheckoutPackageTier,
|
|
data.lastCheckoutOneTimeCode,
|
|
data.lastCheckoutPackageTier,
|
|
]);
|
|
const checkoutAt = firstNonEmptyString([
|
|
summaryInstance.lastCheckoutAt,
|
|
data.lastCheckoutAt,
|
|
]);
|
|
if (checkoutType === "one_time" && checkoutCode) {
|
|
addDetected({
|
|
taskType:
|
|
checkoutCode.indexOf("credits") !== -1 ? "publish_credits" : "manual",
|
|
title: "Fulfill one-time order: " + checkoutCode,
|
|
productCode: checkoutCode,
|
|
orderRef: checkoutCode,
|
|
status: "pending",
|
|
createdAt: checkoutAt,
|
|
updatedAt: checkoutAt,
|
|
});
|
|
}
|
|
if (checkoutType === "subscription" && checkoutCode) {
|
|
addDetected({
|
|
taskType:
|
|
checkoutCode.indexOf("replication") !== -1
|
|
? "replication_setup"
|
|
: "storage_upgrade",
|
|
title: "Provision subscription tier: " + checkoutCode,
|
|
productCode: checkoutCode,
|
|
orderRef: checkoutCode,
|
|
status: "pending",
|
|
createdAt: checkoutAt,
|
|
updatedAt: checkoutAt,
|
|
});
|
|
}
|
|
|
|
const collectOrderArrays = function () {
|
|
return firstArray([
|
|
summary.orders,
|
|
summary.purchases,
|
|
summary.services,
|
|
data.orders,
|
|
data.purchases,
|
|
data.services,
|
|
]);
|
|
};
|
|
collectOrderArrays().forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const customerId = firstNonEmptyString([
|
|
entry.paymenterCustomerId,
|
|
entry.customerId,
|
|
]);
|
|
const user =
|
|
customerId && userByCustomerId[customerId]
|
|
? userByCustomerId[customerId]
|
|
: null;
|
|
const code = firstNonEmptyString([
|
|
entry.code,
|
|
entry.productCode,
|
|
entry.planCode,
|
|
entry.serviceCode,
|
|
entry.itemCode,
|
|
]);
|
|
const title = firstNonEmptyString([
|
|
entry.title,
|
|
entry.name,
|
|
entry.label,
|
|
code ? "Fulfill order item: " + code : "",
|
|
]);
|
|
addDetected({
|
|
taskType:
|
|
code.indexOf("credits") !== -1
|
|
? "publish_credits"
|
|
: code.indexOf("replication") !== -1
|
|
? "replication_setup"
|
|
: "manual",
|
|
title: title,
|
|
nextcloudUserId: firstNonEmptyString([
|
|
entry.nextcloudUserId,
|
|
user ? user.nextcloudUserId : "",
|
|
]),
|
|
paymenterCustomerId: customerId,
|
|
userLabel: firstNonEmptyString([
|
|
entry.userLabel,
|
|
user ? user.displayName || user.nextcloudUserId : "",
|
|
]),
|
|
orderRef: firstNonEmptyString([
|
|
entry.orderId,
|
|
entry.id,
|
|
entry.reference,
|
|
entry.externalId,
|
|
]),
|
|
productCode: code,
|
|
quantity: firstNonEmptyString([entry.quantity, entry.qty]),
|
|
status: inferTaskStatusFromText(
|
|
firstNonEmptyString([entry.status, entry.state, entry.orderStatus])
|
|
),
|
|
createdAt: firstNonEmptyString([entry.createdAt]),
|
|
updatedAt: firstNonEmptyString([entry.updatedAt]),
|
|
completedAt: firstNonEmptyString([
|
|
entry.completedAt,
|
|
entry.fulfilledAt,
|
|
]),
|
|
});
|
|
});
|
|
|
|
knownUsers.forEach(function (user) {
|
|
const userOrders = firstArray([
|
|
user.raw.orders,
|
|
user.raw.purchases,
|
|
user.raw.services,
|
|
user.raw.subscriptions,
|
|
]);
|
|
userOrders.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const code = firstNonEmptyString([
|
|
entry.code,
|
|
entry.productCode,
|
|
entry.planCode,
|
|
entry.serviceCode,
|
|
]);
|
|
const title = firstNonEmptyString([
|
|
entry.title,
|
|
entry.name,
|
|
entry.label,
|
|
code ? "Fulfill user order: " + code : "",
|
|
]);
|
|
addDetected({
|
|
taskType:
|
|
code.indexOf("credits") !== -1
|
|
? "publish_credits"
|
|
: code.indexOf("replication") !== -1
|
|
? "replication_setup"
|
|
: "manual",
|
|
title: title,
|
|
nextcloudUserId: user.nextcloudUserId,
|
|
paymenterCustomerId: user.paymenterCustomerId,
|
|
userLabel: user.displayName || user.nextcloudUserId,
|
|
orderRef: firstNonEmptyString([
|
|
entry.orderId,
|
|
entry.id,
|
|
entry.reference,
|
|
]),
|
|
productCode: code,
|
|
quantity: firstNonEmptyString([entry.quantity, entry.qty]),
|
|
status: inferTaskStatusFromText(
|
|
firstNonEmptyString([entry.status, entry.state])
|
|
),
|
|
createdAt: firstNonEmptyString([entry.createdAt]),
|
|
updatedAt: firstNonEmptyString([entry.updatedAt]),
|
|
completedAt: firstNonEmptyString([
|
|
entry.completedAt,
|
|
entry.fulfilledAt,
|
|
]),
|
|
});
|
|
});
|
|
});
|
|
|
|
collectIntroGrantRecords(detailData).forEach(function (grant) {
|
|
const titleBits = ["Intro grant request"];
|
|
if (cleanString(grant.displayName)) {
|
|
titleBits.push(cleanString(grant.displayName));
|
|
} else if (cleanString(grant.nextcloudUserId)) {
|
|
titleBits.push(cleanString(grant.nextcloudUserId));
|
|
}
|
|
addDetected({
|
|
taskType: "intro_grant",
|
|
title: titleBits.join(": "),
|
|
nextcloudUserId: cleanString(grant.nextcloudUserId),
|
|
paymenterCustomerId: cleanString(grant.paymenterCustomerId),
|
|
userLabel: cleanString(grant.displayName),
|
|
orderRef: cleanString(grant.requestId),
|
|
productCode: "intro_grant",
|
|
quantity: cleanString(grant.amount),
|
|
status: normalizeOperationStatus(grant.status || "pending"),
|
|
notes: cleanString(grant.message),
|
|
createdAt: cleanString(grant.createdAt),
|
|
updatedAt: cleanString(grant.updatedAt || grant.createdAt),
|
|
completedAt: cleanString(grant.completedAt),
|
|
meta: {
|
|
grantRequest: grant.raw,
|
|
},
|
|
});
|
|
});
|
|
|
|
const dedupedByKey = {};
|
|
detected.forEach(function (entry) {
|
|
const key = cleanString(entry.detectionKey);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(dedupedByKey, key)) {
|
|
dedupedByKey[key] = entry;
|
|
return;
|
|
}
|
|
const existing = dedupedByKey[key];
|
|
const existingStatus = normalizeOperationStatus(existing.status);
|
|
const incomingStatus = normalizeOperationStatus(entry.status);
|
|
if (existingStatus === "completed" && incomingStatus !== "completed") {
|
|
return;
|
|
}
|
|
dedupedByKey[key] = entry;
|
|
});
|
|
|
|
return Object.keys(dedupedByKey).map(function (key) {
|
|
return dedupedByKey[key];
|
|
});
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
const raw = cleanString(value);
|
|
if (!raw) {
|
|
return "Not set";
|
|
}
|
|
const parsed = new Date(raw);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return raw;
|
|
}
|
|
return parsed.toLocaleString();
|
|
}
|
|
|
|
function createInfoPanel(rawEl, toggleButton, allButton) {
|
|
return {
|
|
rawEl: rawEl || null,
|
|
toggleButton: toggleButton || null,
|
|
allButton: allButton || null,
|
|
rawVisible: false,
|
|
showAll: false,
|
|
loading: false,
|
|
message: "",
|
|
errorMessage: "",
|
|
fullData: null,
|
|
focusedData: null,
|
|
};
|
|
}
|
|
|
|
function infoPanelHasData(panel) {
|
|
return panel && panel.fullData !== null && panel.fullData !== undefined;
|
|
}
|
|
|
|
function setInfoPanelLoading(panel, message) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
panel.loading = true;
|
|
panel.errorMessage = "";
|
|
panel.message = cleanString(message) || "Loading...";
|
|
panel.fullData = null;
|
|
panel.focusedData = null;
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function setInfoPanelEmpty(panel, message) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
panel.loading = false;
|
|
panel.errorMessage = "";
|
|
panel.message = cleanString(message) || "No data available.";
|
|
panel.fullData = null;
|
|
panel.focusedData = null;
|
|
panel.showAll = false;
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function setInfoPanelError(panel, message) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
panel.loading = false;
|
|
panel.errorMessage = cleanString(message) || "Unknown error.";
|
|
panel.message = "";
|
|
panel.fullData = null;
|
|
panel.focusedData = null;
|
|
panel.showAll = false;
|
|
panel.rawVisible = true;
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function setInfoPanelData(panel, fullData, focusedData) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
panel.loading = false;
|
|
panel.errorMessage = "";
|
|
panel.message = "";
|
|
panel.fullData = fullData;
|
|
panel.focusedData = focusedData;
|
|
if (!infoPanelHasData(panel)) {
|
|
panel.showAll = false;
|
|
}
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function setInfoPanelRawVisible(panel, rawVisible) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
panel.rawVisible = Boolean(rawVisible);
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function setInfoPanelShowAll(panel, showAll) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (!infoPanelHasData(panel)) {
|
|
panel.showAll = false;
|
|
renderInfoPanel(panel);
|
|
return;
|
|
}
|
|
panel.showAll = Boolean(showAll);
|
|
if (panel.showAll) {
|
|
panel.rawVisible = true;
|
|
}
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function stringifyPanelData(value) {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value || {}, null, 2);
|
|
} catch (_error) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function renderInfoPanel(panel) {
|
|
if (!panel || !panel.rawEl) {
|
|
return;
|
|
}
|
|
let content = "";
|
|
if (panel.errorMessage) {
|
|
content = panel.errorMessage;
|
|
} else if (panel.loading) {
|
|
content = panel.message || "Loading...";
|
|
} else if (infoPanelHasData(panel)) {
|
|
const payload = panel.showAll
|
|
? panel.fullData
|
|
: panel.focusedData !== null && panel.focusedData !== undefined
|
|
? panel.focusedData
|
|
: panel.fullData;
|
|
content = stringifyPanelData(payload);
|
|
} else {
|
|
content = panel.message || "No data available.";
|
|
}
|
|
panel.rawEl.textContent = content;
|
|
panel.rawEl.classList.toggle("chd-hidden", !panel.rawVisible);
|
|
if (panel.toggleButton) {
|
|
panel.toggleButton.textContent = panel.rawVisible
|
|
? "Hide Raw"
|
|
: "Show Raw";
|
|
}
|
|
if (panel.allButton) {
|
|
panel.allButton.disabled = !infoPanelHasData(panel);
|
|
panel.allButton.textContent = panel.showAll
|
|
? "Show Focused Info"
|
|
: "Show All Info";
|
|
}
|
|
}
|
|
|
|
function bindInfoPanelControls(panel) {
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (panel.toggleButton) {
|
|
panel.toggleButton.addEventListener("click", function () {
|
|
panel.rawVisible = !panel.rawVisible;
|
|
renderInfoPanel(panel);
|
|
});
|
|
}
|
|
if (panel.allButton) {
|
|
panel.allButton.addEventListener("click", function () {
|
|
if (!infoPanelHasData(panel)) {
|
|
return;
|
|
}
|
|
panel.showAll = !panel.showAll;
|
|
if (panel.showAll) {
|
|
panel.rawVisible = true;
|
|
}
|
|
renderInfoPanel(panel);
|
|
});
|
|
}
|
|
renderInfoPanel(panel);
|
|
}
|
|
|
|
function renderSummaryItems(container, items, emptyMessage) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.innerHTML = "";
|
|
const list = Array.isArray(items) ? items : [];
|
|
if (!list.length) {
|
|
container.classList.add("chd-summary-empty");
|
|
container.textContent = emptyMessage || "No summary data.";
|
|
return;
|
|
}
|
|
container.classList.remove("chd-summary-empty");
|
|
list.forEach(function (entry) {
|
|
if (!entry || typeof entry !== "object") {
|
|
return;
|
|
}
|
|
const item = document.createElement("div");
|
|
item.className = "chd-summary-item";
|
|
if (entry.wide) {
|
|
item.classList.add("chd-summary-item--wide");
|
|
}
|
|
const label = document.createElement("span");
|
|
label.className = "chd-summary-label";
|
|
label.textContent = cleanString(entry.label) || "Info";
|
|
const value = document.createElement("span");
|
|
value.className = "chd-summary-value";
|
|
value.textContent = cleanString(entry.value) || "Not set";
|
|
item.appendChild(label);
|
|
item.appendChild(value);
|
|
container.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function buildStatusPill(label, value, level) {
|
|
const pill = document.createElement("span");
|
|
pill.className = "chd-status-pill";
|
|
pill.dataset.level = normalizeStatusLevel(level);
|
|
pill.textContent =
|
|
cleanString(label).toUpperCase() +
|
|
": " +
|
|
(cleanString(value) || "Unknown");
|
|
return pill;
|
|
}
|
|
|
|
function renderStatusPills(container, statuses) {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.innerHTML = "";
|
|
const list = Array.isArray(statuses) ? statuses : [];
|
|
if (!list.length) {
|
|
container.classList.add("chd-hidden");
|
|
return;
|
|
}
|
|
container.classList.remove("chd-hidden");
|
|
list.forEach(function (entry) {
|
|
if (!entry || typeof entry !== "object") {
|
|
return;
|
|
}
|
|
container.appendChild(
|
|
buildStatusPill(entry.label, entry.value, entry.level)
|
|
);
|
|
});
|
|
}
|
|
|
|
function summarizeOverviewData(payloadData) {
|
|
const data = isRecord(payloadData) ? payloadData : {};
|
|
const chd = firstObject([data.chd]);
|
|
const billingAutomation = firstObject([data.billingAutomation]);
|
|
const entitlements = firstObject([data.entitlements]);
|
|
const designStatus = firstObject([data.designStatus]);
|
|
const mode = firstNonEmptyString(
|
|
[data.mode, data.sovereignMode, data.scMode],
|
|
"powered"
|
|
);
|
|
const baseUrl = firstNonEmptyString([chd.baseUrl]);
|
|
const instanceId = firstNonEmptyString([chd.instanceId]);
|
|
const connectorAdminTokenSet = coerceBoolean(chd.connectorAdminTokenSet);
|
|
const packageTier = firstNonEmptyString(
|
|
[entitlements.packageTier],
|
|
"Not set"
|
|
);
|
|
const billingProvider = firstNonEmptyString(
|
|
[chd.billingProvider],
|
|
"Not set"
|
|
);
|
|
const billingSyncMode = firstNonEmptyString(
|
|
[chd.billingSyncMode],
|
|
"Not set"
|
|
);
|
|
const lastSyncAtRaw = firstNonEmptyString([chd.lastSyncAt]);
|
|
const automationEnabled = coerceBoolean(billingAutomation.enabled);
|
|
const automationLastRunAt = firstNonEmptyString([
|
|
billingAutomation.lastRunCompletedAt,
|
|
billingAutomation.lastRunStartedAt,
|
|
]);
|
|
const automationLastRunStatus = firstNonEmptyString(
|
|
[billingAutomation.lastRunStatus],
|
|
"idle"
|
|
);
|
|
const automationLevel = (() => {
|
|
const normalized = automationLastRunStatus.toLowerCase();
|
|
if (
|
|
normalized.indexOf("success") !== -1 ||
|
|
normalized.indexOf("ok") !== -1 ||
|
|
normalized.indexOf("complete") !== -1 ||
|
|
normalized.indexOf("fulfilled") !== -1
|
|
) {
|
|
return "good";
|
|
}
|
|
if (
|
|
normalized.indexOf("skip") !== -1 ||
|
|
normalized.indexOf("idle") !== -1 ||
|
|
normalized.indexOf("pending") !== -1
|
|
) {
|
|
return "partial";
|
|
}
|
|
return automationEnabled ? "issues" : "partial";
|
|
})();
|
|
const syncStatus = evaluateSyncStatus(chd.lastSyncStatus);
|
|
const connectorLevel = baseUrl && instanceId ? "good" : "issues";
|
|
const tokenLevel = coerceBoolean(chd.instanceTokenSet) ? "good" : "issues";
|
|
const registrationLevel = coerceBoolean(chd.registrationTokenSet)
|
|
? "good"
|
|
: "partial";
|
|
const summaryItems = [
|
|
{ label: "Mode", value: mode },
|
|
{ label: "Instance ID", value: instanceId || "Missing" },
|
|
{ label: "Connector URL", value: baseUrl || "Missing", wide: true },
|
|
{ label: "Package Tier", value: packageTier },
|
|
{ label: "Billing Provider", value: billingProvider },
|
|
{ label: "Sync Mode", value: billingSyncMode },
|
|
{
|
|
label: "Invite Automation",
|
|
value: automationEnabled ? "Enabled" : "Disabled",
|
|
},
|
|
{
|
|
label: "Automation Last Run",
|
|
value: automationLastRunAt ? formatDateTime(automationLastRunAt) : "Never",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Last Sync",
|
|
value: lastSyncAtRaw ? formatDateTime(lastSyncAtRaw) : "Never",
|
|
},
|
|
];
|
|
const statusItems = [
|
|
{
|
|
label: "Connector",
|
|
value: connectorLevel === "good" ? "Configured" : "Config Missing",
|
|
level: connectorLevel,
|
|
},
|
|
{
|
|
label: "Instance Token",
|
|
value: coerceBoolean(chd.instanceTokenSet) ? "Present" : "Missing",
|
|
level: tokenLevel,
|
|
},
|
|
{
|
|
label: "Registration Token",
|
|
value: coerceBoolean(chd.registrationTokenSet) ? "Present" : "Missing",
|
|
level: registrationLevel,
|
|
},
|
|
{
|
|
label: "Connector Admin",
|
|
value: connectorAdminTokenSet ? "Configured" : "Missing",
|
|
level: connectorAdminTokenSet ? "good" : "issues",
|
|
},
|
|
{
|
|
label: "Invite Fulfillment",
|
|
value: automationEnabled ? "Enabled" : "Disabled",
|
|
level: automationEnabled ? "good" : "partial",
|
|
},
|
|
{
|
|
label: "Auto Run",
|
|
value: automationLastRunStatus,
|
|
level: automationLevel,
|
|
},
|
|
{ label: "Sync", value: syncStatus.label, level: syncStatus.level },
|
|
];
|
|
const focusedData = {
|
|
mode: mode,
|
|
connection: {
|
|
baseUrl: baseUrl,
|
|
instanceId: instanceId,
|
|
instanceTokenSet: coerceBoolean(chd.instanceTokenSet),
|
|
registrationTokenSet: coerceBoolean(chd.registrationTokenSet),
|
|
connectorAdminTokenSet: connectorAdminTokenSet,
|
|
},
|
|
sync: {
|
|
status: firstNonEmptyString([chd.lastSyncStatus]),
|
|
at: lastSyncAtRaw,
|
|
message: firstNonEmptyString([chd.lastSyncMessage]),
|
|
},
|
|
billingAutomation: billingAutomation,
|
|
entitlements: entitlements,
|
|
designStatus: designStatus,
|
|
};
|
|
return {
|
|
summaryItems: summaryItems,
|
|
statusItems: statusItems,
|
|
focusedData: focusedData,
|
|
};
|
|
}
|
|
|
|
function summarizeBillingAutoFulfillmentData(payloadData) {
|
|
const data = isRecord(payloadData) ? payloadData : {};
|
|
const enabled = coerceBoolean(data.enabled);
|
|
const lastRunStatus = firstNonEmptyString([data.lastRunStatus], "idle");
|
|
const lastRunStartedAt = firstNonEmptyString([data.lastRunStartedAt]);
|
|
const lastRunCompletedAt = firstNonEmptyString([data.lastRunCompletedAt]);
|
|
const lastSuccessfulRunCompletedAt = firstNonEmptyString([
|
|
data.lastSuccessfulRunCompletedAt,
|
|
]);
|
|
const lastRunSince = firstNonEmptyString([data.lastRunSince]);
|
|
const lastRunMessage = firstNonEmptyString([data.lastRunMessage]);
|
|
const runCount = coerceFiniteNumber(data.lastRunCount) || 0;
|
|
const fulfilledCount = coerceFiniteNumber(data.lastRunFulfilledCount) || 0;
|
|
const skippedCount = coerceFiniteNumber(data.lastRunSkippedCount) || 0;
|
|
const errorCount = coerceFiniteNumber(data.lastRunErrorCount) || 0;
|
|
const processedCount = coerceFiniteNumber(data.processedCount) || 0;
|
|
const fulfilledStoredCount = coerceFiniteNumber(data.fulfilledCount) || 0;
|
|
const pendingStoredCount = coerceFiniteNumber(data.pendingCount) || 0;
|
|
const storedErrorCount = coerceFiniteNumber(data.errorCount) || 0;
|
|
const lastRunLevel = (() => {
|
|
const normalized = lastRunStatus.toLowerCase();
|
|
if (
|
|
normalized.indexOf("success") !== -1 ||
|
|
normalized.indexOf("ok") !== -1 ||
|
|
normalized.indexOf("complete") !== -1 ||
|
|
normalized.indexOf("fulfilled") !== -1
|
|
) {
|
|
return "good";
|
|
}
|
|
if (
|
|
normalized.indexOf("skip") !== -1 ||
|
|
normalized.indexOf("idle") !== -1 ||
|
|
normalized.indexOf("pending") !== -1
|
|
) {
|
|
return "partial";
|
|
}
|
|
return "issues";
|
|
})();
|
|
|
|
return {
|
|
summaryItems: [
|
|
{
|
|
label: "Automation",
|
|
value: enabled ? "Enabled" : "Disabled",
|
|
},
|
|
{
|
|
label: "Last Run",
|
|
value: lastRunCompletedAt
|
|
? formatDateTime(lastRunCompletedAt)
|
|
: lastRunStartedAt
|
|
? formatDateTime(lastRunStartedAt)
|
|
: "Never",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Last Successful Scan",
|
|
value: lastSuccessfulRunCompletedAt
|
|
? formatDateTime(lastSuccessfulRunCompletedAt)
|
|
: "Never",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Since",
|
|
value: lastRunSince ? formatDateTime(lastRunSince) : "Initial lookback",
|
|
},
|
|
{ label: "Run Status", value: lastRunStatus },
|
|
{ label: "Run Message", value: lastRunMessage || "No run recorded yet.", wide: true },
|
|
{ label: "Processed", value: String(runCount) },
|
|
{ label: "Fulfilled", value: String(fulfilledCount) },
|
|
{ label: "Skipped", value: String(skippedCount) },
|
|
{ label: "Errors", value: String(errorCount) },
|
|
{ label: "Stored Orders", value: String(processedCount) },
|
|
{ label: "Stored Fulfilled", value: String(fulfilledStoredCount) },
|
|
{ label: "Stored Pending", value: String(pendingStoredCount) },
|
|
{ label: "Stored Errors", value: String(storedErrorCount) },
|
|
],
|
|
statusItems: [
|
|
{
|
|
label: "Invite Fulfillment",
|
|
value: enabled ? "Enabled" : "Disabled",
|
|
level: enabled ? "good" : "partial",
|
|
},
|
|
{
|
|
label: "Last Run",
|
|
value: lastRunStatus,
|
|
level: lastRunLevel,
|
|
},
|
|
],
|
|
focusedData: {
|
|
enabled: enabled,
|
|
lastRunStartedAt: lastRunStartedAt,
|
|
lastRunCompletedAt: lastRunCompletedAt,
|
|
lastSuccessfulRunCompletedAt: lastSuccessfulRunCompletedAt,
|
|
lastRunSince: lastRunSince,
|
|
lastRunStatus: lastRunStatus,
|
|
lastRunMessage: lastRunMessage,
|
|
lastRunCount: runCount,
|
|
lastRunFulfilledCount: fulfilledCount,
|
|
lastRunSkippedCount: skippedCount,
|
|
lastRunErrorCount: errorCount,
|
|
processedCount: processedCount,
|
|
fulfilledCount: fulfilledStoredCount,
|
|
pendingCount: pendingStoredCount,
|
|
errorCount: storedErrorCount,
|
|
},
|
|
};
|
|
}
|
|
|
|
function splitAddonsByType(addons) {
|
|
const list = Array.isArray(addons) ? addons : [];
|
|
let monthlyCount = 0;
|
|
let oneTimeCount = 0;
|
|
list.forEach(function (entry) {
|
|
if (!entry || typeof entry !== "object") {
|
|
return;
|
|
}
|
|
if (isMonthlyAddonPreviewEntry(entry)) {
|
|
monthlyCount += 1;
|
|
return;
|
|
}
|
|
const purchaseType = cleanString(entry.purchaseType).toLowerCase();
|
|
const purchaseInterval = cleanString(
|
|
entry.purchaseInterval
|
|
).toLowerCase();
|
|
if (purchaseType === "one_time" || purchaseInterval === "one_time") {
|
|
oneTimeCount += 1;
|
|
return;
|
|
}
|
|
if (!purchaseType && !purchaseInterval) {
|
|
oneTimeCount += 1;
|
|
}
|
|
});
|
|
return {
|
|
monthlyCount: monthlyCount,
|
|
oneTimeCount: oneTimeCount,
|
|
};
|
|
}
|
|
|
|
function isMonthlyAddonPreviewEntry(entry) {
|
|
const purchaseType = trimString(entry && entry.purchaseType).toLowerCase();
|
|
const purchaseInterval = trimString(
|
|
entry && entry.purchaseInterval
|
|
).toLowerCase();
|
|
if (purchaseType === "monthly_addon" || purchaseInterval === "monthly") {
|
|
return true;
|
|
}
|
|
const code = trimString(
|
|
entry && (entry.code || entry.id || entry.productId)
|
|
).toLowerCase();
|
|
if (!code) {
|
|
return false;
|
|
}
|
|
return (
|
|
code.startsWith("capacity_") ||
|
|
code.startsWith("capacity-") ||
|
|
code.includes("capacity") ||
|
|
code.includes("storage") ||
|
|
code.includes("on-server capaticy") ||
|
|
code.includes("_monthly") ||
|
|
code.includes("-monthly")
|
|
);
|
|
}
|
|
|
|
function summarizeConnectorData(payloadData) {
|
|
const data = isRecord(payloadData) ? payloadData : {};
|
|
const source = firstObject([data.source]);
|
|
const details = firstObject([data.details]);
|
|
const verification = firstObject([data.verification]);
|
|
const entitlements = firstObject([data.entitlements]);
|
|
const catalog = firstObject([data.catalog, details.catalog]);
|
|
const packages = firstArray([
|
|
catalog.subscriptions,
|
|
catalog.packages,
|
|
data.subscriptions,
|
|
data.packages,
|
|
]);
|
|
const addons = firstArray([catalog.addons, data.addons]);
|
|
const extraOffers = firstArray([catalog.extraOffers, data.extraOffers]);
|
|
const oneTimeProducts = firstArray([catalog.oneTime, data.oneTime]);
|
|
const split = splitAddonsByType(addons);
|
|
const packageCount = packages.length;
|
|
const monthlyAddonCount = split.monthlyCount;
|
|
const oneTimeCount = split.oneTimeCount || oneTimeProducts.length;
|
|
const extraOffersCount = extraOffers.length;
|
|
const instanceId = firstNonEmptyString([
|
|
source.instanceId,
|
|
data.instanceId,
|
|
details.instanceId,
|
|
]);
|
|
const nextcloudPublicUrl = firstNonEmptyString([
|
|
source.nextcloudPublicUrl,
|
|
data.nextcloudPublicUrl,
|
|
details.nextcloudPublicUrl,
|
|
]);
|
|
const packageTier = firstNonEmptyString(
|
|
[entitlements.packageTier, details.packageTier, data.packageTier],
|
|
"Not set"
|
|
);
|
|
const customerId = firstNonEmptyString([
|
|
verification.paymenterCustomerId,
|
|
details.paymenterCustomerId,
|
|
data.paymenterCustomerId,
|
|
]);
|
|
const domainStatus = evaluateDomainStatus(
|
|
firstNonEmptyString([
|
|
verification.domainVerificationState,
|
|
details.domainVerificationState,
|
|
data.domainVerificationState,
|
|
])
|
|
);
|
|
const syncStatus = evaluateSyncStatus(
|
|
firstNonEmptyString([
|
|
details.lastSyncStatus,
|
|
source.lastSyncStatus,
|
|
data.lastSyncStatus,
|
|
])
|
|
);
|
|
const lastSyncAtRaw = firstNonEmptyString([
|
|
details.lastSyncAt,
|
|
source.lastSyncAt,
|
|
data.lastSyncAt,
|
|
data.fetchedAt,
|
|
]);
|
|
const summaryItems = [
|
|
{ label: "Instance ID", value: instanceId || "Missing" },
|
|
{
|
|
label: "Nextcloud URL",
|
|
value: nextcloudPublicUrl || "Missing",
|
|
wide: true,
|
|
},
|
|
{ label: "Package Tier", value: packageTier },
|
|
{ label: "Paymenter Customer", value: customerId || "Not linked" },
|
|
{ label: "Packages", value: String(packageCount) },
|
|
{ label: "Monthly Add-ons", value: String(monthlyAddonCount) },
|
|
{ label: "One-time Products", value: String(oneTimeCount) },
|
|
{ label: "Extra Offers", value: String(extraOffersCount) },
|
|
{
|
|
label: "Last Sync",
|
|
value: lastSyncAtRaw ? formatDateTime(lastSyncAtRaw) : "Not set",
|
|
},
|
|
];
|
|
const connectorLevel = instanceId && nextcloudPublicUrl ? "good" : "issues";
|
|
const customerLevel = customerId ? "good" : "partial";
|
|
const statusItems = [
|
|
{
|
|
label: "Connector",
|
|
value: connectorLevel === "good" ? "Configured" : "Config Missing",
|
|
level: connectorLevel,
|
|
},
|
|
{ label: "Domain", value: domainStatus.label, level: domainStatus.level },
|
|
{ label: "Sync", value: syncStatus.label, level: syncStatus.level },
|
|
{
|
|
label: "Billing Link",
|
|
value: customerId ? "Present" : "Missing",
|
|
level: customerLevel,
|
|
},
|
|
];
|
|
const focusedData = {
|
|
source: {
|
|
instanceId: instanceId,
|
|
nextcloudPublicUrl: nextcloudPublicUrl,
|
|
},
|
|
verification: {
|
|
paymenterCustomerId: customerId,
|
|
domainVerificationState: domainStatus.label,
|
|
},
|
|
entitlements: {
|
|
packageTier: packageTier,
|
|
creditsBalance: firstNonEmptyString([entitlements.creditsBalance]),
|
|
monthlyCredits: firstNonEmptyString([entitlements.monthlyCredits]),
|
|
},
|
|
catalogCounts: {
|
|
packages: packageCount,
|
|
monthlyAddons: monthlyAddonCount,
|
|
oneTimeProducts: oneTimeCount,
|
|
extraOffers: extraOffersCount,
|
|
},
|
|
lastSync: {
|
|
status: syncStatus.label,
|
|
at: lastSyncAtRaw,
|
|
},
|
|
};
|
|
return {
|
|
summaryItems: summaryItems,
|
|
statusItems: statusItems,
|
|
focusedData: focusedData,
|
|
};
|
|
}
|
|
|
|
function summarizeInstanceHealth(instance) {
|
|
const entry = isRecord(instance) ? instance : {};
|
|
const instanceId = firstNonEmptyString([entry.instanceId]);
|
|
const nextcloudPublicUrl = firstNonEmptyString([entry.nextcloudPublicUrl]);
|
|
const paymenterCustomerId = firstNonEmptyString([
|
|
entry.paymenterCustomerId,
|
|
]);
|
|
const paymenterName = firstNonEmptyString([
|
|
entry.paymenterDisplayName,
|
|
[
|
|
trimString(entry.paymenterFirstName),
|
|
trimString(entry.paymenterLastName),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
]);
|
|
const legacyAdminEmail = firstNonEmptyString([entry.adminEmail]);
|
|
const mspContactName = firstNonEmptyString([
|
|
entry.mspContactName,
|
|
paymenterName,
|
|
]);
|
|
const mspContactEmail = firstNonEmptyString([
|
|
entry.mspContactEmail,
|
|
legacyAdminEmail,
|
|
]);
|
|
const pluginAdminEmail = firstNonEmptyString([entry.pluginAdminEmail]);
|
|
const paymenterEmail = firstNonEmptyString([
|
|
entry.paymenterEmail,
|
|
entry.lastPaymenterResolvedEmail,
|
|
]);
|
|
const adminQortalAddress = firstNonEmptyString([entry.adminQortalAddress]);
|
|
const domainStatus = evaluateDomainStatus(
|
|
firstNonEmptyString([entry.domainVerificationState])
|
|
);
|
|
const syncStatus = evaluateSyncStatus(
|
|
firstNonEmptyString([entry.lastSyncStatus])
|
|
);
|
|
let level = "good";
|
|
if (!instanceId) {
|
|
level = combineStatusLevel(level, "issues");
|
|
}
|
|
if (!nextcloudPublicUrl) {
|
|
level = combineStatusLevel(level, "partial");
|
|
}
|
|
if (domainStatus.level === "issues") {
|
|
level = combineStatusLevel(level, "issues");
|
|
}
|
|
return {
|
|
level: level,
|
|
instanceId: instanceId,
|
|
nextcloudPublicUrl: nextcloudPublicUrl,
|
|
paymenterCustomerId: paymenterCustomerId,
|
|
mspContactName: mspContactName,
|
|
mspContactEmail: mspContactEmail,
|
|
legacyAdminEmail: legacyAdminEmail,
|
|
pluginAdminEmail: pluginAdminEmail,
|
|
paymenterEmail: paymenterEmail,
|
|
adminQortalAddress: adminQortalAddress,
|
|
domainStatus: domainStatus,
|
|
syncStatus: syncStatus,
|
|
lastSyncAt: firstNonEmptyString([entry.lastSyncAt]),
|
|
knownUsersCount: coerceFiniteNumber(entry.knownUsersCount),
|
|
lastCheckoutType: firstNonEmptyString([entry.lastCheckoutType]),
|
|
};
|
|
}
|
|
|
|
function buildInstanceFocusedData(entry) {
|
|
const health = summarizeInstanceHealth(entry);
|
|
return {
|
|
instanceId: health.instanceId,
|
|
nextcloudPublicUrl: health.nextcloudPublicUrl,
|
|
paymenterCustomerId: health.paymenterCustomerId,
|
|
mspContactName: health.mspContactName,
|
|
mspContactEmail: health.mspContactEmail,
|
|
legacyAdminEmail: health.legacyAdminEmail,
|
|
pluginAdminEmail: health.pluginAdminEmail,
|
|
paymenterEmail: health.paymenterEmail,
|
|
adminQortalAddress: health.adminQortalAddress,
|
|
domainVerificationState: health.domainStatus.label,
|
|
lastSyncStatus: health.syncStatus.label,
|
|
lastSyncAt: health.lastSyncAt,
|
|
knownUsersCount: health.knownUsersCount,
|
|
lastCheckoutType: health.lastCheckoutType,
|
|
billingSummary: firstObject([entry.lastPaymenterSyncSummary]),
|
|
overallStatus: levelTitle(health.level),
|
|
};
|
|
}
|
|
|
|
function renderInstanceCompactList(instances) {
|
|
if (!instancesCompactEl) {
|
|
return;
|
|
}
|
|
instancesCompactEl.innerHTML = "";
|
|
const list = (Array.isArray(instances) ? instances.slice() : []).sort(
|
|
function (a, b) {
|
|
const actionA = coerceFiniteNumber(a && a.actionRequiredCount) || 0;
|
|
const actionB = coerceFiniteNumber(b && b.actionRequiredCount) || 0;
|
|
if (actionA !== actionB) {
|
|
return actionB - actionA;
|
|
}
|
|
const grantsA = coerceFiniteNumber(a && a.grantRequestCount) || 0;
|
|
const grantsB = coerceFiniteNumber(b && b.grantRequestCount) || 0;
|
|
if (grantsA !== grantsB) {
|
|
return grantsB - grantsA;
|
|
}
|
|
const titleA = firstNonEmptyString(
|
|
[a && a.instanceId, a && a.nextcloudPublicUrl],
|
|
""
|
|
);
|
|
const titleB = firstNonEmptyString(
|
|
[b && b.instanceId, b && b.nextcloudPublicUrl],
|
|
""
|
|
);
|
|
return titleA.localeCompare(titleB);
|
|
}
|
|
);
|
|
if (!list.length) {
|
|
instancesCompactEl.textContent = "No connector instances returned.";
|
|
return;
|
|
}
|
|
list.forEach(function (entry) {
|
|
const health = summarizeInstanceHealth(entry);
|
|
const row = document.createElement("article");
|
|
row.className = "chd-instance-card";
|
|
|
|
const main = document.createElement("div");
|
|
main.className = "chd-instance-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-instance-title";
|
|
title.textContent = health.instanceId || "Unknown instance";
|
|
main.appendChild(title);
|
|
|
|
const chips = document.createElement("div");
|
|
chips.className = "chd-chip-row";
|
|
const chipData = [
|
|
{ label: "URL", value: health.nextcloudPublicUrl || "Missing" },
|
|
{ label: "Paymenter", value: health.paymenterCustomerId || "Missing" },
|
|
{
|
|
label: "Users",
|
|
value:
|
|
health.knownUsersCount === null
|
|
? "Unknown"
|
|
: String(health.knownUsersCount),
|
|
},
|
|
{ label: "Domain", value: health.domainStatus.label },
|
|
{ label: "Sync", value: health.syncStatus.label },
|
|
];
|
|
const actionRequiredCount =
|
|
coerceFiniteNumber(entry.actionRequiredCount) || 0;
|
|
const grantRequestCount =
|
|
coerceFiniteNumber(entry.grantRequestCount) || 0;
|
|
if (actionRequiredCount > 0) {
|
|
chipData.unshift({
|
|
label: "Needs Action",
|
|
value: String(actionRequiredCount),
|
|
});
|
|
}
|
|
if (grantRequestCount > 0) {
|
|
chipData.unshift({
|
|
label: "Grant Requests",
|
|
value: String(grantRequestCount),
|
|
});
|
|
}
|
|
chipData.forEach(function (chipEntry) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "chd-chip";
|
|
chip.textContent = chipEntry.label + ": " + chipEntry.value;
|
|
chips.appendChild(chip);
|
|
});
|
|
main.appendChild(chips);
|
|
row.appendChild(main);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-instance-actions";
|
|
if (actionRequiredCount > 0) {
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Action",
|
|
"Open Details To Act",
|
|
grantRequestCount > 0 ? "issues" : "partial"
|
|
)
|
|
);
|
|
}
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Overall Status",
|
|
levelTitle(health.level),
|
|
health.level
|
|
)
|
|
);
|
|
const allInfoButton = document.createElement("button");
|
|
allInfoButton.type = "button";
|
|
allInfoButton.className = "button";
|
|
allInfoButton.textContent =
|
|
actionRequiredCount > 0 ? "Action Required" : "All Info";
|
|
allInfoButton.classList.toggle(
|
|
"chd-button--action-required",
|
|
actionRequiredCount > 0
|
|
);
|
|
allInfoButton.disabled = !health.instanceId;
|
|
allInfoButton.addEventListener("click", function () {
|
|
if (!health.instanceId) {
|
|
return;
|
|
}
|
|
if (instanceSelectEl) {
|
|
instanceSelectEl.value = health.instanceId;
|
|
}
|
|
void loadConnectorInstanceDetail(health.instanceId, {
|
|
showRaw: true,
|
|
showAll: true,
|
|
});
|
|
});
|
|
actions.appendChild(allInfoButton);
|
|
row.appendChild(actions);
|
|
instancesCompactEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderRemovedInstanceList(instances) {
|
|
renderSummaryItems(
|
|
removedInstancesSummaryEl,
|
|
Array.isArray(instances) && instances.length
|
|
? [{ label: "Removed Instances", value: String(instances.length) }]
|
|
: [],
|
|
"No removed instances."
|
|
);
|
|
if (!removedInstancesListEl) {
|
|
return;
|
|
}
|
|
removedInstancesListEl.innerHTML = "";
|
|
const list = Array.isArray(instances) ? instances.slice() : [];
|
|
list.sort(function (a, b) {
|
|
const leftTs = Date.parse(trimString(a && a.removedAt)) || 0;
|
|
const rightTs = Date.parse(trimString(b && b.removedAt)) || 0;
|
|
return rightTs - leftTs;
|
|
});
|
|
if (!list.length) {
|
|
removedInstancesListEl.textContent = "No removed instances.";
|
|
return;
|
|
}
|
|
list.forEach(function (entry) {
|
|
const row = document.createElement("article");
|
|
row.className = "chd-instance-card chd-instance-card--removed";
|
|
|
|
const main = document.createElement("div");
|
|
main.className = "chd-instance-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-instance-title";
|
|
title.textContent =
|
|
trimString(entry && entry.instanceId) || "Unknown instance";
|
|
main.appendChild(title);
|
|
|
|
const chips = document.createElement("div");
|
|
chips.className = "chd-chip-row";
|
|
[
|
|
{
|
|
label: "URL",
|
|
value: trimString(entry && entry.nextcloudPublicUrl) || "Missing",
|
|
},
|
|
{
|
|
label: "Removed",
|
|
value: trimString(entry && entry.removedAt)
|
|
? formatDateTime(entry.removedAt)
|
|
: "Unknown",
|
|
},
|
|
{
|
|
label: "By",
|
|
value: trimString(entry && entry.removedBy) || "Not recorded",
|
|
},
|
|
{
|
|
label: "Reason",
|
|
value: trimString(entry && entry.removalReason) || "Not recorded",
|
|
},
|
|
].forEach(function (chipEntry) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "chd-chip";
|
|
chip.textContent = chipEntry.label + ": " + chipEntry.value;
|
|
chips.appendChild(chip);
|
|
});
|
|
main.appendChild(chips);
|
|
row.appendChild(main);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-instance-actions";
|
|
const restoreButton = document.createElement("button");
|
|
restoreButton.type = "button";
|
|
restoreButton.className = "button";
|
|
restoreButton.textContent = "Restore Instance";
|
|
restoreButton.disabled = trimString(entry && entry.instanceId) === "";
|
|
restoreButton.addEventListener("click", function () {
|
|
const instanceId = trimString(entry && entry.instanceId);
|
|
if (!instanceId) {
|
|
return;
|
|
}
|
|
if (
|
|
!window.confirm(
|
|
"Restore " + instanceId + " to the active connector configuration?"
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
void saveConnectorInstanceState(instanceId, "restore").catch(function (
|
|
error
|
|
) {
|
|
if (instanceConnectorStatusEl) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Failed to update instance state: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
});
|
|
actions.appendChild(restoreButton);
|
|
row.appendChild(actions);
|
|
removedInstancesListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function summarizeInstancesData(payloadData) {
|
|
const data = isRecord(payloadData) ? payloadData : {};
|
|
const paging = firstObject([data.paging]);
|
|
const instances = Array.isArray(data.instances) ? data.instances : [];
|
|
const statusCounts = {
|
|
good: 0,
|
|
partial: 0,
|
|
issues: 0,
|
|
};
|
|
instances.forEach(function (entry) {
|
|
const level = summarizeInstanceHealth(entry).level;
|
|
statusCounts[level] += 1;
|
|
});
|
|
const actionableInstances = instances.filter(function (entry) {
|
|
return (coerceFiniteNumber(entry && entry.actionRequiredCount) || 0) > 0;
|
|
}).length;
|
|
const totalActionRequired = instances.reduce(function (sum, entry) {
|
|
return (
|
|
sum + (coerceFiniteNumber(entry && entry.actionRequiredCount) || 0)
|
|
);
|
|
}, 0);
|
|
const totalGrantRequests = instances.reduce(function (sum, entry) {
|
|
return sum + (coerceFiniteNumber(entry && entry.grantRequestCount) || 0);
|
|
}, 0);
|
|
const summaryItems = [
|
|
{ label: "Instances Loaded", value: String(instances.length) },
|
|
{ label: "Instances With Action", value: String(actionableInstances) },
|
|
{ label: "Pending Actions", value: String(totalActionRequired) },
|
|
{ label: "Grant Requests", value: String(totalGrantRequests) },
|
|
{ label: "Good", value: String(statusCounts.good) },
|
|
{ label: "Partially Good", value: String(statusCounts.partial) },
|
|
{ label: "Issues", value: String(statusCounts.issues) },
|
|
];
|
|
if (coerceFiniteNumber(paging.total) !== null) {
|
|
summaryItems.push({
|
|
label: "Total (Connector)",
|
|
value: String(coerceFiniteNumber(paging.total)),
|
|
});
|
|
}
|
|
const focusedData = {
|
|
counts: statusCounts,
|
|
instances: instances.map(buildInstanceFocusedData),
|
|
paging: paging,
|
|
};
|
|
return {
|
|
instances: instances,
|
|
summaryItems: summaryItems,
|
|
focusedData: focusedData,
|
|
};
|
|
}
|
|
|
|
function resolveDetailInstance(detailData) {
|
|
const data = isRecord(detailData) ? detailData : {};
|
|
const topLevelInstance = firstObject([data.instance]);
|
|
const summaryInstance = firstObject([
|
|
data.summary && data.summary.instance ? data.summary.instance : null,
|
|
]);
|
|
const nestedInstance = Object.assign({}, topLevelInstance, summaryInstance);
|
|
if (Object.keys(nestedInstance).length > 0) {
|
|
return nestedInstance;
|
|
}
|
|
if (cleanString(data.instanceId) !== "") {
|
|
return data;
|
|
}
|
|
const nestedData = firstObject([data.data]);
|
|
if (cleanString(nestedData.instanceId) !== "") {
|
|
return nestedData;
|
|
}
|
|
const deepInstance = firstObject([nestedData.instance]);
|
|
return deepInstance;
|
|
}
|
|
|
|
function summarizeInstanceDetail(detailData, fallbackInstanceId) {
|
|
const instance = resolveDetailInstance(detailData);
|
|
const health = summarizeInstanceHealth(instance);
|
|
const instanceId = health.instanceId || cleanString(fallbackInstanceId);
|
|
const knownUsers = collectKnownUsers(detailData);
|
|
const userCommerceRows = collectUserCommerceRows(detailData, knownUsers);
|
|
const linkedUserCount = userCommerceRows.filter(function (row) {
|
|
return cleanString(row && row.paymenterCustomerId);
|
|
}).length;
|
|
const unlinkedUserCount = Math.max(0, userCommerceRows.length - linkedUserCount);
|
|
const operationsCandidates = detectOperationsFromInstanceDetail(
|
|
instanceId,
|
|
detailData
|
|
);
|
|
const billing = summarizeBillingHealth(instance.lastPaymenterSyncSummary, {
|
|
services: coerceFiniteNumber(instance.lastPaymenterSyncSummary && instance.lastPaymenterSyncSummary.services),
|
|
invoices: coerceFiniteNumber(instance.lastPaymenterSyncSummary && instance.lastPaymenterSyncSummary.invoices),
|
|
});
|
|
const summaryItems = [
|
|
{ label: "Instance ID", value: instanceId || "Missing" },
|
|
{
|
|
label: "Nextcloud URL",
|
|
value: health.nextcloudPublicUrl || "Missing",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Paymenter Customer",
|
|
value: health.paymenterCustomerId || "Missing",
|
|
},
|
|
{
|
|
label: "NuQloud MSP Contact Name",
|
|
value: health.mspContactName || "Missing",
|
|
},
|
|
{
|
|
label: "NuQloud MSP Contact Email",
|
|
value: health.mspContactEmail || "Missing",
|
|
},
|
|
{ label: "Paymenter Email", value: health.paymenterEmail || "Missing" },
|
|
{
|
|
label: "Active Package",
|
|
value: firstNonEmptyString(
|
|
[billing.activePackageTier, billing.activeServiceLabel],
|
|
"Not set"
|
|
),
|
|
},
|
|
{
|
|
label: "Active Services",
|
|
value: String(billing.activeServices),
|
|
},
|
|
{
|
|
label: "Suspended Services",
|
|
value: String(billing.suspendedServices),
|
|
},
|
|
{
|
|
label: "Cancelled Services",
|
|
value: String(billing.cancelledServices),
|
|
},
|
|
{
|
|
label: "Outstanding Invoices",
|
|
value: String(billing.outstandingInvoices),
|
|
},
|
|
{
|
|
label: "Overdue Invoices",
|
|
value: String(billing.overdueInvoices),
|
|
},
|
|
{
|
|
label: "Billing Health",
|
|
value: billing.billingHealthLabel,
|
|
},
|
|
{
|
|
label: "Attention",
|
|
value: billing.attentionText || "None",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Future Plugin Admin Email",
|
|
value: health.pluginAdminEmail || "Not set",
|
|
},
|
|
{
|
|
label: "Future Plugin Admin Qortal Address",
|
|
value: health.adminQortalAddress || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Connector Target Profile",
|
|
value: cleanString(instance.desiredConnectorProfile) || "Not set",
|
|
},
|
|
{
|
|
label: "Connector Target URL",
|
|
value: cleanString(instance.desiredConnectorBaseUrl) || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Current Connector URL",
|
|
value: cleanString(instance.currentConnectorBaseUrl) || "Not set",
|
|
wide: true,
|
|
},
|
|
{ label: "Domain Verification", value: health.domainStatus.label },
|
|
{
|
|
label: "Last Sync",
|
|
value: health.lastSyncAt
|
|
? formatDateTime(health.lastSyncAt)
|
|
: "Not set",
|
|
},
|
|
{
|
|
label: "Known Users",
|
|
value:
|
|
health.knownUsersCount === null
|
|
? "Unknown"
|
|
: String(health.knownUsersCount),
|
|
},
|
|
{
|
|
label: "Paymenter Linked Users",
|
|
value: String(linkedUserCount),
|
|
},
|
|
{
|
|
label: "Paymenter Unlinked Users",
|
|
value: String(unlinkedUserCount),
|
|
},
|
|
{
|
|
label: "Last Checkout Type",
|
|
value: health.lastCheckoutType || "Not set",
|
|
},
|
|
];
|
|
if (
|
|
health.legacyAdminEmail &&
|
|
health.legacyAdminEmail !== health.mspContactEmail
|
|
) {
|
|
summaryItems.splice(5, 0, {
|
|
label: "Legacy Compatibility Email",
|
|
value: health.legacyAdminEmail,
|
|
});
|
|
}
|
|
const statusItems = [
|
|
{
|
|
label: "Overall Status",
|
|
value: levelTitle(health.level),
|
|
level: health.level,
|
|
},
|
|
{
|
|
label: "Domain",
|
|
value: health.domainStatus.label,
|
|
level: health.domainStatus.level,
|
|
},
|
|
{
|
|
label: "Sync",
|
|
value: health.syncStatus.label,
|
|
level: health.syncStatus.level,
|
|
},
|
|
{
|
|
label: "Billing",
|
|
value: billing.billingHealthLabel,
|
|
level: billing.billingHealthLevel,
|
|
},
|
|
{
|
|
label: "Services",
|
|
value: billing.serviceHealthLabel,
|
|
level: billing.serviceHealthLevel,
|
|
},
|
|
{
|
|
label: "Invoices",
|
|
value: billing.invoiceHealthLabel,
|
|
level: billing.invoiceHealthLevel,
|
|
},
|
|
{
|
|
label: "Billing Link",
|
|
value: health.paymenterCustomerId ? "Present" : "Missing",
|
|
level: health.paymenterCustomerId ? "good" : "partial",
|
|
},
|
|
];
|
|
const focusedData = {
|
|
instance: buildInstanceFocusedData(instance),
|
|
verification: firstObject([detailData && detailData.verification]),
|
|
summary: firstObject([detailData && detailData.summary]),
|
|
billing: billing,
|
|
knownUsers: knownUsers.map(function (entry) {
|
|
return {
|
|
nextcloudUserId: entry.nextcloudUserId,
|
|
displayName: entry.displayName,
|
|
email: entry.email,
|
|
qortalAddress: entry.qortalAddress,
|
|
paymenterCustomerId: entry.paymenterCustomerId,
|
|
};
|
|
}),
|
|
userCommerce: userCommerceRows,
|
|
operationsCandidates: operationsCandidates,
|
|
};
|
|
return {
|
|
summaryItems: summaryItems,
|
|
statusItems: statusItems,
|
|
focusedData: focusedData,
|
|
knownUsers: knownUsers,
|
|
userCommerceRows: userCommerceRows,
|
|
operationsCandidates: operationsCandidates,
|
|
};
|
|
}
|
|
|
|
function populateInstanceConnectorControls(instance) {
|
|
const source = isRecord(instance) ? instance : {};
|
|
const desiredProfile = trimString(source.desiredConnectorProfile);
|
|
const desiredBaseUrl = trimString(source.desiredConnectorBaseUrl);
|
|
const fallbackProfile = findSelectableConnectorProfile(desiredProfile);
|
|
const fallbackBaseUrl = trimString(
|
|
fallbackProfile && fallbackProfile.publicBaseUrl
|
|
);
|
|
const currentBaseUrl = trimString(source.currentConnectorBaseUrl);
|
|
if (instanceConnectorProfileEl) {
|
|
const desiredExists = Array.from(instanceConnectorProfileEl.options).some(
|
|
function (option) {
|
|
return trimString(option.value) === desiredProfile;
|
|
}
|
|
);
|
|
instanceConnectorProfileEl.value = desiredExists ? desiredProfile : "";
|
|
}
|
|
if (instanceConnectorUrlEl) {
|
|
instanceConnectorUrlEl.value = desiredBaseUrl || fallbackBaseUrl;
|
|
}
|
|
if (instanceRemoveButton) {
|
|
instanceRemoveButton.disabled = !activeInstanceId;
|
|
}
|
|
if (instanceConnectorStatusEl) {
|
|
if (!activeInstanceId) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Load an instance to manage connector target.";
|
|
} else if (desiredProfile || desiredBaseUrl) {
|
|
let text = "Pending target for next sync/register";
|
|
if (desiredProfile) {
|
|
text += " | Profile: " + desiredProfile;
|
|
}
|
|
if (desiredBaseUrl) {
|
|
text += " | URL: " + desiredBaseUrl;
|
|
}
|
|
if (currentBaseUrl) {
|
|
text += " | Current: " + currentBaseUrl;
|
|
}
|
|
instanceConnectorStatusEl.textContent = text;
|
|
} else {
|
|
instanceConnectorStatusEl.textContent = currentBaseUrl
|
|
? "No connector target override set. Current connector: " +
|
|
currentBaseUrl
|
|
: "No connector target override set.";
|
|
}
|
|
}
|
|
}
|
|
|
|
function populateInstanceMetadataControls(instance) {
|
|
const source = isRecord(instance) ? instance : {};
|
|
const paymenterDerivedName = firstNonEmptyString([
|
|
source.paymenterDisplayName,
|
|
[
|
|
trimString(source.paymenterFirstName),
|
|
trimString(source.paymenterLastName),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" "),
|
|
]);
|
|
if (instanceMspContactNameEl) {
|
|
instanceMspContactNameEl.value = trimString(
|
|
source.mspContactName || paymenterDerivedName
|
|
);
|
|
}
|
|
if (instanceMspContactEmailEl) {
|
|
instanceMspContactEmailEl.value = trimString(
|
|
source.mspContactEmail || source.adminEmail
|
|
);
|
|
}
|
|
if (instancePluginAdminEmailEl) {
|
|
instancePluginAdminEmailEl.value = trimString(source.pluginAdminEmail);
|
|
}
|
|
if (instanceAdminQortalAddressEl) {
|
|
instanceAdminQortalAddressEl.value = trimString(
|
|
source.adminQortalAddress
|
|
);
|
|
}
|
|
if (instanceMetadataSaveButton) {
|
|
instanceMetadataSaveButton.disabled = !activeInstanceId;
|
|
}
|
|
if (instanceMetadataStatusEl) {
|
|
if (!activeInstanceId) {
|
|
instanceMetadataStatusEl.textContent =
|
|
"Load an instance to manage NuQloud MSP contact fields.";
|
|
return;
|
|
}
|
|
const contactEmail = trimString(
|
|
source.mspContactEmail || source.adminEmail
|
|
);
|
|
const pluginEmail = trimString(source.pluginAdminEmail);
|
|
const paymenterEmail = trimString(
|
|
source.paymenterEmail || source.lastPaymenterResolvedEmail
|
|
);
|
|
let text = "NuQloud MSP fields loaded";
|
|
if (contactEmail) {
|
|
text += " | MSP email: " + contactEmail;
|
|
}
|
|
if (paymenterEmail) {
|
|
text += " | Paymenter email: " + paymenterEmail;
|
|
}
|
|
if (pluginEmail) {
|
|
text += " | Plugin admin email: " + pluginEmail;
|
|
}
|
|
instanceMetadataStatusEl.textContent = text;
|
|
}
|
|
}
|
|
|
|
function populateOperationsUserSelect(knownUsers) {
|
|
if (!operationsUserSelectEl) {
|
|
return;
|
|
}
|
|
const users = Array.isArray(knownUsers) ? knownUsers : [];
|
|
operationsUserSelectEl.innerHTML = "";
|
|
const instanceOption = document.createElement("option");
|
|
instanceOption.value = "";
|
|
instanceOption.textContent = "Instance-level task";
|
|
operationsUserSelectEl.appendChild(instanceOption);
|
|
users.forEach(function (entry, index) {
|
|
const option = document.createElement("option");
|
|
option.value = "user:" + String(index);
|
|
const name = firstNonEmptyString(
|
|
[entry.displayName, entry.nextcloudUserId],
|
|
"User"
|
|
);
|
|
const customerId = firstNonEmptyString([entry.paymenterCustomerId], "");
|
|
option.textContent = customerId
|
|
? name + " (Paymenter #" + customerId + ")"
|
|
: name;
|
|
option.dataset.nextcloudUserId = firstNonEmptyString(
|
|
[entry.nextcloudUserId],
|
|
""
|
|
);
|
|
option.dataset.paymenterCustomerId = firstNonEmptyString(
|
|
[entry.paymenterCustomerId],
|
|
""
|
|
);
|
|
option.dataset.userLabel = firstNonEmptyString(
|
|
[entry.displayName, entry.nextcloudUserId],
|
|
""
|
|
);
|
|
operationsUserSelectEl.appendChild(option);
|
|
});
|
|
}
|
|
|
|
function mergeInstanceOperations(persistedTasks, detectedTasks) {
|
|
const persisted = Array.isArray(persistedTasks) ? persistedTasks : [];
|
|
const detected = Array.isArray(detectedTasks) ? detectedTasks : [];
|
|
const merged = [];
|
|
const persistedByDetectionKey = {};
|
|
persisted.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const task = Object.assign({}, entry);
|
|
task.status = normalizeOperationStatus(task.status);
|
|
if (cleanString(task.detectionKey)) {
|
|
persistedByDetectionKey[cleanString(task.detectionKey)] = task;
|
|
}
|
|
merged.push(task);
|
|
});
|
|
detected.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const detectionKey = cleanString(entry.detectionKey);
|
|
if (!detectionKey) {
|
|
return;
|
|
}
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(
|
|
persistedByDetectionKey,
|
|
detectionKey
|
|
)
|
|
) {
|
|
const persistedTask = persistedByDetectionKey[detectionKey];
|
|
persistedTask.title =
|
|
cleanString(persistedTask.title) || cleanString(entry.title);
|
|
persistedTask.taskType =
|
|
cleanString(persistedTask.taskType) || cleanString(entry.taskType);
|
|
persistedTask.nextcloudUserId =
|
|
cleanString(persistedTask.nextcloudUserId) ||
|
|
cleanString(entry.nextcloudUserId);
|
|
persistedTask.paymenterCustomerId =
|
|
cleanString(persistedTask.paymenterCustomerId) ||
|
|
cleanString(entry.paymenterCustomerId);
|
|
persistedTask.userLabel =
|
|
cleanString(persistedTask.userLabel) || cleanString(entry.userLabel);
|
|
persistedTask.orderRef =
|
|
cleanString(persistedTask.orderRef) || cleanString(entry.orderRef);
|
|
persistedTask.productCode =
|
|
cleanString(persistedTask.productCode) ||
|
|
cleanString(entry.productCode);
|
|
persistedTask.quantity =
|
|
cleanString(persistedTask.quantity) || cleanString(entry.quantity);
|
|
if (
|
|
!cleanString(persistedTask.createdAt) &&
|
|
cleanString(entry.createdAt)
|
|
) {
|
|
persistedTask.createdAt = cleanString(entry.createdAt);
|
|
}
|
|
if (
|
|
!cleanString(persistedTask.updatedAt) &&
|
|
cleanString(entry.updatedAt)
|
|
) {
|
|
persistedTask.updatedAt = cleanString(entry.updatedAt);
|
|
}
|
|
if (
|
|
(!persistedTask.meta || !Object.keys(persistedTask.meta).length) &&
|
|
isRecord(entry.meta)
|
|
) {
|
|
persistedTask.meta = entry.meta;
|
|
}
|
|
return;
|
|
}
|
|
const detectedTask = Object.assign({}, entry);
|
|
detectedTask.id = "";
|
|
detectedTask.status = normalizeOperationStatus(detectedTask.status);
|
|
merged.push(detectedTask);
|
|
});
|
|
merged.sort(operationTaskSortComparator);
|
|
return merged;
|
|
}
|
|
|
|
function renderUserCommerceSummary(rows) {
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
const usersWithItems = list.filter(function (row) {
|
|
return coerceFiniteNumber(row.totalItems) > 0;
|
|
}).length;
|
|
const linkedUsers = list.filter(function (row) {
|
|
return cleanString(row && row.paymenterCustomerId);
|
|
}).length;
|
|
let pending = 0;
|
|
let inProgress = 0;
|
|
let blocked = 0;
|
|
let completed = 0;
|
|
list.forEach(function (row) {
|
|
pending += coerceFiniteNumber(row.pendingCount) || 0;
|
|
inProgress += coerceFiniteNumber(row.inProgressCount) || 0;
|
|
blocked += coerceFiniteNumber(row.blockedCount) || 0;
|
|
completed += coerceFiniteNumber(row.completedCount) || 0;
|
|
});
|
|
renderSummaryItems(
|
|
userOrdersSummaryEl,
|
|
[
|
|
{ label: "Known Users", value: String(list.length) },
|
|
{ label: "Users With Order Data", value: String(usersWithItems) },
|
|
{ label: "Paymenter Linked Users", value: String(linkedUsers) },
|
|
{
|
|
label: "Paymenter Unlinked Users",
|
|
value: String(Math.max(0, list.length - linkedUsers)),
|
|
},
|
|
{ label: "Pending Items", value: String(pending) },
|
|
{ label: "In Progress Items", value: String(inProgress) },
|
|
{ label: "Blocked Items", value: String(blocked) },
|
|
{ label: "Completed Items", value: String(completed) },
|
|
],
|
|
"No reconciliation data available for this instance."
|
|
);
|
|
}
|
|
|
|
function renderUserCommerceList(rows) {
|
|
if (!userOrdersListEl) {
|
|
return;
|
|
}
|
|
userOrdersListEl.innerHTML = "";
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
if (!list.length) {
|
|
userOrdersListEl.textContent = "No instance users returned.";
|
|
return;
|
|
}
|
|
list.forEach(function (row) {
|
|
if (!isRecord(row)) {
|
|
return;
|
|
}
|
|
const card = document.createElement("article");
|
|
card.className = "chd-ops-item";
|
|
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[row.displayName, row.nextcloudUserId],
|
|
"Unknown user"
|
|
);
|
|
main.appendChild(title);
|
|
|
|
const identityParts = [];
|
|
if (cleanString(row.nextcloudUserId)) {
|
|
identityParts.push("Nextcloud: " + cleanString(row.nextcloudUserId));
|
|
}
|
|
if (cleanString(row.paymenterCustomerId)) {
|
|
identityParts.push(
|
|
"Paymenter: #" + cleanString(row.paymenterCustomerId)
|
|
);
|
|
}
|
|
if (cleanString(row.email)) {
|
|
identityParts.push("Email: " + cleanString(row.email));
|
|
}
|
|
if (cleanString(row.qortalAddress)) {
|
|
identityParts.push("Qortal: " + cleanString(row.qortalAddress));
|
|
}
|
|
if (identityParts.length > 0) {
|
|
const identity = document.createElement("div");
|
|
identity.className = "chd-ops-meta";
|
|
identity.textContent = identityParts.join(" | ");
|
|
main.appendChild(identity);
|
|
}
|
|
|
|
const totalItems = coerceFiniteNumber(row.totalItems) || 0;
|
|
const countsMeta = document.createElement("div");
|
|
countsMeta.className = "chd-ops-meta";
|
|
countsMeta.textContent =
|
|
"Items: " +
|
|
String(totalItems) +
|
|
" | Pending: " +
|
|
String(coerceFiniteNumber(row.pendingCount) || 0) +
|
|
" | In Progress: " +
|
|
String(coerceFiniteNumber(row.inProgressCount) || 0) +
|
|
" | Blocked: " +
|
|
String(coerceFiniteNumber(row.blockedCount) || 0) +
|
|
" | Completed: " +
|
|
String(coerceFiniteNumber(row.completedCount) || 0);
|
|
main.appendChild(countsMeta);
|
|
|
|
const recentCodes = Array.isArray(row.recentCodes)
|
|
? row.recentCodes.filter(Boolean)
|
|
: [];
|
|
if (recentCodes.length > 0) {
|
|
const recent = document.createElement("div");
|
|
recent.className = "chd-ops-meta";
|
|
recent.textContent = "Recent Codes: " + recentCodes.join(", ");
|
|
main.appendChild(recent);
|
|
}
|
|
card.appendChild(main);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
let level = "partial";
|
|
let label = "No order data";
|
|
if (totalItems > 0) {
|
|
label = "Order data present";
|
|
level = "good";
|
|
}
|
|
if ((coerceFiniteNumber(row.blockedCount) || 0) > 0) {
|
|
label = "Blocked items present";
|
|
level = "issues";
|
|
}
|
|
actions.appendChild(buildStatusPill("Data", label, level));
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Paymenter",
|
|
cleanString(row.paymenterCustomerId) ? "Linked" : "Unlinked",
|
|
cleanString(row.paymenterCustomerId) ? "good" : "partial"
|
|
)
|
|
);
|
|
card.appendChild(actions);
|
|
userOrdersListEl.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderUserCommerceRows(rows) {
|
|
renderUserCommerceSummary(rows);
|
|
renderUserCommerceList(rows);
|
|
}
|
|
|
|
function normalizeIdentityIndexKey(entry) {
|
|
const record = isRecord(entry) ? entry : {};
|
|
const qortalAddress = cleanString(record.qortalAddress).toLowerCase();
|
|
if (qortalAddress) {
|
|
return "qortal:" + qortalAddress;
|
|
}
|
|
const paymenterCustomerId = cleanString(
|
|
record.paymenterCustomerId
|
|
).toLowerCase();
|
|
if (paymenterCustomerId) {
|
|
return "paymenter:" + paymenterCustomerId;
|
|
}
|
|
const email = cleanString(record.email).toLowerCase();
|
|
if (email) {
|
|
return "email:" + email;
|
|
}
|
|
const nextcloudUserId = cleanString(record.nextcloudUserId).toLowerCase();
|
|
if (nextcloudUserId) {
|
|
return "nextcloud:" + nextcloudUserId;
|
|
}
|
|
const displayName = cleanString(record.displayName).toLowerCase();
|
|
if (displayName) {
|
|
return "display:" + displayName;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function identityIndexMatchesPaymenterClient(row, client) {
|
|
const rowPaymenterCustomerId = cleanString(row && row.paymenterCustomerId).toLowerCase();
|
|
const clientPaymenterCustomerId = cleanString(
|
|
(client && client.paymenterCustomerId) ||
|
|
(client && client.customerId) ||
|
|
(client && client.id)
|
|
).toLowerCase();
|
|
if (
|
|
rowPaymenterCustomerId &&
|
|
clientPaymenterCustomerId &&
|
|
rowPaymenterCustomerId === clientPaymenterCustomerId
|
|
) {
|
|
return true;
|
|
}
|
|
const rowEmail = cleanString(row && row.email).toLowerCase();
|
|
const clientEmail = cleanString(client && client.email).toLowerCase();
|
|
if (rowEmail && clientEmail && rowEmail === clientEmail) {
|
|
return true;
|
|
}
|
|
const rowNextcloudUserId = cleanString(row && row.nextcloudUserId).toLowerCase();
|
|
const clientUsername = cleanString(client && client.username).toLowerCase();
|
|
if (rowNextcloudUserId && clientUsername && rowNextcloudUserId === clientUsername) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function upsertIdentityIndexEntry(rowsByKey, entry, instanceId) {
|
|
if (!isRecord(rowsByKey)) {
|
|
return null;
|
|
}
|
|
const record = isRecord(entry) ? entry : {};
|
|
const identityKey = normalizeIdentityIndexKey(record);
|
|
if (!identityKey) {
|
|
return null;
|
|
}
|
|
const instanceLabel = cleanString(instanceId);
|
|
let row = Object.prototype.hasOwnProperty.call(rowsByKey, identityKey)
|
|
? rowsByKey[identityKey]
|
|
: null;
|
|
const displayName = firstNonEmptyString(
|
|
[
|
|
record.displayName,
|
|
record.nextcloudUserId,
|
|
record.email,
|
|
record.qortalAddress,
|
|
record.paymenterCustomerId,
|
|
],
|
|
"Unknown identity"
|
|
);
|
|
if (!row) {
|
|
row = {
|
|
identityKey: identityKey,
|
|
displayName: displayName,
|
|
nextcloudUserId: cleanString(record.nextcloudUserId),
|
|
email: cleanString(record.email),
|
|
qortalAddress: cleanString(record.qortalAddress),
|
|
paymenterCustomerId: cleanString(record.paymenterCustomerId),
|
|
instanceLabels: [],
|
|
instanceLabelsByKey: {},
|
|
instanceCount: 0,
|
|
};
|
|
rowsByKey[identityKey] = row;
|
|
} else {
|
|
row.displayName = firstNonEmptyString(
|
|
[row.displayName, displayName],
|
|
displayName
|
|
);
|
|
row.nextcloudUserId = firstNonEmptyString(
|
|
[row.nextcloudUserId, record.nextcloudUserId],
|
|
""
|
|
);
|
|
row.email = firstNonEmptyString([row.email, record.email], "");
|
|
row.qortalAddress = firstNonEmptyString(
|
|
[row.qortalAddress, record.qortalAddress],
|
|
""
|
|
);
|
|
row.paymenterCustomerId = firstNonEmptyString(
|
|
[row.paymenterCustomerId, record.paymenterCustomerId],
|
|
""
|
|
);
|
|
}
|
|
if (instanceLabel && !row.instanceLabelsByKey[instanceLabel]) {
|
|
row.instanceLabelsByKey[instanceLabel] = true;
|
|
row.instanceLabels.push(instanceLabel);
|
|
}
|
|
row.instanceCount = row.instanceLabels.length;
|
|
return row;
|
|
}
|
|
|
|
function renderIdentityIndex(rows) {
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
const paymenterClients = Array.isArray(paymenterClientsCache)
|
|
? paymenterClientsCache
|
|
: [];
|
|
const linkedIdentityCount = list.filter(function (row) {
|
|
return paymenterClients.some(function (client) {
|
|
return identityIndexMatchesPaymenterClient(row, client);
|
|
});
|
|
}).length;
|
|
const multiInstanceCount = list.filter(function (row) {
|
|
return coerceFiniteNumber(row && row.instanceCount) > 1;
|
|
}).length;
|
|
const singleInstanceCount = list.filter(function (row) {
|
|
return coerceFiniteNumber(row && row.instanceCount) === 1;
|
|
}).length;
|
|
const matchedPaymenterCount = paymenterClients.filter(function (client) {
|
|
return list.some(function (row) {
|
|
return identityIndexMatchesPaymenterClient(row, client);
|
|
});
|
|
}).length;
|
|
const unmatchedPaymenterCount = Math.max(
|
|
0,
|
|
paymenterClients.length - matchedPaymenterCount
|
|
);
|
|
renderSummaryItems(
|
|
identityIndexSummaryEl,
|
|
[
|
|
{ label: "Identities", value: String(list.length) },
|
|
{ label: "Single Instance", value: String(singleInstanceCount) },
|
|
{ label: "Multi Instance", value: String(multiInstanceCount) },
|
|
{ label: "Linked Identities", value: String(linkedIdentityCount) },
|
|
{
|
|
label: "Paymenter Customers",
|
|
value: String(paymenterClients.length),
|
|
},
|
|
{
|
|
label: "Paymenter Matched",
|
|
value: String(matchedPaymenterCount),
|
|
},
|
|
{
|
|
label: "Paymenter Unmatched",
|
|
value: String(unmatchedPaymenterCount),
|
|
},
|
|
],
|
|
"Refresh instances to build the identity index."
|
|
);
|
|
if (!identityIndexListEl) {
|
|
return;
|
|
}
|
|
identityIndexListEl.innerHTML = "";
|
|
if (!list.length) {
|
|
identityIndexListEl.textContent =
|
|
"Refresh instances to build the identity index.";
|
|
return;
|
|
}
|
|
list.forEach(function (row) {
|
|
if (!isRecord(row)) {
|
|
return;
|
|
}
|
|
const entry = row;
|
|
const card = document.createElement("article");
|
|
card.className = "chd-ops-item";
|
|
if (coerceFiniteNumber(entry.instanceCount) > 1) {
|
|
card.classList.add("chd-ops-item--attention");
|
|
}
|
|
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[
|
|
entry.displayName,
|
|
entry.nextcloudUserId,
|
|
entry.email,
|
|
entry.qortalAddress,
|
|
],
|
|
"Unknown identity"
|
|
);
|
|
main.appendChild(title);
|
|
|
|
const metaParts = [];
|
|
if (cleanString(entry.nextcloudUserId)) {
|
|
metaParts.push("Nextcloud: " + cleanString(entry.nextcloudUserId));
|
|
}
|
|
if (cleanString(entry.email)) {
|
|
metaParts.push("Email: " + cleanString(entry.email));
|
|
}
|
|
if (cleanString(entry.qortalAddress)) {
|
|
metaParts.push("Qortal: " + cleanString(entry.qortalAddress));
|
|
}
|
|
if (cleanString(entry.paymenterCustomerId)) {
|
|
metaParts.push(
|
|
"Paymenter: #" + cleanString(entry.paymenterCustomerId)
|
|
);
|
|
}
|
|
const instanceLabels = Array.isArray(entry.instanceLabels)
|
|
? entry.instanceLabels.filter(Boolean)
|
|
: [];
|
|
if (instanceLabels.length) {
|
|
metaParts.push("Instances: " + instanceLabels.join(", "));
|
|
}
|
|
if (metaParts.length) {
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = metaParts.join(" | ");
|
|
main.appendChild(meta);
|
|
}
|
|
card.appendChild(main);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Scope",
|
|
coerceFiniteNumber(entry.instanceCount) > 1
|
|
? "Multiple Instances"
|
|
: "Single Instance",
|
|
coerceFiniteNumber(entry.instanceCount) > 1 ? "partial" : "good"
|
|
)
|
|
);
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Paymenter",
|
|
paymenterClients.some(function (client) {
|
|
return identityIndexMatchesPaymenterClient(entry, client);
|
|
})
|
|
? "Linked"
|
|
: "Unlinked",
|
|
paymenterClients.some(function (client) {
|
|
return identityIndexMatchesPaymenterClient(entry, client);
|
|
})
|
|
? "good"
|
|
: "partial"
|
|
)
|
|
);
|
|
card.appendChild(actions);
|
|
identityIndexListEl.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function setPaymenterStatus(message) {
|
|
if (paymenterStatusEl) {
|
|
paymenterStatusEl.textContent = cleanString(message) || "Ready.";
|
|
}
|
|
}
|
|
|
|
function renderPaymenterClients(data) {
|
|
const source = isRecord(data) ? data : {};
|
|
const users = Array.isArray(source.users) ? source.users : [];
|
|
paymenterClientsCache = users.slice();
|
|
const paging = firstObject([source.paging]);
|
|
const runtime = firstObject([source.runtime]);
|
|
const matchedCount = users.filter(function (entry) {
|
|
return cleanString(entry && entry.matchedInstanceId);
|
|
}).length;
|
|
renderSummaryItems(
|
|
paymenterClientsSummaryEl,
|
|
[
|
|
{ label: "Customers Loaded", value: String(users.length) },
|
|
{ label: "Matched To Instances", value: String(matchedCount) },
|
|
{
|
|
label: "Effective Backend",
|
|
value:
|
|
cleanString(runtime.effectivePaymenterProfile) ||
|
|
"connector default",
|
|
},
|
|
{
|
|
label: "Base URL",
|
|
value: cleanString(runtime.effectivePaymenterBaseUrl) || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Total Available",
|
|
value: String(coerceFiniteNumber(paging.total) || users.length),
|
|
},
|
|
],
|
|
"No Paymenter customer data loaded."
|
|
);
|
|
if (!paymenterClientsListEl) {
|
|
renderIdentityIndex(connectorIdentityIndexCache);
|
|
return;
|
|
}
|
|
paymenterClientsListEl.innerHTML = "";
|
|
if (!users.length) {
|
|
paymenterClientsListEl.textContent =
|
|
"No Paymenter customers returned.";
|
|
renderIdentityIndex(connectorIdentityIndexCache);
|
|
return;
|
|
}
|
|
users.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = "chd-ops-item";
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[entry.displayName, entry.email, entry.username],
|
|
"Paymenter customer"
|
|
);
|
|
main.appendChild(title);
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = [
|
|
cleanString(entry.paymenterCustomerId)
|
|
? "Paymenter #" + cleanString(entry.paymenterCustomerId)
|
|
: "",
|
|
cleanString(entry.email) ? "Email: " + cleanString(entry.email) : "",
|
|
cleanString(entry.username)
|
|
? "Username: " + cleanString(entry.username)
|
|
: "",
|
|
cleanString(entry.customerExternalId)
|
|
? "External ID: " + cleanString(entry.customerExternalId)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (meta.textContent) {
|
|
main.appendChild(meta);
|
|
}
|
|
const matched = document.createElement("div");
|
|
matched.className = "chd-ops-meta";
|
|
matched.textContent = cleanString(entry.matchedInstanceId)
|
|
? "Matched instance: " +
|
|
cleanString(entry.matchedInstanceId) +
|
|
(cleanString(entry.matchedBy)
|
|
? " via " + cleanString(entry.matchedBy)
|
|
: "")
|
|
: "No connector instance match detected.";
|
|
main.appendChild(matched);
|
|
row.appendChild(main);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Match",
|
|
cleanString(entry.matchedInstanceId) ? "Connected" : "Unmatched",
|
|
cleanString(entry.matchedInstanceId) ? "good" : "partial"
|
|
)
|
|
);
|
|
row.appendChild(actions);
|
|
paymenterClientsListEl.appendChild(row);
|
|
});
|
|
renderIdentityIndex(connectorIdentityIndexCache);
|
|
}
|
|
|
|
function summarizeBillingHealth(summarySource, fallbackCounts) {
|
|
const summary = firstObject([summarySource]);
|
|
const fallback = isRecord(fallbackCounts) ? fallbackCounts : {};
|
|
const totalServices = coerceFiniteNumber(
|
|
summary.totalServices ?? summary.services ?? fallback.services
|
|
);
|
|
const activeServices = coerceFiniteNumber(summary.activeServices) || 0;
|
|
const inactiveServices = coerceFiniteNumber(
|
|
summary.inactiveServices ??
|
|
Math.max(0, (totalServices || 0) - activeServices)
|
|
) || 0;
|
|
const suspendedServices = coerceFiniteNumber(summary.suspendedServices) || 0;
|
|
const cancelledServices = coerceFiniteNumber(summary.cancelledServices) || 0;
|
|
const pastDueServices = coerceFiniteNumber(summary.pastDueServices) || 0;
|
|
const pendingServices = coerceFiniteNumber(summary.pendingServices) || 0;
|
|
const unknownServices = coerceFiniteNumber(summary.unknownServices) || 0;
|
|
const totalInvoices = coerceFiniteNumber(
|
|
summary.totalInvoices ?? summary.invoices ?? fallback.invoices
|
|
);
|
|
const paidInvoices = coerceFiniteNumber(summary.paidInvoices) || 0;
|
|
const outstandingInvoices = coerceFiniteNumber(summary.outstandingInvoices) || 0;
|
|
const overdueInvoices = coerceFiniteNumber(summary.overdueInvoices) || 0;
|
|
const partialInvoices = coerceFiniteNumber(summary.partialInvoices) || 0;
|
|
const activePackageTiers = firstArray([summary.activePackageTiers])
|
|
.map(function (entry) {
|
|
return cleanString(entry);
|
|
})
|
|
.filter(Boolean);
|
|
const activeServiceLabels = firstArray([summary.activeServiceLabels])
|
|
.map(function (entry) {
|
|
return cleanString(entry);
|
|
})
|
|
.filter(Boolean);
|
|
const activePackageTier = firstNonEmptyString([
|
|
summary.activePackageTier,
|
|
activePackageTiers[0],
|
|
]);
|
|
const activeServiceLabel = firstNonEmptyString([
|
|
summary.activeServiceLabel,
|
|
activeServiceLabels[0],
|
|
]);
|
|
const serviceHealthLevel = (() => {
|
|
if (totalServices && !activeServices) {
|
|
return "issues";
|
|
}
|
|
if (suspendedServices > 0 || cancelledServices > 0 || pastDueServices > 0) {
|
|
return "issues";
|
|
}
|
|
if (pendingServices > 0 || unknownServices > 0) {
|
|
return activeServices > 0 ? "partial" : "issues";
|
|
}
|
|
if (totalServices === null || totalServices === undefined) {
|
|
return activeServices > 0 ? "good" : "partial";
|
|
}
|
|
return activeServices > 0 ? "good" : "partial";
|
|
})();
|
|
const invoiceHealthLevel = (() => {
|
|
if (overdueInvoices > 0) {
|
|
return "issues";
|
|
}
|
|
if (outstandingInvoices > 0 || partialInvoices > 0) {
|
|
return "partial";
|
|
}
|
|
return totalInvoices > 0 ? "good" : "partial";
|
|
})();
|
|
const billingHealthLevel = summary.level
|
|
? normalizeStatusLevel(summary.level)
|
|
: totalServices === 0 && totalInvoices === 0
|
|
? "partial"
|
|
: combineStatusLevel(serviceHealthLevel, invoiceHealthLevel);
|
|
const billingHealthLabel = firstNonEmptyString([
|
|
summary.label,
|
|
levelTitle(billingHealthLevel),
|
|
]);
|
|
const serviceHealthLabel = (() => {
|
|
if (serviceHealthLevel === "good") {
|
|
return "Healthy";
|
|
}
|
|
if (serviceHealthLevel === "issues") {
|
|
return activeServices > 0 ? "Attention needed" : "No active service";
|
|
}
|
|
return "Needs review";
|
|
})();
|
|
const invoiceHealthLabel = (() => {
|
|
if (invoiceHealthLevel === "good") {
|
|
return "Current";
|
|
}
|
|
if (invoiceHealthLevel === "issues") {
|
|
return "Overdue";
|
|
}
|
|
return "Outstanding";
|
|
})();
|
|
const alertCount = coerceFiniteNumber(
|
|
summary.billingAlertCount ??
|
|
summary.alertCount ??
|
|
(Array.isArray(summary.alerts) ? summary.alerts.length : 0)
|
|
) || 0;
|
|
const attentionParts = [];
|
|
if (activeServiceLabel) {
|
|
attentionParts.push("Active: " + activeServiceLabel);
|
|
} else if (activePackageTier) {
|
|
attentionParts.push("Package: " + activePackageTier);
|
|
}
|
|
if (suspendedServices > 0) {
|
|
attentionParts.push("Suspended: " + String(suspendedServices));
|
|
}
|
|
if (cancelledServices > 0) {
|
|
attentionParts.push("Cancelled: " + String(cancelledServices));
|
|
}
|
|
if (overdueInvoices > 0) {
|
|
attentionParts.push("Overdue invoices: " + String(overdueInvoices));
|
|
} else if (outstandingInvoices > 0) {
|
|
attentionParts.push("Outstanding invoices: " + String(outstandingInvoices));
|
|
}
|
|
return {
|
|
totalServices: totalServices || 0,
|
|
activeServices: activeServices,
|
|
inactiveServices: inactiveServices,
|
|
suspendedServices: suspendedServices,
|
|
cancelledServices: cancelledServices,
|
|
pastDueServices: pastDueServices,
|
|
pendingServices: pendingServices,
|
|
unknownServices: unknownServices,
|
|
totalInvoices: totalInvoices || 0,
|
|
paidInvoices: paidInvoices,
|
|
outstandingInvoices: outstandingInvoices,
|
|
overdueInvoices: overdueInvoices,
|
|
partialInvoices: partialInvoices,
|
|
activePackageTier: activePackageTier,
|
|
activePackageTiers: activePackageTiers,
|
|
activeServiceLabel: activeServiceLabel,
|
|
activeServiceLabels: activeServiceLabels,
|
|
serviceHealthLevel: serviceHealthLevel,
|
|
invoiceHealthLevel: invoiceHealthLevel,
|
|
billingHealthLevel: billingHealthLevel,
|
|
billingHealthLabel: billingHealthLabel,
|
|
serviceHealthLabel: serviceHealthLabel,
|
|
invoiceHealthLabel: invoiceHealthLabel,
|
|
alertCount: alertCount,
|
|
attentionText: attentionParts.join(" | "),
|
|
};
|
|
}
|
|
|
|
function countSnapshotCollection(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.length;
|
|
}
|
|
const source = isRecord(value) ? value : {};
|
|
const candidates = [
|
|
source.data,
|
|
source.items,
|
|
source.records,
|
|
source.entries,
|
|
];
|
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
if (Array.isArray(candidates[index])) {
|
|
return candidates[index].length;
|
|
}
|
|
}
|
|
if (isRecord(source.data)) {
|
|
return 1;
|
|
}
|
|
if (isRecord(source)) {
|
|
return Object.keys(source).length > 0 ? 1 : 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function summarizePaymenterSnapshotPayload(data) {
|
|
const source = isRecord(data) ? data : {};
|
|
const runtime = firstObject([source.runtime]);
|
|
const request = firstObject([source.request]);
|
|
const snapshot = firstObject([source.snapshot]);
|
|
const snapshotData = firstObject([snapshot.data]);
|
|
const user = firstObject([snapshotData.user]);
|
|
const billing = summarizeBillingHealth(snapshotData.billingSummary, {
|
|
services: countSnapshotCollection(snapshotData.services),
|
|
invoices: countSnapshotCollection(snapshotData.invoices),
|
|
});
|
|
const summaryItems = [
|
|
{
|
|
label: "Requested Instance",
|
|
value:
|
|
cleanString(request.instanceId) ||
|
|
cleanString(activeInstanceId) ||
|
|
"Not set",
|
|
},
|
|
{
|
|
label: "Requested Email",
|
|
value: cleanString(request.requestedEmail) || "Not set",
|
|
},
|
|
{
|
|
label: "Resolved Customer ID",
|
|
value: firstNonEmptyString(
|
|
[user.id, user.customer_id, user.user_id],
|
|
"Not resolved"
|
|
),
|
|
},
|
|
{
|
|
label: "Paymenter Email",
|
|
value: firstNonEmptyString([user.email], "Not set"),
|
|
},
|
|
{
|
|
label: "Display Name",
|
|
value: firstNonEmptyString(
|
|
[user.name, user.full_name, user.company_name],
|
|
"Not set"
|
|
),
|
|
},
|
|
{
|
|
label: "Active Package",
|
|
value: firstNonEmptyString(
|
|
[billing.activePackageTier, billing.activeServiceLabel],
|
|
"Not set"
|
|
),
|
|
},
|
|
{
|
|
label: "Active Services",
|
|
value: String(billing.activeServices),
|
|
},
|
|
{
|
|
label: "Suspended Services",
|
|
value: String(billing.suspendedServices),
|
|
},
|
|
{
|
|
label: "Cancelled Services",
|
|
value: String(billing.cancelledServices),
|
|
},
|
|
{
|
|
label: "Outstanding Invoices",
|
|
value: String(billing.outstandingInvoices),
|
|
},
|
|
{
|
|
label: "Overdue Invoices",
|
|
value: String(billing.overdueInvoices),
|
|
},
|
|
{
|
|
label: "Billing Health",
|
|
value: billing.billingHealthLabel,
|
|
},
|
|
{
|
|
label: "Attention",
|
|
value: billing.attentionText || "None",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Services",
|
|
value: String(countSnapshotCollection(snapshotData.services)),
|
|
},
|
|
{
|
|
label: "Orders",
|
|
value: String(countSnapshotCollection(snapshotData.orders)),
|
|
},
|
|
{
|
|
label: "Invoices",
|
|
value: String(countSnapshotCollection(snapshotData.invoices)),
|
|
},
|
|
{
|
|
label: "Invoice Items",
|
|
value: String(countSnapshotCollection(snapshotData.invoiceItems)),
|
|
},
|
|
{
|
|
label: "Credits",
|
|
value: String(countSnapshotCollection(snapshotData.credits)),
|
|
},
|
|
{
|
|
label: "Properties",
|
|
value: String(countSnapshotCollection(snapshotData.properties)),
|
|
},
|
|
{
|
|
label: "Effective Backend",
|
|
value:
|
|
cleanString(runtime.effectivePaymenterProfile) || "connector default",
|
|
},
|
|
{
|
|
label: "Base URL",
|
|
value: cleanString(runtime.effectivePaymenterBaseUrl) || "Not set",
|
|
wide: true,
|
|
},
|
|
];
|
|
const warnings = Array.isArray(snapshot.warnings) ? snapshot.warnings : [];
|
|
const statusItems = [
|
|
{
|
|
label: "Snapshot",
|
|
value: cleanString(user.id || user.customer_id || user.user_id)
|
|
? "Resolved"
|
|
: "No customer resolved",
|
|
level: cleanString(user.id || user.customer_id || user.user_id)
|
|
? "good"
|
|
: "partial",
|
|
},
|
|
{
|
|
label: "Billing Health",
|
|
value: billing.billingHealthLabel,
|
|
level: billing.billingHealthLevel,
|
|
},
|
|
{
|
|
label: "Service Health",
|
|
value: billing.serviceHealthLabel,
|
|
level: billing.serviceHealthLevel,
|
|
},
|
|
{
|
|
label: "Invoice Health",
|
|
value: billing.invoiceHealthLabel,
|
|
level: billing.invoiceHealthLevel,
|
|
},
|
|
{
|
|
label: "Warnings",
|
|
value: warnings.length ? String(warnings.length) : "None",
|
|
level: warnings.length ? "partial" : "good",
|
|
},
|
|
];
|
|
return {
|
|
summaryItems: summaryItems,
|
|
statusItems: statusItems,
|
|
focusedData: {
|
|
request: request,
|
|
runtime: runtime,
|
|
user: user,
|
|
billing: billing,
|
|
counts: {
|
|
services: countSnapshotCollection(snapshotData.services),
|
|
orders: countSnapshotCollection(snapshotData.orders),
|
|
invoices: countSnapshotCollection(snapshotData.invoices),
|
|
invoiceItems: countSnapshotCollection(snapshotData.invoiceItems),
|
|
credits: countSnapshotCollection(snapshotData.credits),
|
|
properties: countSnapshotCollection(snapshotData.properties),
|
|
},
|
|
warnings: warnings,
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderPaymenterBackups(data) {
|
|
const source = isRecord(data) ? data : {};
|
|
const backups = Array.isArray(source.backups) ? source.backups : [];
|
|
const paging = firstObject([source.paging]);
|
|
renderSummaryItems(
|
|
paymenterBackupsSummaryEl,
|
|
[
|
|
{ label: "Backups Loaded", value: String(backups.length) },
|
|
{
|
|
label: "Total Available",
|
|
value: String(coerceFiniteNumber(paging.total) || backups.length),
|
|
},
|
|
{
|
|
label: "Selected Instance Scope",
|
|
value: cleanString(activeInstanceId) || "All instances",
|
|
},
|
|
],
|
|
"No connector backup history available."
|
|
);
|
|
if (!paymenterBackupsListEl) {
|
|
return;
|
|
}
|
|
paymenterBackupsListEl.innerHTML = "";
|
|
if (!backups.length) {
|
|
paymenterBackupsListEl.textContent =
|
|
"No connector backup history returned.";
|
|
return;
|
|
}
|
|
backups.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = "chd-ops-item";
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[entry.instanceId, entry.filename],
|
|
"Backup"
|
|
);
|
|
main.appendChild(title);
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = [
|
|
cleanString(entry.action) ? "Action: " + cleanString(entry.action) : "",
|
|
cleanString(entry.performedAt)
|
|
? "At: " + formatDateTime(entry.performedAt)
|
|
: "",
|
|
cleanString(entry.effectivePaymenterProfile)
|
|
? "Backend: " + cleanString(entry.effectivePaymenterProfile)
|
|
: "",
|
|
cleanString(entry.matchSource)
|
|
? "Match: " + cleanString(entry.matchSource)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (meta.textContent) {
|
|
main.appendChild(meta);
|
|
}
|
|
const notices = Array.isArray(entry.notices)
|
|
? entry.notices.filter(Boolean)
|
|
: [];
|
|
if (notices.length) {
|
|
const note = document.createElement("div");
|
|
note.className = "chd-ops-meta";
|
|
note.textContent = notices.join(" | ");
|
|
main.appendChild(note);
|
|
}
|
|
row.appendChild(main);
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Backup",
|
|
cleanString(entry.action) || "Snapshot",
|
|
"good"
|
|
)
|
|
);
|
|
row.appendChild(actions);
|
|
paymenterBackupsListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderPaymenterOrders(data) {
|
|
const source = isRecord(data) ? data : {};
|
|
const orders = Array.isArray(source.orders) ? source.orders : [];
|
|
const paging = firstObject([source.paging]);
|
|
const filters = firstObject([source.filters]);
|
|
const runtime = firstObject([source.runtime]);
|
|
renderSummaryItems(
|
|
paymenterOrdersSummaryEl,
|
|
[
|
|
{ label: "Orders Loaded", value: String(orders.length) },
|
|
{
|
|
label: "Total Available",
|
|
value: String(coerceFiniteNumber(paging.total) || orders.length),
|
|
},
|
|
{
|
|
label: "Selected Scope",
|
|
value:
|
|
cleanString(filters.customerId) ||
|
|
cleanString(filters.instanceId) ||
|
|
cleanString(activeInstanceId) ||
|
|
"All instances",
|
|
},
|
|
{
|
|
label: "Effective Backend",
|
|
value:
|
|
cleanString(runtime.effectivePaymenterProfile) ||
|
|
"connector default",
|
|
},
|
|
],
|
|
"No Paymenter orders returned."
|
|
);
|
|
if (!paymenterOrdersListEl) {
|
|
return;
|
|
}
|
|
paymenterOrdersListEl.innerHTML = "";
|
|
if (!orders.length) {
|
|
paymenterOrdersListEl.textContent = "No Paymenter orders returned.";
|
|
return;
|
|
}
|
|
orders.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = "chd-ops-item";
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[
|
|
entry.orderId ? "Order #" + cleanString(entry.orderId) : "",
|
|
entry.customerName,
|
|
entry.customerEmail,
|
|
],
|
|
"Paymenter order"
|
|
);
|
|
main.appendChild(title);
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = [
|
|
cleanString(entry.status) ? "Status: " + cleanString(entry.status) : "",
|
|
cleanString(entry.paymentStatus)
|
|
? "Payment: " + cleanString(entry.paymentStatus)
|
|
: "",
|
|
cleanString(entry.total)
|
|
? "Total: " +
|
|
cleanString(entry.total) +
|
|
(cleanString(entry.currency)
|
|
? " " + cleanString(entry.currency)
|
|
: "")
|
|
: "",
|
|
cleanString(entry.createdAt)
|
|
? "Created: " + formatDateTime(entry.createdAt)
|
|
: "",
|
|
cleanString(entry.customerEmail)
|
|
? "Email: " + cleanString(entry.customerEmail)
|
|
: "",
|
|
cleanString(entry.paymenterCustomerId)
|
|
? "Paymenter #" + cleanString(entry.paymenterCustomerId)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (meta.textContent) {
|
|
main.appendChild(meta);
|
|
}
|
|
const relation = document.createElement("div");
|
|
relation.className = "chd-ops-meta";
|
|
relation.textContent = [
|
|
cleanString(entry.customerName)
|
|
? "Customer: " + cleanString(entry.customerName)
|
|
: "",
|
|
cleanString(entry.instanceId)
|
|
? "Instance: " + cleanString(entry.instanceId)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (relation.textContent) {
|
|
main.appendChild(relation);
|
|
}
|
|
row.appendChild(main);
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
const statusValue = cleanString(entry.status) || "Order";
|
|
const normalizedStatus = statusValue.toLowerCase();
|
|
const hasWord = function (token) {
|
|
return new RegExp("(^|[^a-z])" + token + "([^a-z]|$)").test(
|
|
normalizedStatus
|
|
);
|
|
};
|
|
let statusLevel = "partial";
|
|
if (
|
|
normalizedStatus.includes("cancel") ||
|
|
normalizedStatus.includes("suspend") ||
|
|
normalizedStatus.includes("past due") ||
|
|
normalizedStatus.includes("past_due") ||
|
|
normalizedStatus.includes("overdue") ||
|
|
normalizedStatus.includes("failed")
|
|
) {
|
|
statusLevel = "issues";
|
|
} else if (
|
|
(normalizedStatus.includes("paid") &&
|
|
!normalizedStatus.includes("unpaid") &&
|
|
!normalizedStatus.includes("partial")) ||
|
|
(normalizedStatus.includes("complete") &&
|
|
!normalizedStatus.includes("incomplete")) ||
|
|
hasWord("active")
|
|
) {
|
|
statusLevel = "good";
|
|
}
|
|
actions.appendChild(buildStatusPill("Order", statusValue, statusLevel));
|
|
row.appendChild(actions);
|
|
paymenterOrdersListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderPaymenterInvoices(data) {
|
|
const source = isRecord(data) ? data : {};
|
|
const invoices = Array.isArray(source.invoices) ? source.invoices : [];
|
|
const paging = firstObject([source.paging]);
|
|
const filters = firstObject([source.filters]);
|
|
const runtime = firstObject([source.runtime]);
|
|
renderSummaryItems(
|
|
paymenterInvoicesSummaryEl,
|
|
[
|
|
{ label: "Invoices Loaded", value: String(invoices.length) },
|
|
{
|
|
label: "Total Available",
|
|
value: String(coerceFiniteNumber(paging.total) || invoices.length),
|
|
},
|
|
{
|
|
label: "Selected Scope",
|
|
value:
|
|
cleanString(filters.customerId) ||
|
|
cleanString(filters.instanceId) ||
|
|
cleanString(activeInstanceId) ||
|
|
"All instances",
|
|
},
|
|
{
|
|
label: "Effective Backend",
|
|
value:
|
|
cleanString(runtime.effectivePaymenterProfile) ||
|
|
"connector default",
|
|
},
|
|
],
|
|
"No Paymenter invoices returned."
|
|
);
|
|
if (!paymenterInvoicesListEl) {
|
|
return;
|
|
}
|
|
paymenterInvoicesListEl.innerHTML = "";
|
|
if (!invoices.length) {
|
|
paymenterInvoicesListEl.textContent = "No Paymenter invoices returned.";
|
|
return;
|
|
}
|
|
invoices.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = "chd-ops-item";
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[
|
|
entry.invoiceId ? "Invoice #" + cleanString(entry.invoiceId) : "",
|
|
entry.customerName,
|
|
entry.customerEmail,
|
|
],
|
|
"Paymenter invoice"
|
|
);
|
|
main.appendChild(title);
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = [
|
|
cleanString(entry.status) ? "Status: " + cleanString(entry.status) : "",
|
|
cleanString(entry.dueAt) ? "Due: " + formatDateTime(entry.dueAt) : "",
|
|
cleanString(entry.createdAt)
|
|
? "Created: " + formatDateTime(entry.createdAt)
|
|
: "",
|
|
cleanString(entry.currency)
|
|
? "Currency: " + cleanString(entry.currency)
|
|
: "",
|
|
cleanString(entry.paymenterCustomerId)
|
|
? "Paymenter #" + cleanString(entry.paymenterCustomerId)
|
|
: "",
|
|
cleanString(entry.itemCount)
|
|
? "Items: " + cleanString(entry.itemCount)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (meta.textContent) {
|
|
main.appendChild(meta);
|
|
}
|
|
const relation = document.createElement("div");
|
|
relation.className = "chd-ops-meta";
|
|
relation.textContent = [
|
|
cleanString(entry.customerName)
|
|
? "Customer: " + cleanString(entry.customerName)
|
|
: "",
|
|
cleanString(entry.customerEmail)
|
|
? "Email: " + cleanString(entry.customerEmail)
|
|
: "",
|
|
cleanString(entry.instanceId)
|
|
? "Instance: " + cleanString(entry.instanceId)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (relation.textContent) {
|
|
main.appendChild(relation);
|
|
}
|
|
row.appendChild(main);
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
const statusValue = cleanString(entry.status) || "Invoice";
|
|
const normalizedStatus = statusValue.toLowerCase();
|
|
let statusLevel = "partial";
|
|
if (
|
|
normalizedStatus.includes("paid") &&
|
|
!normalizedStatus.includes("unpaid") &&
|
|
!normalizedStatus.includes("partial") &&
|
|
!normalizedStatus.includes("past due") &&
|
|
!normalizedStatus.includes("past_due")
|
|
) {
|
|
statusLevel = "good";
|
|
} else if (
|
|
normalizedStatus.includes("cancel") ||
|
|
normalizedStatus.includes("void") ||
|
|
normalizedStatus.includes("past due") ||
|
|
normalizedStatus.includes("past_due") ||
|
|
normalizedStatus.includes("overdue") ||
|
|
normalizedStatus.includes("unpaid") ||
|
|
normalizedStatus.includes("failed")
|
|
) {
|
|
statusLevel = "issues";
|
|
}
|
|
actions.appendChild(buildStatusPill("Invoice", statusValue, statusLevel));
|
|
row.appendChild(actions);
|
|
paymenterInvoicesListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderPaymenterInvoiceItems(data) {
|
|
const source = isRecord(data) ? data : {};
|
|
const invoiceItems = Array.isArray(source.invoiceItems)
|
|
? source.invoiceItems
|
|
: [];
|
|
const paging = firstObject([source.paging]);
|
|
const filters = firstObject([source.filters]);
|
|
const runtime = firstObject([source.runtime]);
|
|
renderSummaryItems(
|
|
paymenterInvoiceItemsSummaryEl,
|
|
[
|
|
{ label: "Items Loaded", value: String(invoiceItems.length) },
|
|
{
|
|
label: "Total Available",
|
|
value: String(
|
|
coerceFiniteNumber(paging.total) || invoiceItems.length
|
|
),
|
|
},
|
|
{
|
|
label: "Selected Scope",
|
|
value:
|
|
cleanString(filters.referenceType) ||
|
|
cleanString(filters.referenceId) ||
|
|
cleanString(activeInstanceId) ||
|
|
"All invoice items",
|
|
},
|
|
{
|
|
label: "Effective Backend",
|
|
value:
|
|
cleanString(runtime.effectivePaymenterProfile) ||
|
|
"connector default",
|
|
},
|
|
],
|
|
"No Paymenter invoice items returned."
|
|
);
|
|
if (!paymenterInvoiceItemsListEl) {
|
|
return;
|
|
}
|
|
paymenterInvoiceItemsListEl.innerHTML = "";
|
|
if (!invoiceItems.length) {
|
|
paymenterInvoiceItemsListEl.textContent =
|
|
"No Paymenter invoice items returned.";
|
|
return;
|
|
}
|
|
invoiceItems.forEach(function (entry) {
|
|
if (!isRecord(entry)) {
|
|
return;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = "chd-ops-item";
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = firstNonEmptyString(
|
|
[
|
|
entry.description,
|
|
entry.invoiceItemId
|
|
? "Item #" + cleanString(entry.invoiceItemId)
|
|
: "",
|
|
entry.referenceType,
|
|
],
|
|
"Invoice item"
|
|
);
|
|
main.appendChild(title);
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = [
|
|
cleanString(entry.quantity)
|
|
? "Qty: " + cleanString(entry.quantity)
|
|
: "",
|
|
cleanString(entry.price) ? "Price: " + cleanString(entry.price) : "",
|
|
cleanString(entry.referenceType)
|
|
? "Reference: " +
|
|
cleanString(entry.referenceType) +
|
|
(cleanString(entry.referenceId)
|
|
? " #" + cleanString(entry.referenceId)
|
|
: "")
|
|
: "",
|
|
cleanString(entry.createdAt)
|
|
? "Created: " + formatDateTime(entry.createdAt)
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
if (meta.textContent) {
|
|
main.appendChild(meta);
|
|
}
|
|
row.appendChild(main);
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Item",
|
|
cleanString(entry.referenceType) || "Line item",
|
|
"good"
|
|
)
|
|
);
|
|
row.appendChild(actions);
|
|
paymenterInvoiceItemsListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function loadPaymenterClients() {
|
|
if (!paymenterClientsSummaryEl || !paymenterClientsUrl) {
|
|
return;
|
|
}
|
|
setPaymenterStatus("Loading Paymenter customers...");
|
|
renderSummaryItems(
|
|
paymenterClientsSummaryEl,
|
|
[],
|
|
"Loading Paymenter customers..."
|
|
);
|
|
if (paymenterClientsListEl) {
|
|
paymenterClientsListEl.textContent = "Loading Paymenter customers...";
|
|
}
|
|
const url = new URL(paymenterClientsUrl, window.location.origin);
|
|
const profile = getSelectedPaymenterViewProfile();
|
|
if (profile) {
|
|
url.searchParams.set("paymenterProfile", profile);
|
|
}
|
|
url.searchParams.set("limit", "100");
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
renderPaymenterClients(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
setPaymenterStatus("Paymenter customers refreshed.");
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load Paymenter customers: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(paymenterClientsSummaryEl, [], errorMessage);
|
|
if (paymenterClientsListEl) {
|
|
paymenterClientsListEl.textContent = errorMessage;
|
|
}
|
|
setPaymenterStatus(errorMessage);
|
|
}
|
|
}
|
|
|
|
async function loadPaymenterOrders() {
|
|
if (!paymenterOrdersSummaryEl || !paymenterOrdersUrl) {
|
|
return;
|
|
}
|
|
setPaymenterStatus("Loading Paymenter orders...");
|
|
renderSummaryItems(
|
|
paymenterOrdersSummaryEl,
|
|
[],
|
|
"Loading Paymenter orders..."
|
|
);
|
|
if (paymenterOrdersListEl) {
|
|
paymenterOrdersListEl.textContent = "Loading Paymenter orders...";
|
|
}
|
|
const url = new URL(paymenterOrdersUrl, window.location.origin);
|
|
const detailInstance = resolveDetailInstance(
|
|
activeInstanceDetailData || {}
|
|
);
|
|
const profile = getSelectedPaymenterViewProfile();
|
|
if (trimString(activeInstanceId)) {
|
|
url.searchParams.set("instanceId", trimString(activeInstanceId));
|
|
}
|
|
if (trimString(detailInstance && detailInstance.paymenterCustomerId)) {
|
|
url.searchParams.set(
|
|
"customerId",
|
|
trimString(detailInstance.paymenterCustomerId)
|
|
);
|
|
}
|
|
if (profile) {
|
|
url.searchParams.set("paymenterProfile", profile);
|
|
}
|
|
url.searchParams.set("limit", "100");
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
renderPaymenterOrders(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
setPaymenterStatus("Paymenter orders refreshed.");
|
|
} catch (error) {
|
|
const rawErrorMessage =
|
|
error && error.message ? error.message : String(error);
|
|
const unavailableMessage =
|
|
"Paymenter orders are unavailable from this backend. Invoices and service records are the better source of truth for ongoing billing.";
|
|
const errorMessage =
|
|
rawErrorMessage.indexOf("handleAdminPaymenterOrdersRequest is not defined") !== -1 ||
|
|
rawErrorMessage.indexOf("500 Internal Server Error") !== -1
|
|
? unavailableMessage
|
|
: "Failed to load Paymenter orders: " + rawErrorMessage;
|
|
renderSummaryItems(paymenterOrdersSummaryEl, [], errorMessage);
|
|
if (paymenterOrdersListEl) {
|
|
paymenterOrdersListEl.textContent = errorMessage;
|
|
}
|
|
setPaymenterStatus(errorMessage);
|
|
}
|
|
}
|
|
|
|
async function loadPaymenterSnapshotForActiveInstance() {
|
|
const panel = infoPanels.paymenterSnapshot;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (!paymenterSnapshotUrl) {
|
|
renderSummaryItems(
|
|
paymenterSnapshotSummaryEl,
|
|
[],
|
|
"Paymenter snapshot endpoint is not configured."
|
|
);
|
|
renderStatusPills(paymenterSnapshotStatusEl, []);
|
|
setInfoPanelError(
|
|
panel,
|
|
"Paymenter snapshot endpoint is not configured."
|
|
);
|
|
return;
|
|
}
|
|
const instanceId = trimString(activeInstanceId);
|
|
if (!instanceId) {
|
|
renderSummaryItems(
|
|
paymenterSnapshotSummaryEl,
|
|
[],
|
|
"Select an instance to load Paymenter snapshot data."
|
|
);
|
|
renderStatusPills(paymenterSnapshotStatusEl, []);
|
|
setInfoPanelEmpty(
|
|
panel,
|
|
"Select an instance to load Paymenter snapshot data."
|
|
);
|
|
return;
|
|
}
|
|
const detailInstance = resolveDetailInstance(
|
|
activeInstanceDetailData || {}
|
|
);
|
|
const url = new URL(paymenterSnapshotUrl, window.location.origin);
|
|
url.searchParams.set("instanceId", instanceId);
|
|
const customerId = trimString(
|
|
detailInstance && detailInstance.paymenterCustomerId
|
|
);
|
|
const requestedEmail = trimString(
|
|
detailInstance &&
|
|
(detailInstance.mspContactEmail || detailInstance.adminEmail)
|
|
);
|
|
const profile = getSelectedPaymenterViewProfile();
|
|
if (customerId) {
|
|
url.searchParams.set("customerId", customerId);
|
|
}
|
|
if (requestedEmail) {
|
|
url.searchParams.set("requestedEmail", requestedEmail);
|
|
}
|
|
if (profile) {
|
|
url.searchParams.set("paymenterProfile", profile);
|
|
}
|
|
setInfoPanelLoading(panel, "Loading Paymenter snapshot...");
|
|
renderSummaryItems(
|
|
paymenterSnapshotSummaryEl,
|
|
[],
|
|
"Loading Paymenter snapshot..."
|
|
);
|
|
renderStatusPills(paymenterSnapshotStatusEl, []);
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
const presentation = summarizePaymenterSnapshotPayload(data);
|
|
renderSummaryItems(
|
|
paymenterSnapshotSummaryEl,
|
|
presentation.summaryItems,
|
|
"No Paymenter snapshot returned."
|
|
);
|
|
renderStatusPills(paymenterSnapshotStatusEl, presentation.statusItems);
|
|
setInfoPanelData(panel, data, presentation.focusedData);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load Paymenter snapshot: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(paymenterSnapshotSummaryEl, [], errorMessage);
|
|
renderStatusPills(paymenterSnapshotStatusEl, []);
|
|
setInfoPanelError(panel, errorMessage);
|
|
}
|
|
}
|
|
|
|
async function loadPaymenterBackups() {
|
|
const panel = infoPanels.paymenterBackups;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (!paymenterBackupsUrl) {
|
|
renderSummaryItems(
|
|
paymenterBackupsSummaryEl,
|
|
[],
|
|
"Paymenter backups endpoint is not configured."
|
|
);
|
|
setInfoPanelError(panel, "Paymenter backups endpoint is not configured.");
|
|
return;
|
|
}
|
|
const url = new URL(paymenterBackupsUrl, window.location.origin);
|
|
if (trimString(activeInstanceId)) {
|
|
url.searchParams.set("instanceId", trimString(activeInstanceId));
|
|
}
|
|
url.searchParams.set("limit", "20");
|
|
setInfoPanelLoading(panel, "Loading connector backup history...");
|
|
renderSummaryItems(
|
|
paymenterBackupsSummaryEl,
|
|
[],
|
|
"Loading connector backup history..."
|
|
);
|
|
if (paymenterBackupsListEl) {
|
|
paymenterBackupsListEl.textContent =
|
|
"Loading connector backup history...";
|
|
}
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
renderPaymenterBackups(data);
|
|
setInfoPanelData(panel, data, {
|
|
backups: Array.isArray(data.backups) ? data.backups : [],
|
|
paging:
|
|
data.paging && typeof data.paging === "object" ? data.paging : {},
|
|
});
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load connector backup history: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(paymenterBackupsSummaryEl, [], errorMessage);
|
|
if (paymenterBackupsListEl) {
|
|
paymenterBackupsListEl.textContent = errorMessage;
|
|
}
|
|
setInfoPanelError(panel, errorMessage);
|
|
}
|
|
}
|
|
|
|
async function loadPaymenterInvoices() {
|
|
if (!paymenterInvoicesSummaryEl || !paymenterInvoicesUrl) {
|
|
return;
|
|
}
|
|
setPaymenterStatus("Loading Paymenter invoices...");
|
|
renderSummaryItems(
|
|
paymenterInvoicesSummaryEl,
|
|
[],
|
|
"Loading Paymenter invoices..."
|
|
);
|
|
if (paymenterInvoicesListEl) {
|
|
paymenterInvoicesListEl.textContent = "Loading Paymenter invoices...";
|
|
}
|
|
const url = new URL(paymenterInvoicesUrl, window.location.origin);
|
|
const detailInstance = resolveDetailInstance(
|
|
activeInstanceDetailData || {}
|
|
);
|
|
const profile = getSelectedPaymenterViewProfile();
|
|
if (trimString(activeInstanceId)) {
|
|
url.searchParams.set("instanceId", trimString(activeInstanceId));
|
|
}
|
|
if (trimString(detailInstance && detailInstance.paymenterCustomerId)) {
|
|
url.searchParams.set(
|
|
"customerId",
|
|
trimString(detailInstance.paymenterCustomerId)
|
|
);
|
|
}
|
|
if (profile) {
|
|
url.searchParams.set("paymenterProfile", profile);
|
|
}
|
|
url.searchParams.set("limit", "100");
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
renderPaymenterInvoices(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
setPaymenterStatus("Paymenter invoices refreshed.");
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load Paymenter invoices: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(paymenterInvoicesSummaryEl, [], errorMessage);
|
|
if (paymenterInvoicesListEl) {
|
|
paymenterInvoicesListEl.textContent = errorMessage;
|
|
}
|
|
setPaymenterStatus(errorMessage);
|
|
}
|
|
}
|
|
|
|
async function loadPaymenterInvoiceItems() {
|
|
if (!paymenterInvoiceItemsSummaryEl || !paymenterInvoiceItemsUrl) {
|
|
return;
|
|
}
|
|
setPaymenterStatus("Loading Paymenter invoice items...");
|
|
renderSummaryItems(
|
|
paymenterInvoiceItemsSummaryEl,
|
|
[],
|
|
"Loading Paymenter invoice items..."
|
|
);
|
|
if (paymenterInvoiceItemsListEl) {
|
|
paymenterInvoiceItemsListEl.textContent =
|
|
"Loading Paymenter invoice items...";
|
|
}
|
|
const url = new URL(paymenterInvoiceItemsUrl, window.location.origin);
|
|
const profile = getSelectedPaymenterViewProfile();
|
|
if (profile) {
|
|
url.searchParams.set("paymenterProfile", profile);
|
|
}
|
|
url.searchParams.set("limit", "100");
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
renderPaymenterInvoiceItems(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
setPaymenterStatus("Paymenter invoice items refreshed.");
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load Paymenter invoice items: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(paymenterInvoiceItemsSummaryEl, [], errorMessage);
|
|
if (paymenterInvoiceItemsListEl) {
|
|
paymenterInvoiceItemsListEl.textContent = errorMessage;
|
|
}
|
|
setPaymenterStatus(errorMessage);
|
|
}
|
|
}
|
|
|
|
async function runPaymenterSourceSync(dryRun) {
|
|
if (!paymenterSourceSyncUrl) {
|
|
throw new Error("Paymenter source sync endpoint is not configured.");
|
|
}
|
|
const instanceId = trimString(activeInstanceId);
|
|
const profile = getSelectedPaymenterViewProfile();
|
|
const scopeLabel = instanceId
|
|
? "instance " + instanceId
|
|
: "all active connector instances";
|
|
setPaymenterStatus(
|
|
(dryRun ? "Dry-running" : "Running") +
|
|
" source-of-truth sync for " +
|
|
scopeLabel +
|
|
"..."
|
|
);
|
|
const payload = {};
|
|
if (instanceId) {
|
|
payload.instanceId = instanceId;
|
|
}
|
|
if (profile) {
|
|
payload.paymenterProfile = profile;
|
|
}
|
|
if (dryRun) {
|
|
payload.dryRun = true;
|
|
}
|
|
const response = await requestJson(paymenterSourceSyncUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data =
|
|
response && response.data && typeof response.data === "object"
|
|
? response.data
|
|
: {};
|
|
const changed = coerceFiniteNumber(data.changed) || 0;
|
|
const failed = coerceFiniteNumber(data.failed) || 0;
|
|
const processed = coerceFiniteNumber(data.processed) || 0;
|
|
const summaryMessage =
|
|
(dryRun ? "Dry run complete" : "Source-of-truth sync complete") +
|
|
" | Processed: " +
|
|
processed +
|
|
" | Changed: " +
|
|
changed +
|
|
" | Failed: " +
|
|
failed;
|
|
setPaymenterStatus(summaryMessage);
|
|
await loadConnectorInstances();
|
|
if (instanceId) {
|
|
if (instanceSelectEl) {
|
|
instanceSelectEl.value = instanceId;
|
|
}
|
|
await loadConnectorInstanceDetail(instanceId);
|
|
}
|
|
await loadPaymenterClients();
|
|
await loadPaymenterBackups();
|
|
return data;
|
|
}
|
|
|
|
function renderOperationsSummary(tasks) {
|
|
if (!operationsSummaryEl) {
|
|
return;
|
|
}
|
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
const counts = {
|
|
pending: 0,
|
|
in_progress: 0,
|
|
blocked: 0,
|
|
completed: 0,
|
|
};
|
|
list.forEach(function (entry) {
|
|
const status = normalizeOperationStatus(
|
|
entry && entry.status ? entry.status : ""
|
|
);
|
|
if (Object.prototype.hasOwnProperty.call(counts, status)) {
|
|
counts[status] += 1;
|
|
}
|
|
});
|
|
const actionableCount = list.filter(operationRequiresAction).length;
|
|
const introGrantCount = list.filter(function (entry) {
|
|
return (
|
|
cleanString(entry && entry.taskType ? entry.taskType : "") ===
|
|
"intro_grant" &&
|
|
normalizeOperationStatus(entry && entry.status ? entry.status : "") !==
|
|
"completed"
|
|
);
|
|
}).length;
|
|
const fulfillmentCount = list.filter(function (entry) {
|
|
return (
|
|
cleanString(entry && entry.taskType ? entry.taskType : "") !==
|
|
"intro_grant" &&
|
|
normalizeOperationStatus(entry && entry.status ? entry.status : "") ===
|
|
"pending"
|
|
);
|
|
}).length;
|
|
const items = [
|
|
{ label: "Requires Action", value: String(actionableCount) },
|
|
{ label: "Grant Requests", value: String(introGrantCount) },
|
|
{ label: "Pending Fulfillment", value: String(fulfillmentCount) },
|
|
{ label: "Pending", value: String(counts.pending) },
|
|
{ label: "Blocked", value: String(counts.blocked) },
|
|
{ label: "In Progress", value: String(counts.in_progress) },
|
|
{ label: "Completed", value: String(counts.completed) },
|
|
{ label: "Tasks", value: String(list.length) },
|
|
];
|
|
renderSummaryItems(operationsSummaryEl, items, "No operation tasks.");
|
|
}
|
|
|
|
async function saveInstanceOperationTask(instanceId, task) {
|
|
if (!saveInstanceOperationUrl) {
|
|
throw new Error("Instance operation save endpoint is not configured.");
|
|
}
|
|
const payloadBody = {
|
|
instanceId: String(instanceId || ""),
|
|
task: buildOperationTaskPayload(task),
|
|
};
|
|
const payload = await requestJson(saveInstanceOperationUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders(
|
|
"application/x-www-form-urlencoded; charset=UTF-8"
|
|
),
|
|
credentials: "same-origin",
|
|
body: buildLegacyPayloadBody(payloadBody),
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
return isRecord(data.task) ? data.task : null;
|
|
}
|
|
|
|
async function setInstanceOperationTaskStatus(instanceId, taskId, status) {
|
|
if (!updateInstanceOperationStatusUrl) {
|
|
throw new Error("Instance operation status endpoint is not configured.");
|
|
}
|
|
const payloadBody = {
|
|
instanceId: String(instanceId || ""),
|
|
taskId: String(taskId || ""),
|
|
status: normalizeOperationStatus(status),
|
|
};
|
|
const payload = await requestJson(updateInstanceOperationStatusUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders(
|
|
"application/x-www-form-urlencoded; charset=UTF-8"
|
|
),
|
|
credentials: "same-origin",
|
|
body: buildLegacyPayloadBody(payloadBody),
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
return isRecord(data.task) ? data.task : null;
|
|
}
|
|
|
|
async function runIntroGrantTaskAction(instanceId, task, action, options) {
|
|
if (!introGrantTaskActionUrl) {
|
|
throw new Error("Intro grant action endpoint is not configured.");
|
|
}
|
|
const extra = isRecord(options) ? options : {};
|
|
const payloadBody = {
|
|
instanceId: String(instanceId || ""),
|
|
task: buildOperationTaskPayload(task),
|
|
action: String(action || ""),
|
|
message: cleanString(extra.message),
|
|
amount: extra.amount,
|
|
sendFunds: extra.sendFunds ? 1 : 0,
|
|
transfer: isRecord(extra.transfer)
|
|
? cloneJsonValue(extra.transfer, {})
|
|
: undefined,
|
|
};
|
|
const payload = await requestJson(introGrantTaskActionUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders(
|
|
"application/x-www-form-urlencoded; charset=UTF-8"
|
|
),
|
|
credentials: "same-origin",
|
|
body: buildLegacyPayloadBody(payloadBody),
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
return {
|
|
task: isRecord(data.task) ? data.task : null,
|
|
connector: isRecord(data.connector) ? data.connector : {},
|
|
transfer: isRecord(data.transfer) ? data.transfer : {},
|
|
};
|
|
}
|
|
|
|
async function ensurePersistedTask(instanceId, task) {
|
|
if (!isRecord(task)) {
|
|
throw new Error("Invalid operation task payload.");
|
|
}
|
|
const existingId = cleanString(task.id);
|
|
if (existingId) {
|
|
return task;
|
|
}
|
|
const persistedTask = await saveInstanceOperationTask(instanceId, task);
|
|
if (!isRecord(persistedTask) || !cleanString(persistedTask.id)) {
|
|
throw new Error("Failed to persist operation task.");
|
|
}
|
|
return persistedTask;
|
|
}
|
|
|
|
function renderOperationsList(tasks) {
|
|
if (!operationsListEl) {
|
|
return;
|
|
}
|
|
operationsListEl.innerHTML = "";
|
|
const list = Array.isArray(tasks) ? tasks : [];
|
|
if (!list.length) {
|
|
operationsListEl.textContent = "No operation tasks yet.";
|
|
return;
|
|
}
|
|
list.forEach(function (task) {
|
|
if (!isRecord(task)) {
|
|
return;
|
|
}
|
|
const normalizedStatus = normalizeOperationStatus(task.status);
|
|
const actionRequired = operationRequiresAction(task);
|
|
const row = document.createElement("article");
|
|
row.className = "chd-ops-item";
|
|
if (actionRequired) {
|
|
row.classList.add("chd-ops-item--attention");
|
|
}
|
|
if (cleanString(task.taskType) === "intro_grant") {
|
|
row.classList.add("chd-ops-item--grant");
|
|
}
|
|
|
|
const main = document.createElement("div");
|
|
main.className = "chd-ops-item-main";
|
|
const title = document.createElement("div");
|
|
title.className = "chd-ops-title";
|
|
title.textContent = cleanString(task.title) || "Untitled operation";
|
|
main.appendChild(title);
|
|
|
|
const metaParts = [];
|
|
const source = cleanString(task.source) || "manual";
|
|
metaParts.push("Source: " + source);
|
|
if (cleanString(task.taskType)) {
|
|
metaParts.push("Type: " + cleanString(task.taskType));
|
|
}
|
|
if (cleanString(task.userLabel)) {
|
|
metaParts.push("User: " + cleanString(task.userLabel));
|
|
} else if (cleanString(task.nextcloudUserId)) {
|
|
metaParts.push("User: " + cleanString(task.nextcloudUserId));
|
|
}
|
|
if (cleanString(task.paymenterCustomerId)) {
|
|
metaParts.push("Paymenter: #" + cleanString(task.paymenterCustomerId));
|
|
}
|
|
if (cleanString(task.orderRef)) {
|
|
metaParts.push("Order: " + cleanString(task.orderRef));
|
|
}
|
|
if (cleanString(task.productCode)) {
|
|
metaParts.push("Code: " + cleanString(task.productCode));
|
|
}
|
|
const meta = document.createElement("div");
|
|
meta.className = "chd-ops-meta";
|
|
meta.textContent = metaParts.join(" | ");
|
|
main.appendChild(meta);
|
|
|
|
if (cleanString(task.notes)) {
|
|
const notes = document.createElement("div");
|
|
notes.className = "chd-ops-meta";
|
|
notes.textContent = "Notes: " + cleanString(task.notes);
|
|
main.appendChild(notes);
|
|
}
|
|
const grantMeta =
|
|
isRecord(task.meta) && isRecord(task.meta.grantRequest)
|
|
? task.meta.grantRequest
|
|
: null;
|
|
if (grantMeta) {
|
|
const grantParts = [];
|
|
const qortalAddress = firstNonEmptyString(
|
|
[grantMeta.qortalAddress, grantMeta.address],
|
|
""
|
|
);
|
|
const amount = firstNonEmptyString(
|
|
[
|
|
grantMeta.amount,
|
|
grantMeta.grantAmountQort,
|
|
grantMeta.grantRequiredAmount,
|
|
],
|
|
""
|
|
);
|
|
if (qortalAddress) {
|
|
grantParts.push("Qortal: " + qortalAddress);
|
|
}
|
|
if (amount) {
|
|
grantParts.push("Requested: " + amount + " QORT");
|
|
}
|
|
if (grantParts.length > 0) {
|
|
const grantDetails = document.createElement("div");
|
|
grantDetails.className = "chd-ops-meta";
|
|
grantDetails.textContent = grantParts.join(" | ");
|
|
main.appendChild(grantDetails);
|
|
}
|
|
}
|
|
const updatedMeta = document.createElement("div");
|
|
updatedMeta.className = "chd-ops-meta";
|
|
updatedMeta.textContent =
|
|
"Updated: " +
|
|
formatDateTime(
|
|
cleanString(task.updatedAt) || cleanString(task.createdAt)
|
|
);
|
|
main.appendChild(updatedMeta);
|
|
row.appendChild(main);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "chd-ops-actions";
|
|
if (actionRequired) {
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Action",
|
|
operationActionLabel(task),
|
|
normalizedStatus === "blocked" ? "issues" : "partial"
|
|
)
|
|
);
|
|
}
|
|
actions.appendChild(
|
|
buildStatusPill(
|
|
"Status",
|
|
operationStatusLabel(normalizedStatus),
|
|
operationStatusLevel(normalizedStatus)
|
|
)
|
|
);
|
|
|
|
const applyStatusChange = async function (nextStatus) {
|
|
if (!activeInstanceId) {
|
|
return;
|
|
}
|
|
try {
|
|
const persistedTask = await ensurePersistedTask(
|
|
activeInstanceId,
|
|
task
|
|
);
|
|
await setInstanceOperationTaskStatus(
|
|
activeInstanceId,
|
|
persistedTask.id,
|
|
nextStatus
|
|
);
|
|
await loadInstanceOperations(
|
|
activeInstanceId,
|
|
activeInstanceDetailData
|
|
);
|
|
} catch (error) {
|
|
window.alert(
|
|
"Failed to update task status: " +
|
|
(error && error.message ? error.message : String(error))
|
|
);
|
|
}
|
|
};
|
|
|
|
if (
|
|
normalizedStatus === "pending" &&
|
|
cleanString(task.taskType) !== "intro_grant"
|
|
) {
|
|
const startButton = document.createElement("button");
|
|
startButton.type = "button";
|
|
startButton.className = "button";
|
|
startButton.textContent = "Start";
|
|
startButton.addEventListener("click", function () {
|
|
void applyStatusChange("in_progress");
|
|
});
|
|
actions.appendChild(startButton);
|
|
}
|
|
if (
|
|
cleanString(task.taskType) === "intro_grant" &&
|
|
normalizedStatus !== "completed"
|
|
) {
|
|
const defaultGrantAmount = firstNonEmptyString(
|
|
[
|
|
grantMeta &&
|
|
(grantMeta.amount ||
|
|
grantMeta.grantAmountQort ||
|
|
grantMeta.grantRequiredAmount),
|
|
task.quantity,
|
|
],
|
|
"2"
|
|
);
|
|
const amountControl = document.createElement("label");
|
|
amountControl.className = "chd-ops-amount-control";
|
|
amountControl.textContent = "Grant QORT";
|
|
const amountInput = document.createElement("input");
|
|
amountInput.type = "number";
|
|
amountInput.min = "0.00000001";
|
|
amountInput.step = "0.00000001";
|
|
amountInput.value = defaultGrantAmount;
|
|
amountInput.setAttribute("aria-label", "Grant amount in QORT");
|
|
amountControl.appendChild(amountInput);
|
|
actions.appendChild(amountControl);
|
|
|
|
const approveButton = document.createElement("button");
|
|
approveButton.type = "button";
|
|
approveButton.className = "button button-primary";
|
|
approveButton.textContent = "Approve + Send QORT";
|
|
approveButton.addEventListener("click", function () {
|
|
const amountInputValue = cleanString(amountInput.value);
|
|
const messageInput = window.prompt(
|
|
"Optional approval message",
|
|
cleanString(task.notes)
|
|
);
|
|
void (async function () {
|
|
const persistedTask = await ensurePersistedTask(
|
|
activeInstanceId,
|
|
task
|
|
);
|
|
const qortalAddress = firstNonEmptyString(
|
|
[
|
|
grantMeta && (grantMeta.qortalAddress || grantMeta.address),
|
|
persistedTask.meta &&
|
|
persistedTask.meta.grantRequest &&
|
|
(persistedTask.meta.grantRequest.qortalAddress ||
|
|
persistedTask.meta.grantRequest.address),
|
|
],
|
|
""
|
|
);
|
|
if (!qortalAddress) {
|
|
throw new Error(
|
|
"Grant request is missing the recipient Qortal address."
|
|
);
|
|
}
|
|
const amountQort = Number(amountInputValue);
|
|
if (!Number.isFinite(amountQort) || amountQort <= 0) {
|
|
throw new Error("Grant amount must be greater than zero.");
|
|
}
|
|
const transferResult = await sendCoinWithApproval(
|
|
qortalAddress,
|
|
amountQort,
|
|
0.01
|
|
);
|
|
await runIntroGrantTaskAction(
|
|
activeInstanceId,
|
|
persistedTask,
|
|
"approve",
|
|
{
|
|
amount: amountInputValue,
|
|
message: cleanString(messageInput),
|
|
sendFunds: false,
|
|
transfer: {
|
|
recipient: qortalAddress,
|
|
amountQort: amountQort,
|
|
txSignature: transferResult.txSignature,
|
|
result: transferResult.result,
|
|
},
|
|
}
|
|
);
|
|
await loadInstanceOperations(
|
|
activeInstanceId,
|
|
activeInstanceDetailData
|
|
);
|
|
})().catch(function (error) {
|
|
closeApprovalModal();
|
|
window.alert(
|
|
"Failed to approve intro grant: " +
|
|
(error && error.message ? error.message : String(error))
|
|
);
|
|
});
|
|
});
|
|
actions.appendChild(approveButton);
|
|
|
|
const rejectButton = document.createElement("button");
|
|
rejectButton.type = "button";
|
|
rejectButton.className = "button button-error";
|
|
rejectButton.textContent = "Reject";
|
|
rejectButton.addEventListener("click", function () {
|
|
const messageInput = window.prompt(
|
|
"Optional rejection message",
|
|
cleanString(task.notes)
|
|
);
|
|
void (async function () {
|
|
const persistedTask = await ensurePersistedTask(
|
|
activeInstanceId,
|
|
task
|
|
);
|
|
await runIntroGrantTaskAction(
|
|
activeInstanceId,
|
|
persistedTask,
|
|
"reject",
|
|
{
|
|
message: cleanString(messageInput),
|
|
}
|
|
);
|
|
await loadInstanceOperations(
|
|
activeInstanceId,
|
|
activeInstanceDetailData
|
|
);
|
|
})().catch(function (error) {
|
|
window.alert(
|
|
"Failed to reject intro grant: " +
|
|
(error && error.message ? error.message : String(error))
|
|
);
|
|
});
|
|
});
|
|
actions.appendChild(rejectButton);
|
|
}
|
|
if (
|
|
cleanString(task.taskType) !== "intro_grant" &&
|
|
normalizedStatus !== "completed"
|
|
) {
|
|
const completeButton = document.createElement("button");
|
|
completeButton.type = "button";
|
|
completeButton.className = "button button-primary";
|
|
completeButton.textContent = "Mark Complete";
|
|
completeButton.addEventListener("click", function () {
|
|
void applyStatusChange("completed");
|
|
});
|
|
actions.appendChild(completeButton);
|
|
} else if (cleanString(task.taskType) !== "intro_grant") {
|
|
const reopenButton = document.createElement("button");
|
|
reopenButton.type = "button";
|
|
reopenButton.className = "button";
|
|
reopenButton.textContent = "Reopen";
|
|
reopenButton.addEventListener("click", function () {
|
|
void applyStatusChange("pending");
|
|
});
|
|
actions.appendChild(reopenButton);
|
|
}
|
|
row.appendChild(actions);
|
|
operationsListEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function fetchPersistedInstanceOperations(instanceId) {
|
|
const normalizedInstanceId = cleanString(instanceId);
|
|
if (!normalizedInstanceId || !instanceOperationsUrl) {
|
|
return [];
|
|
}
|
|
const url = new URL(instanceOperationsUrl, window.location.origin);
|
|
url.searchParams.set("instanceId", normalizedInstanceId);
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
return firstArray([data.tasks]);
|
|
}
|
|
|
|
async function fetchConnectorInstanceDetailData(instanceId) {
|
|
const normalizedInstanceId = cleanString(instanceId);
|
|
if (!normalizedInstanceId || !connectorInstanceDetailUrl) {
|
|
return {};
|
|
}
|
|
const url = new URL(connectorInstanceDetailUrl, window.location.origin);
|
|
url.searchParams.set("instanceId", normalizedInstanceId);
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
return payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
}
|
|
|
|
async function scanInstancesForPendingTasks(instances) {
|
|
const list = Array.isArray(instances) ? instances : [];
|
|
if (!list.length || !connectorInstanceDetailUrl) {
|
|
connectorIdentityIndexCache = [];
|
|
renderIdentityIndex(connectorIdentityIndexCache);
|
|
return;
|
|
}
|
|
const identityRowsByKey = {};
|
|
for (let i = 0; i < list.length; i += 1) {
|
|
const entry = isRecord(list[i]) ? list[i] : null;
|
|
const instanceId = cleanString(
|
|
entry && entry.instanceId ? entry.instanceId : ""
|
|
);
|
|
if (!instanceId) {
|
|
continue;
|
|
}
|
|
try {
|
|
const detailData = await fetchConnectorInstanceDetailData(instanceId);
|
|
const detectedTasks = detectOperationsFromInstanceDetail(
|
|
instanceId,
|
|
detailData
|
|
);
|
|
const persistedTasks = await fetchPersistedInstanceOperations(
|
|
instanceId
|
|
);
|
|
const mergedTasks = mergeInstanceOperations(
|
|
persistedTasks,
|
|
detectedTasks
|
|
);
|
|
const counts = summarizeTaskCounts(mergedTasks);
|
|
const knownUsers = collectKnownUsers(detailData);
|
|
knownUsers.forEach(function (user) {
|
|
upsertIdentityIndexEntry(identityRowsByKey, user, instanceId);
|
|
});
|
|
entry.actionRequiredCount = counts.actionRequiredCount;
|
|
entry.grantRequestCount = counts.grantRequestCount;
|
|
entry.operationTaskCount = counts.taskCount;
|
|
} catch (_error) {
|
|
entry.actionRequiredCount =
|
|
coerceFiniteNumber(entry.actionRequiredCount) || 0;
|
|
entry.grantRequestCount =
|
|
coerceFiniteNumber(entry.grantRequestCount) || 0;
|
|
entry.operationTaskCount =
|
|
coerceFiniteNumber(entry.operationTaskCount) || 0;
|
|
}
|
|
const incremental = summarizeInstancesData({
|
|
instances: connectorInstancesCache,
|
|
});
|
|
renderSummaryItems(
|
|
instancesSummaryEl,
|
|
incremental.summaryItems,
|
|
"No instances returned."
|
|
);
|
|
renderInstanceCompactList(connectorInstancesCache);
|
|
}
|
|
connectorIdentityIndexCache = Object.keys(identityRowsByKey)
|
|
.map(function (key) {
|
|
return identityRowsByKey[key];
|
|
})
|
|
.sort(function (a, b) {
|
|
const instanceCountA = coerceFiniteNumber(a && a.instanceCount) || 0;
|
|
const instanceCountB = coerceFiniteNumber(b && b.instanceCount) || 0;
|
|
if (instanceCountA !== instanceCountB) {
|
|
return instanceCountB - instanceCountA;
|
|
}
|
|
const nameA = firstNonEmptyString(
|
|
[
|
|
a && a.displayName,
|
|
a && a.nextcloudUserId,
|
|
a && a.email,
|
|
a && a.qortalAddress,
|
|
],
|
|
""
|
|
).toLowerCase();
|
|
const nameB = firstNonEmptyString(
|
|
[
|
|
b && b.displayName,
|
|
b && b.nextcloudUserId,
|
|
b && b.email,
|
|
b && b.qortalAddress,
|
|
],
|
|
""
|
|
).toLowerCase();
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
renderIdentityIndex(connectorIdentityIndexCache);
|
|
const refreshed = summarizeInstancesData({
|
|
instances: connectorInstancesCache,
|
|
});
|
|
renderSummaryItems(
|
|
instancesSummaryEl,
|
|
refreshed.summaryItems,
|
|
"No instances returned."
|
|
);
|
|
renderInstanceCompactList(connectorInstancesCache);
|
|
}
|
|
|
|
async function loadInstanceOperations(instanceId, detailData) {
|
|
const normalizedInstanceId = cleanString(instanceId);
|
|
activeInstanceId = normalizedInstanceId;
|
|
activeInstanceDetailData = isRecord(detailData)
|
|
? detailData
|
|
: activeInstanceDetailData;
|
|
if (!normalizedInstanceId) {
|
|
renderSummaryItems(
|
|
operationsSummaryEl,
|
|
[],
|
|
"Select an instance to load operations."
|
|
);
|
|
if (operationsListEl) {
|
|
operationsListEl.textContent = "Select an instance to load operations.";
|
|
}
|
|
activeInstanceOperations = [];
|
|
return;
|
|
}
|
|
if (operationsListEl) {
|
|
operationsListEl.textContent = "Loading operations...";
|
|
}
|
|
renderSummaryItems(operationsSummaryEl, [], "Loading operations...");
|
|
const detectedTasks = detectOperationsFromInstanceDetail(
|
|
normalizedInstanceId,
|
|
activeInstanceDetailData
|
|
);
|
|
try {
|
|
const persistedTasks = await fetchPersistedInstanceOperations(
|
|
normalizedInstanceId
|
|
);
|
|
const merged = mergeInstanceOperations(persistedTasks, detectedTasks);
|
|
activeInstanceOperations = merged;
|
|
renderOperationsSummary(merged);
|
|
renderOperationsList(merged);
|
|
} catch (error) {
|
|
const fallback = mergeInstanceOperations([], detectedTasks);
|
|
activeInstanceOperations = fallback;
|
|
renderOperationsSummary(fallback);
|
|
renderOperationsList(fallback);
|
|
if (operationsSummaryEl) {
|
|
operationsSummaryEl.classList.add("chd-summary-empty");
|
|
operationsSummaryEl.textContent =
|
|
"Stored operations could not be loaded; showing detected tasks only.";
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateInstanceSelect(instances) {
|
|
if (!instanceSelectEl) {
|
|
return;
|
|
}
|
|
const currentSelected = String(instanceSelectEl.value || "").trim();
|
|
instanceSelectEl.innerHTML = "";
|
|
const list = Array.isArray(instances) ? instances : [];
|
|
list.forEach(function (entry) {
|
|
if (!entry || typeof entry !== "object") {
|
|
return;
|
|
}
|
|
const instanceId = String(entry.instanceId || "").trim();
|
|
if (!instanceId) {
|
|
return;
|
|
}
|
|
const labelParts = [instanceId];
|
|
const url = String(entry.nextcloudPublicUrl || "").trim();
|
|
if (url) {
|
|
labelParts.push(url);
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = instanceId;
|
|
option.textContent = labelParts.join(" — ");
|
|
if (currentSelected && currentSelected === instanceId) {
|
|
option.selected = true;
|
|
}
|
|
instanceSelectEl.appendChild(option);
|
|
});
|
|
if (!instanceSelectEl.value && instanceSelectEl.options.length > 0) {
|
|
instanceSelectEl.selectedIndex = 0;
|
|
}
|
|
}
|
|
|
|
async function loadOverview() {
|
|
const panel = infoPanels.overview;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (!overviewUrl) {
|
|
renderSummaryItems(
|
|
overviewSummaryEl,
|
|
[],
|
|
"Overview endpoint is not configured."
|
|
);
|
|
renderStatusPills(overviewStatusEl, []);
|
|
setInfoPanelError(panel, "Overview endpoint is not configured.");
|
|
renderConnectorAdminTokenControls({ connectorAdminTokenSet: true });
|
|
renderBillingAutoFulfillmentControls({});
|
|
return;
|
|
}
|
|
setInfoPanelLoading(panel, "Loading overview...");
|
|
renderSummaryItems(overviewSummaryEl, [], "Loading overview...");
|
|
renderStatusPills(overviewStatusEl, []);
|
|
try {
|
|
const payload = await requestJson(overviewUrl, {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
const presentation = summarizeOverviewData(data);
|
|
renderSummaryItems(
|
|
overviewSummaryEl,
|
|
presentation.summaryItems,
|
|
"No overview data returned."
|
|
);
|
|
renderStatusPills(overviewStatusEl, presentation.statusItems);
|
|
setInfoPanelData(panel, data, presentation.focusedData);
|
|
renderConnectorAdminTokenControls(
|
|
data && isRecord(data.chd) ? data.chd : {}
|
|
);
|
|
renderBillingAutoFulfillmentControls(
|
|
data && isRecord(data.billingAutomation) ? data.billingAutomation : {}
|
|
);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load overview: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(overviewSummaryEl, [], errorMessage);
|
|
renderStatusPills(overviewStatusEl, []);
|
|
setInfoPanelError(panel, errorMessage);
|
|
renderConnectorAdminTokenControls({ connectorAdminTokenSet: true });
|
|
renderBillingAutoFulfillmentControls({});
|
|
}
|
|
}
|
|
|
|
function renderBillingAutoFulfillmentControls(payloadData) {
|
|
const data = isRecord(payloadData) ? payloadData : {};
|
|
const presentation = summarizeBillingAutoFulfillmentData(data);
|
|
if (billingAutoFulfillmentEnabledEl) {
|
|
billingAutoFulfillmentEnabledEl.checked = coerceBoolean(data.enabled);
|
|
billingAutoFulfillmentEnabledEl.disabled = !saveBillingAutoFulfillmentUrl;
|
|
}
|
|
if (billingAutoFulfillmentSaveButton) {
|
|
billingAutoFulfillmentSaveButton.disabled = !saveBillingAutoFulfillmentUrl;
|
|
}
|
|
if (billingAutoFulfillmentRunButton) {
|
|
billingAutoFulfillmentRunButton.disabled = !runBillingAutoFulfillmentUrl;
|
|
}
|
|
if (billingAutoFulfillmentBackfillButton) {
|
|
billingAutoFulfillmentBackfillButton.disabled =
|
|
!backfillBillingAutoFulfillmentUrl;
|
|
}
|
|
renderSummaryItems(
|
|
billingAutoFulfillmentSummaryEl,
|
|
presentation.summaryItems,
|
|
"No automation data available."
|
|
);
|
|
renderStatusPills(
|
|
billingAutoFulfillmentStatusEl,
|
|
presentation.statusItems
|
|
);
|
|
}
|
|
|
|
function renderConnectorAdminTokenControls(payloadData) {
|
|
const data = isRecord(payloadData) ? payloadData : {};
|
|
const tokenConfigured = coerceBoolean(data.connectorAdminTokenSet);
|
|
if (!connectorAdminTokenCardEl) {
|
|
return;
|
|
}
|
|
if (tokenConfigured) {
|
|
connectorAdminTokenCardEl.classList.add("chd-hidden");
|
|
if (connectorAdminTokenInputEl) {
|
|
connectorAdminTokenInputEl.value = "";
|
|
}
|
|
if (connectorAdminTokenStatusEl) {
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Connector admin token is configured.";
|
|
}
|
|
return;
|
|
}
|
|
connectorAdminTokenCardEl.classList.remove("chd-hidden");
|
|
if (connectorAdminTokenInputEl) {
|
|
connectorAdminTokenInputEl.disabled = !saveConnectorAdminTokenUrl;
|
|
}
|
|
if (connectorAdminTokenSaveButton) {
|
|
connectorAdminTokenSaveButton.disabled = !saveConnectorAdminTokenUrl;
|
|
}
|
|
if (connectorAdminTokenStatusEl) {
|
|
connectorAdminTokenStatusEl.textContent = saveConnectorAdminTokenUrl
|
|
? "Enter the shared connector admin token to enable connector admin access."
|
|
: "Connector admin token save endpoint is not configured.";
|
|
}
|
|
}
|
|
|
|
async function loadConnectorSummary() {
|
|
const panel = infoPanels.connectorSummary;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (!connectorSummaryUrl) {
|
|
renderSummaryItems(
|
|
connectorSummaryViewEl,
|
|
[],
|
|
"Connector summary endpoint is not configured."
|
|
);
|
|
renderStatusPills(connectorStatusEl, []);
|
|
setInfoPanelError(panel, "Connector summary endpoint is not configured.");
|
|
return;
|
|
}
|
|
setInfoPanelLoading(panel, "Loading connector snapshot...");
|
|
renderSummaryItems(
|
|
connectorSummaryViewEl,
|
|
[],
|
|
"Loading connector snapshot..."
|
|
);
|
|
renderStatusPills(connectorStatusEl, []);
|
|
try {
|
|
const payload = await requestJson(connectorSummaryUrl, {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
const presentation = summarizeConnectorData(data);
|
|
renderSummaryItems(
|
|
connectorSummaryViewEl,
|
|
presentation.summaryItems,
|
|
"No connector snapshot data returned."
|
|
);
|
|
renderStatusPills(connectorStatusEl, presentation.statusItems);
|
|
setInfoPanelData(panel, data, presentation.focusedData);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load connector summary: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(connectorSummaryViewEl, [], errorMessage);
|
|
renderStatusPills(connectorStatusEl, []);
|
|
setInfoPanelError(panel, errorMessage);
|
|
}
|
|
}
|
|
|
|
function setInstanceDetailIdle(message) {
|
|
renderSummaryItems(
|
|
instanceDetailSummaryEl,
|
|
[],
|
|
message || "Select an instance to view details."
|
|
);
|
|
renderStatusPills(instanceDetailStatusEl, []);
|
|
setInfoPanelEmpty(
|
|
infoPanels.instanceDetail,
|
|
message || "Select an instance to view details."
|
|
);
|
|
activeInstanceId = "";
|
|
activeInstanceKnownUsers = [];
|
|
activeInstanceDetailData = null;
|
|
activeInstanceOperations = [];
|
|
populateOperationsUserSelect([]);
|
|
renderSummaryItems(
|
|
operationsSummaryEl,
|
|
[],
|
|
"Select an instance to load operations."
|
|
);
|
|
if (operationsListEl) {
|
|
operationsListEl.textContent = "Select an instance to load operations.";
|
|
}
|
|
renderSummaryItems(
|
|
userOrdersSummaryEl,
|
|
[],
|
|
"Select an instance to load reconciliation data."
|
|
);
|
|
if (userOrdersListEl) {
|
|
userOrdersListEl.textContent =
|
|
"Select an instance to load reconciliation data.";
|
|
}
|
|
renderSummaryItems(
|
|
paymenterSnapshotSummaryEl,
|
|
[],
|
|
"Select an instance to load Paymenter snapshot data."
|
|
);
|
|
renderStatusPills(paymenterSnapshotStatusEl, []);
|
|
setInfoPanelEmpty(
|
|
infoPanels.paymenterSnapshot,
|
|
"Select an instance to load Paymenter snapshot data."
|
|
);
|
|
if (instanceConnectorProfileEl) {
|
|
instanceConnectorProfileEl.value = "";
|
|
}
|
|
if (instanceConnectorUrlEl) {
|
|
instanceConnectorUrlEl.value = "";
|
|
}
|
|
if (instanceConnectorStatusEl) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Load an instance to manage connector target.";
|
|
}
|
|
if (instanceMspContactNameEl) {
|
|
instanceMspContactNameEl.value = "";
|
|
}
|
|
if (instanceMspContactEmailEl) {
|
|
instanceMspContactEmailEl.value = "";
|
|
}
|
|
if (instancePluginAdminEmailEl) {
|
|
instancePluginAdminEmailEl.value = "";
|
|
}
|
|
if (instanceAdminQortalAddressEl) {
|
|
instanceAdminQortalAddressEl.value = "";
|
|
}
|
|
if (instanceMetadataSaveButton) {
|
|
instanceMetadataSaveButton.disabled = true;
|
|
}
|
|
if (instanceMetadataStatusEl) {
|
|
instanceMetadataStatusEl.textContent =
|
|
"Load an instance to manage NuQloud MSP contact fields.";
|
|
}
|
|
if (instanceRemoveButton) {
|
|
instanceRemoveButton.disabled = true;
|
|
}
|
|
void loadPaymenterBackups();
|
|
}
|
|
|
|
async function loadConnectorRoutingConfig() {
|
|
if (!connectorRoutingStatusEl) {
|
|
return;
|
|
}
|
|
if (!connectorRoutingConfigUrl) {
|
|
connectorRoutingStatusEl.textContent =
|
|
"Connector routing endpoint is not configured.";
|
|
return;
|
|
}
|
|
connectorRoutingStatusEl.textContent = "Loading connector routing...";
|
|
try {
|
|
const payload = await requestJson(connectorRoutingConfigUrl, {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
applyConnectorRoutingMeta(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
renderConnectorRoutingControls();
|
|
} catch (error) {
|
|
connectorRoutingStatusEl.textContent =
|
|
"Failed to load connector routing: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
async function saveConnectorRoutingConfig() {
|
|
if (!connectorRoutingStatusEl) {
|
|
return;
|
|
}
|
|
if (!saveConnectorRoutingConfigUrl) {
|
|
connectorRoutingStatusEl.textContent =
|
|
"Connector routing save endpoint is not configured.";
|
|
return;
|
|
}
|
|
const payload = {
|
|
config: {
|
|
activeProfile: connectorRoutingProfileSelectEl
|
|
? trimString(connectorRoutingProfileSelectEl.value)
|
|
: "",
|
|
profiles: {
|
|
test: {
|
|
baseUrl: connectorRoutingTestUrlEl
|
|
? trimString(connectorRoutingTestUrlEl.value)
|
|
: "",
|
|
},
|
|
production: {
|
|
baseUrl: connectorRoutingProductionUrlEl
|
|
? trimString(connectorRoutingProductionUrlEl.value)
|
|
: "",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
connectorRoutingStatusEl.textContent = "Saving connector routing...";
|
|
try {
|
|
const response = await requestJson(saveConnectorRoutingConfigUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
applyConnectorRoutingMeta(
|
|
response && response.data && typeof response.data === "object"
|
|
? response.data
|
|
: {}
|
|
);
|
|
renderConnectorRoutingControls();
|
|
void loadOverview();
|
|
void loadConnectorSummary();
|
|
void loadConnectorInstances().then(function () {
|
|
if (activeInstanceId) {
|
|
return loadConnectorInstanceDetail(activeInstanceId);
|
|
}
|
|
return Promise.resolve();
|
|
});
|
|
void loadConnectorCatalogConfig();
|
|
} catch (error) {
|
|
connectorRoutingStatusEl.textContent =
|
|
"Failed to save connector routing: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
async function saveBillingAutoFulfillment() {
|
|
if (!billingAutoFulfillmentStatusEl) {
|
|
return;
|
|
}
|
|
if (!saveBillingAutoFulfillmentUrl) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Automatic invite fulfillment save endpoint is not configured.";
|
|
return;
|
|
}
|
|
const enabled = billingAutoFulfillmentEnabledEl
|
|
? Boolean(billingAutoFulfillmentEnabledEl.checked)
|
|
: false;
|
|
if (billingAutoFulfillmentSaveButton) {
|
|
billingAutoFulfillmentSaveButton.disabled = true;
|
|
}
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Saving automatic invite fulfillment settings...";
|
|
try {
|
|
const response = await requestJson(saveBillingAutoFulfillmentUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
enabled: enabled,
|
|
}),
|
|
});
|
|
const data =
|
|
response && response.data && typeof response.data === "object"
|
|
? response.data
|
|
: {};
|
|
renderBillingAutoFulfillmentControls(data);
|
|
void loadOverview();
|
|
} catch (error) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Failed to save automatic invite fulfillment: " +
|
|
(error && error.message ? error.message : String(error));
|
|
} finally {
|
|
if (billingAutoFulfillmentSaveButton) {
|
|
billingAutoFulfillmentSaveButton.disabled = !saveBillingAutoFulfillmentUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function saveConnectorAdminToken() {
|
|
if (!connectorAdminTokenStatusEl) {
|
|
return;
|
|
}
|
|
if (!saveConnectorAdminTokenUrl) {
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Connector admin token save endpoint is not configured.";
|
|
return;
|
|
}
|
|
const token = connectorAdminTokenInputEl
|
|
? trimString(connectorAdminTokenInputEl.value)
|
|
: "";
|
|
if (!token) {
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Enter the connector admin token before saving.";
|
|
return;
|
|
}
|
|
if (connectorAdminTokenSaveButton) {
|
|
connectorAdminTokenSaveButton.disabled = true;
|
|
}
|
|
if (connectorAdminTokenInputEl) {
|
|
connectorAdminTokenInputEl.disabled = true;
|
|
}
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Saving connector admin token...";
|
|
try {
|
|
const response = await requestJson(saveConnectorAdminTokenUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
connectorAdminToken: token,
|
|
}),
|
|
});
|
|
const data =
|
|
response && response.data && typeof response.data === "object"
|
|
? response.data
|
|
: {};
|
|
if (connectorAdminTokenInputEl) {
|
|
connectorAdminTokenInputEl.value = "";
|
|
}
|
|
renderConnectorAdminTokenControls(data);
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Connector admin token saved.";
|
|
void loadOverview();
|
|
} catch (error) {
|
|
if (error && error.status === 409) {
|
|
if (connectorAdminTokenInputEl) {
|
|
connectorAdminTokenInputEl.value = "";
|
|
}
|
|
renderConnectorAdminTokenControls({ connectorAdminTokenSet: true });
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Connector admin token is already configured. Refreshing overview...";
|
|
void loadOverview();
|
|
} else {
|
|
connectorAdminTokenStatusEl.textContent =
|
|
"Failed to save connector admin token: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
} finally {
|
|
if (connectorAdminTokenSaveButton) {
|
|
connectorAdminTokenSaveButton.disabled =
|
|
!saveConnectorAdminTokenUrl ||
|
|
Boolean(
|
|
connectorAdminTokenCardEl &&
|
|
connectorAdminTokenCardEl.classList.contains("chd-hidden")
|
|
);
|
|
}
|
|
if (connectorAdminTokenInputEl) {
|
|
connectorAdminTokenInputEl.disabled =
|
|
!saveConnectorAdminTokenUrl ||
|
|
Boolean(
|
|
connectorAdminTokenCardEl &&
|
|
connectorAdminTokenCardEl.classList.contains("chd-hidden")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runBillingAutoFulfillment() {
|
|
if (!billingAutoFulfillmentStatusEl) {
|
|
return;
|
|
}
|
|
if (!runBillingAutoFulfillmentUrl) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Manual invite fulfillment endpoint is not configured.";
|
|
return;
|
|
}
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
typeof window.confirm === "function" &&
|
|
!window.confirm(
|
|
"Run invite fulfillment now? This will check paid orders and send invite emails."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (billingAutoFulfillmentRunButton) {
|
|
billingAutoFulfillmentRunButton.disabled = true;
|
|
}
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Running invite fulfillment now...";
|
|
try {
|
|
const response = await requestJson(runBillingAutoFulfillmentUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
force: true,
|
|
}),
|
|
});
|
|
const data =
|
|
response && response.data && typeof response.data === "object"
|
|
? response.data
|
|
: {};
|
|
const message = firstNonEmptyString([data.message], "Manual run complete.");
|
|
billingAutoFulfillmentStatusEl.textContent = message;
|
|
if (data.overview && isRecord(data.overview)) {
|
|
renderBillingAutoFulfillmentControls(data.overview);
|
|
}
|
|
void loadOverview();
|
|
} catch (error) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Failed to run invite fulfillment: " +
|
|
(error && error.message ? error.message : String(error));
|
|
} finally {
|
|
if (billingAutoFulfillmentRunButton) {
|
|
billingAutoFulfillmentRunButton.disabled = !runBillingAutoFulfillmentUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeBackfillSince(value) {
|
|
const raw = trimString(value);
|
|
if (!raw) {
|
|
return "";
|
|
}
|
|
const parsed = new Date(raw);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return "";
|
|
}
|
|
return parsed.toISOString();
|
|
}
|
|
|
|
async function backfillBillingAutoFulfillment() {
|
|
if (!billingAutoFulfillmentStatusEl) {
|
|
return;
|
|
}
|
|
if (!backfillBillingAutoFulfillmentUrl) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Backfill invite fulfillment endpoint is not configured.";
|
|
return;
|
|
}
|
|
const since = normalizeBackfillSince(
|
|
billingAutoFulfillmentBackfillSinceEl
|
|
? billingAutoFulfillmentBackfillSinceEl.value
|
|
: ""
|
|
);
|
|
const invoiceId = billingAutoFulfillmentBackfillInvoiceEl
|
|
? trimString(billingAutoFulfillmentBackfillInvoiceEl.value)
|
|
: "";
|
|
if (!since && !invoiceId) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Choose a backfill date or enter an invoice ID.";
|
|
return;
|
|
}
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
typeof window.confirm === "function" &&
|
|
!window.confirm(
|
|
"Run invite backfill now? Monthly services require a current paid invoice, and stale invoices will be skipped."
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (billingAutoFulfillmentBackfillButton) {
|
|
billingAutoFulfillmentBackfillButton.disabled = true;
|
|
}
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Running invite fulfillment backfill...";
|
|
try {
|
|
const response = await requestJson(backfillBillingAutoFulfillmentUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
since: since,
|
|
invoiceId: invoiceId,
|
|
}),
|
|
});
|
|
const data =
|
|
response && response.data && typeof response.data === "object"
|
|
? response.data
|
|
: {};
|
|
billingAutoFulfillmentStatusEl.textContent = firstNonEmptyString(
|
|
[data.message],
|
|
"Backfill complete."
|
|
);
|
|
if (data.overview && isRecord(data.overview)) {
|
|
renderBillingAutoFulfillmentControls(data.overview);
|
|
}
|
|
void loadOverview();
|
|
} catch (error) {
|
|
billingAutoFulfillmentStatusEl.textContent =
|
|
"Failed to run invite backfill: " +
|
|
(error && error.message ? error.message : String(error));
|
|
} finally {
|
|
if (billingAutoFulfillmentBackfillButton) {
|
|
billingAutoFulfillmentBackfillButton.disabled =
|
|
!backfillBillingAutoFulfillmentUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadConnectorInstances() {
|
|
const panel = infoPanels.instances;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
if (!connectorInstancesUrl) {
|
|
renderSummaryItems(
|
|
instancesSummaryEl,
|
|
[],
|
|
"Connector instances endpoint is not configured."
|
|
);
|
|
if (instancesCompactEl) {
|
|
instancesCompactEl.textContent =
|
|
"Connector instances endpoint is not configured.";
|
|
}
|
|
setInfoPanelError(
|
|
panel,
|
|
"Connector instances endpoint is not configured."
|
|
);
|
|
setInstanceDetailIdle("Select an instance to view details.");
|
|
return;
|
|
}
|
|
setInfoPanelLoading(panel, "Loading instances...");
|
|
renderSummaryItems(instancesSummaryEl, [], "Loading instances...");
|
|
if (instancesCompactEl) {
|
|
instancesCompactEl.textContent = "Loading instances...";
|
|
}
|
|
try {
|
|
const payload = await requestJson(connectorInstancesUrl, {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
const presentation = summarizeInstancesData(data);
|
|
const instances = presentation.instances;
|
|
connectorInstancesCache = instances;
|
|
updateInstanceSelect(instances);
|
|
renderSummaryItems(
|
|
instancesSummaryEl,
|
|
presentation.summaryItems,
|
|
"No instances returned."
|
|
);
|
|
renderInstanceCompactList(instances);
|
|
setInfoPanelData(panel, data, presentation.focusedData);
|
|
await loadRemovedConnectorInstances();
|
|
void scanInstancesForPendingTasks(instances);
|
|
if (!instances.length) {
|
|
setInstanceDetailIdle("No instance details available yet.");
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load connector instances: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(instancesSummaryEl, [], errorMessage);
|
|
if (instancesCompactEl) {
|
|
instancesCompactEl.textContent = errorMessage;
|
|
}
|
|
setInfoPanelError(panel, errorMessage);
|
|
renderRemovedInstanceList([]);
|
|
setInstanceDetailIdle("Select an instance to view details.");
|
|
}
|
|
}
|
|
|
|
async function loadRemovedConnectorInstances() {
|
|
if (!removedInstancesListEl && !removedInstancesSummaryEl) {
|
|
return;
|
|
}
|
|
if (!connectorInstancesUrl) {
|
|
renderRemovedInstanceList([]);
|
|
return;
|
|
}
|
|
const url = new URL(connectorInstancesUrl, window.location.origin);
|
|
url.searchParams.set("removed", "1");
|
|
try {
|
|
const payload = await requestJson(url.toString(), {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
const data =
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {};
|
|
renderRemovedInstanceList(
|
|
Array.isArray(data.instances) ? data.instances : []
|
|
);
|
|
} catch (_error) {
|
|
renderRemovedInstanceList([]);
|
|
}
|
|
}
|
|
|
|
async function loadConnectorInstanceDetail(instanceId, options) {
|
|
const panel = infoPanels.instanceDetail;
|
|
if (!panel) {
|
|
return;
|
|
}
|
|
const normalizedInstanceId = String(instanceId || "").trim();
|
|
if (!normalizedInstanceId) {
|
|
setInstanceDetailIdle("Select an instance to view details.");
|
|
return;
|
|
}
|
|
if (!connectorInstanceDetailUrl) {
|
|
renderSummaryItems(
|
|
instanceDetailSummaryEl,
|
|
[],
|
|
"Connector instance detail endpoint is not configured."
|
|
);
|
|
renderStatusPills(instanceDetailStatusEl, []);
|
|
setInfoPanelError(
|
|
panel,
|
|
"Connector instance detail endpoint is not configured."
|
|
);
|
|
return;
|
|
}
|
|
setInfoPanelLoading(
|
|
panel,
|
|
"Loading details for " + normalizedInstanceId + "..."
|
|
);
|
|
renderSummaryItems(
|
|
instanceDetailSummaryEl,
|
|
[],
|
|
"Loading details for " + normalizedInstanceId + "..."
|
|
);
|
|
renderStatusPills(instanceDetailStatusEl, []);
|
|
try {
|
|
const data = await fetchConnectorInstanceDetailData(normalizedInstanceId);
|
|
const presentation = summarizeInstanceDetail(data, normalizedInstanceId);
|
|
activeInstanceKnownUsers = Array.isArray(presentation.knownUsers)
|
|
? presentation.knownUsers
|
|
: [];
|
|
populateOperationsUserSelect(activeInstanceKnownUsers);
|
|
activeInstanceDetailData = data;
|
|
activeInstanceId = normalizedInstanceId;
|
|
populateInstanceConnectorControls(resolveDetailInstance(data));
|
|
populateInstanceMetadataControls(resolveDetailInstance(data));
|
|
renderSummaryItems(
|
|
instanceDetailSummaryEl,
|
|
presentation.summaryItems,
|
|
"No detail data returned."
|
|
);
|
|
renderStatusPills(instanceDetailStatusEl, presentation.statusItems);
|
|
renderUserCommerceRows(presentation.userCommerceRows);
|
|
setInfoPanelData(panel, data, presentation.focusedData);
|
|
const detailOptions =
|
|
options && typeof options === "object" ? options : {};
|
|
if (Object.prototype.hasOwnProperty.call(detailOptions, "showAll")) {
|
|
setInfoPanelShowAll(panel, Boolean(detailOptions.showAll));
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(detailOptions, "showRaw")) {
|
|
setInfoPanelRawVisible(panel, Boolean(detailOptions.showRaw));
|
|
}
|
|
await loadPaymenterSnapshotForActiveInstance();
|
|
await loadPaymenterBackups();
|
|
await loadInstanceOperations(normalizedInstanceId, data);
|
|
} catch (error) {
|
|
const errorMessage =
|
|
"Failed to load instance detail: " +
|
|
(error && error.message ? error.message : String(error));
|
|
renderSummaryItems(instanceDetailSummaryEl, [], errorMessage);
|
|
renderStatusPills(instanceDetailStatusEl, []);
|
|
setInfoPanelError(panel, errorMessage);
|
|
renderSummaryItems(
|
|
userOrdersSummaryEl,
|
|
[],
|
|
"Failed to load reconciliation data because instance detail failed."
|
|
);
|
|
if (userOrdersListEl) {
|
|
userOrdersListEl.textContent =
|
|
"Failed to load reconciliation data.";
|
|
}
|
|
renderSummaryItems(
|
|
paymenterSnapshotSummaryEl,
|
|
[],
|
|
"Failed to load Paymenter snapshot because instance detail failed."
|
|
);
|
|
renderStatusPills(paymenterSnapshotStatusEl, []);
|
|
setInfoPanelError(
|
|
infoPanels.paymenterSnapshot,
|
|
"Failed to load Paymenter snapshot because instance detail failed."
|
|
);
|
|
renderSummaryItems(
|
|
operationsSummaryEl,
|
|
[],
|
|
"Failed to load operations because instance detail failed."
|
|
);
|
|
if (operationsListEl) {
|
|
operationsListEl.textContent = "Failed to load operations.";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function saveInstanceConnectorTarget() {
|
|
if (!instanceConnectorStatusEl) {
|
|
return;
|
|
}
|
|
if (!saveConnectorInstanceTargetUrl) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Connector target save endpoint is not configured.";
|
|
return;
|
|
}
|
|
const instanceId = trimString(activeInstanceId);
|
|
if (!instanceId) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Load an instance before applying a connector target.";
|
|
return;
|
|
}
|
|
const selectedProfile = instanceConnectorProfileEl
|
|
? trimString(instanceConnectorProfileEl.value)
|
|
: "";
|
|
let resolvedBaseUrl = instanceConnectorUrlEl
|
|
? trimString(instanceConnectorUrlEl.value)
|
|
: "";
|
|
if (!resolvedBaseUrl && selectedProfile) {
|
|
const profileEntry = findSelectableConnectorProfile(selectedProfile);
|
|
resolvedBaseUrl = trimString(profileEntry && profileEntry.publicBaseUrl);
|
|
if (instanceConnectorUrlEl && resolvedBaseUrl) {
|
|
instanceConnectorUrlEl.value = resolvedBaseUrl;
|
|
}
|
|
}
|
|
const payload = {
|
|
instanceId: instanceId,
|
|
connectorProfile: selectedProfile,
|
|
connectorBaseUrl: resolvedBaseUrl,
|
|
};
|
|
instanceConnectorStatusEl.textContent = "Saving connector target...";
|
|
try {
|
|
const response = await requestJson(saveConnectorInstanceTargetUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const target =
|
|
response &&
|
|
response.data &&
|
|
response.data.connectorTarget &&
|
|
typeof response.data.connectorTarget === "object"
|
|
? response.data.connectorTarget
|
|
: {};
|
|
instanceConnectorStatusEl.textContent =
|
|
"Connector target saved. The instance will pick it up on the next registration or billing sync.";
|
|
if (instanceConnectorProfileEl) {
|
|
instanceConnectorProfileEl.value = trimString(target.connectorProfile);
|
|
}
|
|
if (instanceConnectorUrlEl) {
|
|
instanceConnectorUrlEl.value = trimString(target.connectorBaseUrl);
|
|
}
|
|
await loadConnectorInstanceDetail(instanceId);
|
|
} catch (error) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Failed to save connector target: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
async function saveInstanceMetadata() {
|
|
if (!instanceMetadataStatusEl) {
|
|
return;
|
|
}
|
|
if (!saveConnectorInstanceMetadataUrl) {
|
|
instanceMetadataStatusEl.textContent =
|
|
"Instance metadata save endpoint is not configured.";
|
|
return;
|
|
}
|
|
const instanceId = trimString(activeInstanceId);
|
|
if (!instanceId) {
|
|
instanceMetadataStatusEl.textContent =
|
|
"Load an instance before saving NuQloud MSP contact fields.";
|
|
return;
|
|
}
|
|
const payload = {
|
|
instanceId: instanceId,
|
|
mspContactName: instanceMspContactNameEl
|
|
? trimString(instanceMspContactNameEl.value)
|
|
: "",
|
|
mspContactEmail: instanceMspContactEmailEl
|
|
? trimString(instanceMspContactEmailEl.value)
|
|
: "",
|
|
pluginAdminEmail: instancePluginAdminEmailEl
|
|
? trimString(instancePluginAdminEmailEl.value)
|
|
: "",
|
|
adminQortalAddress: instanceAdminQortalAddressEl
|
|
? trimString(instanceAdminQortalAddressEl.value)
|
|
: "",
|
|
};
|
|
instanceMetadataStatusEl.textContent =
|
|
"Saving NuQloud MSP contact fields...";
|
|
try {
|
|
const response = await requestJson(saveConnectorInstanceMetadataUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const metadata =
|
|
response &&
|
|
response.data &&
|
|
response.data.metadata &&
|
|
typeof response.data.metadata === "object"
|
|
? response.data.metadata
|
|
: {};
|
|
if (instanceMspContactNameEl) {
|
|
instanceMspContactNameEl.value = trimString(metadata.mspContactName);
|
|
}
|
|
if (instanceMspContactEmailEl) {
|
|
instanceMspContactEmailEl.value = trimString(metadata.mspContactEmail);
|
|
}
|
|
if (instancePluginAdminEmailEl) {
|
|
instancePluginAdminEmailEl.value = trimString(
|
|
metadata.pluginAdminEmail
|
|
);
|
|
}
|
|
if (instanceAdminQortalAddressEl) {
|
|
instanceAdminQortalAddressEl.value = trimString(
|
|
metadata.adminQortalAddress
|
|
);
|
|
}
|
|
const mirrored = Boolean(
|
|
response &&
|
|
response.data &&
|
|
response.data.compatibility &&
|
|
response.data.compatibility.mirroredLegacyAdminEmail
|
|
);
|
|
instanceMetadataStatusEl.textContent = mirrored
|
|
? "NuQloud MSP contact fields saved. Legacy connector email mirror updated for compatibility."
|
|
: "NuQloud MSP contact fields saved.";
|
|
await loadConnectorInstanceDetail(instanceId);
|
|
} catch (error) {
|
|
instanceMetadataStatusEl.textContent =
|
|
"Failed to save NuQloud MSP contact fields: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
async function saveConnectorInstanceState(instanceId, action) {
|
|
if (!saveConnectorInstanceStateUrl) {
|
|
throw new Error("Connector instance state endpoint is not configured.");
|
|
}
|
|
const normalizedInstanceId = trimString(instanceId);
|
|
const normalizedAction = trimString(action).toLowerCase();
|
|
if (!normalizedInstanceId) {
|
|
throw new Error("instanceId is required");
|
|
}
|
|
if (normalizedAction !== "remove" && normalizedAction !== "restore") {
|
|
throw new Error("Unsupported instance action");
|
|
}
|
|
const response = await requestJson(saveConnectorInstanceStateUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
instanceId: normalizedInstanceId,
|
|
action: normalizedAction,
|
|
}),
|
|
});
|
|
const backupPath = trimString(
|
|
response && response.data && response.data.backupPath
|
|
);
|
|
const statusMessage =
|
|
normalizedAction === "remove"
|
|
? "Instance removed from the active configuration."
|
|
: "Instance restored to the active configuration.";
|
|
if (instanceConnectorStatusEl) {
|
|
instanceConnectorStatusEl.textContent = backupPath
|
|
? statusMessage + " Backup: " + backupPath
|
|
: statusMessage;
|
|
}
|
|
if (
|
|
normalizedAction === "remove" &&
|
|
trimString(activeInstanceId) === normalizedInstanceId
|
|
) {
|
|
setInstanceDetailIdle("Instance removed from active configuration.");
|
|
}
|
|
await loadConnectorInstances();
|
|
if (normalizedAction === "restore") {
|
|
if (instanceSelectEl) {
|
|
instanceSelectEl.value = normalizedInstanceId;
|
|
}
|
|
await loadConnectorInstanceDetail(normalizedInstanceId);
|
|
}
|
|
}
|
|
|
|
function normalizeCatalogConfigPayload(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return {};
|
|
}
|
|
return payload.config && typeof payload.config === "object"
|
|
? payload.config
|
|
: payload;
|
|
}
|
|
|
|
function buildCatalogProfileStateFromConfig(configValue) {
|
|
const source =
|
|
configValue && typeof configValue === "object" ? configValue : {};
|
|
const packageSummaryRows = packageSummaryMapToRows(
|
|
source.packageSummaryMap || {}
|
|
);
|
|
const nextAudienceRows = ensureDefaultAudienceRows(
|
|
audienceMapToRows(source.audienceCatalogs || {})
|
|
);
|
|
return {
|
|
packageRows: ensureDefaultPackageRows(
|
|
packageMapToRows(
|
|
source.packageProductMap || {},
|
|
source.packagePriceMap || {}
|
|
),
|
|
packageSummaryRows
|
|
),
|
|
addonRows: mapToRows(
|
|
source.addonProductMap || {},
|
|
source.addonLabelMap || {},
|
|
source.addonSummaryMap || {},
|
|
source.addonPriceMap || {}
|
|
),
|
|
oneTimeRows: mapToRows(
|
|
source.oneTimeProductMap || {},
|
|
source.oneTimeLabelMap || {},
|
|
source.oneTimeSummaryMap || {},
|
|
source.oneTimePriceMap || {}
|
|
),
|
|
audienceRows: nextAudienceRows,
|
|
extraOffers: extraOffersToRows(source.extraOffers || []),
|
|
selectedAudienceKey: trimString(
|
|
nextAudienceRows[0] && nextAudienceRows[0].key
|
|
),
|
|
};
|
|
}
|
|
|
|
function buildProfileConfigsFromConfigValue(configValue) {
|
|
const source =
|
|
configValue && typeof configValue === "object" ? configValue : {};
|
|
const result = {};
|
|
const profileConfigs =
|
|
source.profileConfigs && typeof source.profileConfigs === "object"
|
|
? source.profileConfigs
|
|
: {};
|
|
Object.keys(profileConfigs).forEach(function (profileId) {
|
|
const normalized = trimString(profileId);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
result[normalized] = buildCatalogProfileStateFromConfig(
|
|
profileConfigs[profileId]
|
|
);
|
|
});
|
|
if (!Object.keys(result).length) {
|
|
const activeProfile = trimString(
|
|
source.runtime && source.runtime.activePaymenterProfile
|
|
);
|
|
if (activeProfile) {
|
|
result[activeProfile] = buildCatalogProfileStateFromConfig(source);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizePackageMetaRows(rows) {
|
|
if (!Array.isArray(rows)) {
|
|
return [];
|
|
}
|
|
return rows
|
|
.map(function (entry) {
|
|
if (!entry || typeof entry !== "object") {
|
|
return null;
|
|
}
|
|
const code = trimString(entry.code);
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
return {
|
|
code: code,
|
|
label: trimString(entry.label) || code,
|
|
summary: trimString(entry.summary),
|
|
seedInAdmin: entry.seedInAdmin !== false,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function applyConnectorPackageMeta(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return;
|
|
}
|
|
const seedRows = normalizePackageMetaRows(payload.packageSeeds);
|
|
if (seedRows.length) {
|
|
defaultPackageRows = seedRows;
|
|
return;
|
|
}
|
|
const metaRows = normalizePackageMetaRows(payload.packageMeta).filter(
|
|
function (entry) {
|
|
return entry.seedInAdmin !== false;
|
|
}
|
|
);
|
|
if (metaRows.length) {
|
|
defaultPackageRows = metaRows;
|
|
}
|
|
}
|
|
|
|
function applyConnectorRuntimeMeta(payload) {
|
|
const runtime =
|
|
payload && payload.runtime && typeof payload.runtime === "object"
|
|
? payload.runtime
|
|
: {};
|
|
catalogConfigState.runtime = {
|
|
activePaymenterProfile: trimString(runtime.activePaymenterProfile),
|
|
availablePaymenterProfiles: Array.isArray(
|
|
runtime.availablePaymenterProfiles
|
|
)
|
|
? runtime.availablePaymenterProfiles
|
|
: [],
|
|
availableCatalogConfigProfiles: Array.isArray(
|
|
runtime.availableCatalogConfigProfiles
|
|
)
|
|
? runtime.availableCatalogConfigProfiles
|
|
: [],
|
|
availableConnectorProfiles: Array.isArray(
|
|
runtime.availableConnectorProfiles
|
|
)
|
|
? runtime.availableConnectorProfiles
|
|
: [],
|
|
currentPaymenterBaseUrl: trimString(runtime.currentPaymenterBaseUrl),
|
|
diagnostics:
|
|
runtime.diagnostics && typeof runtime.diagnostics === "object"
|
|
? runtime.diagnostics
|
|
: {},
|
|
};
|
|
availableConnectorProfiles = Array.isArray(
|
|
runtime.availableConnectorProfiles
|
|
)
|
|
? runtime.availableConnectorProfiles
|
|
: [];
|
|
}
|
|
|
|
function createEmptyCatalogProfileState() {
|
|
return {
|
|
packageRows: [],
|
|
addonRows: [],
|
|
oneTimeRows: [],
|
|
audienceRows: [],
|
|
extraOffers: [],
|
|
selectedAudienceKey: "",
|
|
};
|
|
}
|
|
|
|
function cloneCatalogProfileState(source) {
|
|
const safe =
|
|
source && typeof source === "object"
|
|
? source
|
|
: createEmptyCatalogProfileState();
|
|
return {
|
|
packageRows: Array.isArray(safe.packageRows)
|
|
? safe.packageRows.map(function (row) {
|
|
return { ...row, price: trimString(row && row.price) };
|
|
})
|
|
: [],
|
|
addonRows: Array.isArray(safe.addonRows)
|
|
? safe.addonRows.map(function (row) {
|
|
return { ...row, price: trimString(row && row.price) };
|
|
})
|
|
: [],
|
|
oneTimeRows: Array.isArray(safe.oneTimeRows)
|
|
? safe.oneTimeRows.map(function (row) {
|
|
return { ...row, price: trimString(row && row.price) };
|
|
})
|
|
: [],
|
|
audienceRows: Array.isArray(safe.audienceRows)
|
|
? safe.audienceRows.map(function (row) {
|
|
return {
|
|
key: trimString(row && row.key),
|
|
packages: Array.isArray(row && row.packages)
|
|
? row.packages.slice()
|
|
: [],
|
|
addons: Array.isArray(row && row.addons)
|
|
? row.addons.slice()
|
|
: [],
|
|
oneTime: Array.isArray(row && row.oneTime)
|
|
? row.oneTime.slice()
|
|
: [],
|
|
extraOfferCodes: Array.isArray(row && row.extraOfferCodes)
|
|
? row.extraOfferCodes.slice()
|
|
: [],
|
|
};
|
|
})
|
|
: [],
|
|
extraOffers: Array.isArray(safe.extraOffers)
|
|
? safe.extraOffers.map(function (row) {
|
|
return { ...row };
|
|
})
|
|
: [],
|
|
selectedAudienceKey: trimString(safe.selectedAudienceKey),
|
|
};
|
|
}
|
|
|
|
function getAvailableCatalogScopeProfiles() {
|
|
const runtimeProfiles = Array.isArray(
|
|
catalogConfigState.runtime.availableCatalogConfigProfiles
|
|
)
|
|
? catalogConfigState.runtime.availableCatalogConfigProfiles
|
|
: [];
|
|
const ids = runtimeProfiles
|
|
.map(function (profile) {
|
|
return trimString(profile && profile.id);
|
|
})
|
|
.filter(Boolean);
|
|
Object.keys(catalogConfigState.profileConfigs || {}).forEach(function (
|
|
key
|
|
) {
|
|
const normalized = trimString(key);
|
|
if (normalized && ids.indexOf(normalized) === -1) {
|
|
ids.push(normalized);
|
|
}
|
|
});
|
|
return ids;
|
|
}
|
|
|
|
function persistCurrentCatalogScopeState() {
|
|
const scopeId = trimString(catalogConfigState.selectedCatalogScopeProfile);
|
|
if (!scopeId) {
|
|
return;
|
|
}
|
|
catalogConfigState.profileConfigs[scopeId] = cloneCatalogProfileState({
|
|
packageRows: catalogConfigState.packageRows,
|
|
addonRows: catalogConfigState.addonRows,
|
|
oneTimeRows: catalogConfigState.oneTimeRows,
|
|
audienceRows: catalogConfigState.audienceRows,
|
|
extraOffers: catalogConfigState.extraOffers,
|
|
selectedAudienceKey: catalogConfigState.selectedAudienceKey,
|
|
});
|
|
}
|
|
|
|
function applyCatalogScopeState(scopeId) {
|
|
const normalized = trimString(scopeId);
|
|
const stored =
|
|
normalized && catalogConfigState.profileConfigs[normalized]
|
|
? catalogConfigState.profileConfigs[normalized]
|
|
: createEmptyCatalogProfileState();
|
|
const next = cloneCatalogProfileState(stored);
|
|
catalogConfigState.selectedCatalogScopeProfile = normalized;
|
|
catalogConfigState.packageRows = ensureDefaultPackageRows(next.packageRows);
|
|
catalogConfigState.addonRows = next.addonRows;
|
|
catalogConfigState.oneTimeRows = next.oneTimeRows;
|
|
catalogConfigState.audienceRows = ensureDefaultAudienceRows(
|
|
next.audienceRows
|
|
);
|
|
catalogConfigState.extraOffers = next.extraOffers;
|
|
catalogConfigState.selectedAudienceKey =
|
|
trimString(next.selectedAudienceKey) ||
|
|
trimString(
|
|
catalogConfigState.audienceRows[0] &&
|
|
catalogConfigState.audienceRows[0].key
|
|
);
|
|
}
|
|
|
|
function applyConnectorRoutingMeta(payload) {
|
|
const config =
|
|
payload && payload.config && typeof payload.config === "object"
|
|
? payload.config
|
|
: {};
|
|
const profiles =
|
|
config && config.profiles && typeof config.profiles === "object"
|
|
? config.profiles
|
|
: {};
|
|
const runtime =
|
|
payload && payload.runtime && typeof payload.runtime === "object"
|
|
? payload.runtime
|
|
: {};
|
|
connectorRoutingState = {
|
|
activeProfile: trimString(config.activeProfile),
|
|
profiles: {
|
|
test: {
|
|
baseUrl: trimString(profiles.test && profiles.test.baseUrl),
|
|
},
|
|
production: {
|
|
baseUrl: trimString(
|
|
profiles.production && profiles.production.baseUrl
|
|
),
|
|
},
|
|
},
|
|
effectiveBaseUrl: trimString(runtime.effectiveBaseUrl),
|
|
fallbackBaseUrl: trimString(runtime.fallbackBaseUrl),
|
|
};
|
|
}
|
|
|
|
function renderConnectorRoutingControls() {
|
|
if (connectorRoutingProfileSelectEl) {
|
|
connectorRoutingProfileSelectEl.value = trimString(
|
|
connectorRoutingState.activeProfile
|
|
);
|
|
}
|
|
if (connectorRoutingTestUrlEl) {
|
|
connectorRoutingTestUrlEl.value = trimString(
|
|
connectorRoutingState.profiles.test &&
|
|
connectorRoutingState.profiles.test.baseUrl
|
|
);
|
|
}
|
|
if (connectorRoutingProductionUrlEl) {
|
|
connectorRoutingProductionUrlEl.value = trimString(
|
|
connectorRoutingState.profiles.production &&
|
|
connectorRoutingState.profiles.production.baseUrl
|
|
);
|
|
}
|
|
if (connectorRoutingStatusEl) {
|
|
const active =
|
|
trimString(connectorRoutingState.activeProfile) || "default";
|
|
const effective = trimString(connectorRoutingState.effectiveBaseUrl);
|
|
const fallback = trimString(connectorRoutingState.fallbackBaseUrl);
|
|
let text = "Active connector mode: " + active;
|
|
if (effective) {
|
|
text += " | Effective URL: " + effective;
|
|
}
|
|
if (fallback && fallback !== effective) {
|
|
text += " | Fallback URL: " + fallback;
|
|
}
|
|
connectorRoutingStatusEl.textContent = text;
|
|
}
|
|
}
|
|
|
|
function renderRuntimeDiagnostics() {
|
|
if (!connectorRuntimeDiagnosticsEl) {
|
|
return;
|
|
}
|
|
const diagnostics =
|
|
catalogConfigState.runtime &&
|
|
catalogConfigState.runtime.diagnostics &&
|
|
typeof catalogConfigState.runtime.diagnostics === "object"
|
|
? catalogConfigState.runtime.diagnostics
|
|
: {};
|
|
const items = [
|
|
{
|
|
label: "Effective Paymenter Profile",
|
|
value: trimString(diagnostics.effectivePaymenterProfile) || "Not set",
|
|
},
|
|
{
|
|
label: "Default Paymenter Profile",
|
|
value: trimString(diagnostics.defaultPaymenterProfile) || "Not set",
|
|
},
|
|
{
|
|
label: "Admin Active Profile",
|
|
value: trimString(diagnostics.adminActivePaymenterProfile) || "Not set",
|
|
},
|
|
{
|
|
label: "Env Active Profile",
|
|
value: trimString(diagnostics.envActivePaymenterProfile) || "Not set",
|
|
},
|
|
{
|
|
label: "Effective Paymenter Base URL",
|
|
value: trimString(diagnostics.effectivePaymenterBaseUrl) || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Catalog Category",
|
|
value: trimString(diagnostics.effectiveCatalogCategoryId) || "Not set",
|
|
},
|
|
{
|
|
label: "Checkout Link Endpoint",
|
|
value:
|
|
trimString(diagnostics.effectiveCheckoutLinkEndpoint) || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Customer External Field",
|
|
value:
|
|
trimString(diagnostics.effectiveCustomerExternalField) || "Not set",
|
|
},
|
|
{
|
|
label: "Qortal Address Upsert",
|
|
value:
|
|
trimString(diagnostics.effectiveQortalAddressUpsertEndpoint) ||
|
|
"Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Webhook Token",
|
|
value: diagnostics.effectiveWebhookTokenConfigured
|
|
? "Configured"
|
|
: "Not configured",
|
|
},
|
|
{
|
|
label: "SSO Bridge",
|
|
value: diagnostics.effectiveSsoBridgeEnabled ? "Enabled" : "Disabled",
|
|
},
|
|
{
|
|
label: "Paymenter SSO URL",
|
|
value: trimString(diagnostics.effectivePaymenterSsoUrl) || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "SSO Verify Path",
|
|
value: trimString(diagnostics.effectiveSsoVerifyPath) || "Not set",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "Scoped Package Count",
|
|
value: String(diagnostics.scopedPackageCount ?? 0),
|
|
},
|
|
{
|
|
label: "Scoped Add-on Count",
|
|
value: String(diagnostics.scopedAddonCount ?? 0),
|
|
},
|
|
{
|
|
label: "Scoped One-Time Count",
|
|
value: String(diagnostics.scopedOneTimeCount ?? 0),
|
|
},
|
|
{
|
|
label: "Scoped Audience Count",
|
|
value: String(diagnostics.scopedAudienceCount ?? 0),
|
|
},
|
|
{
|
|
label: "Scoped Extra Offer Count",
|
|
value: String(diagnostics.scopedExtraOfferCount ?? 0),
|
|
},
|
|
{
|
|
label: "Profile Scopes Present",
|
|
value:
|
|
Array.isArray(diagnostics.availableProfileScopes) &&
|
|
diagnostics.availableProfileScopes.length
|
|
? diagnostics.availableProfileScopes.join(", ")
|
|
: "None",
|
|
wide: true,
|
|
},
|
|
{
|
|
label: "SSO Redirect Allowlist",
|
|
value:
|
|
Array.isArray(diagnostics.effectiveSsoRedirectAllowlist) &&
|
|
diagnostics.effectiveSsoRedirectAllowlist.length
|
|
? diagnostics.effectiveSsoRedirectAllowlist.join(", ")
|
|
: "None",
|
|
wide: true,
|
|
},
|
|
];
|
|
renderSummaryItems(
|
|
connectorRuntimeDiagnosticsEl,
|
|
items,
|
|
"Refresh config to load diagnostics."
|
|
);
|
|
}
|
|
|
|
function getSelectablePaymenterProfiles() {
|
|
const seen = {};
|
|
const options = [];
|
|
(catalogConfigState.runtime.availablePaymenterProfiles || []).forEach(
|
|
function (profile) {
|
|
const id = trimString(profile && profile.id);
|
|
if (!id || Object.prototype.hasOwnProperty.call(seen, id)) {
|
|
return;
|
|
}
|
|
seen[id] = true;
|
|
options.push({
|
|
id: id,
|
|
baseUrl: trimString(profile && profile.baseUrl),
|
|
});
|
|
}
|
|
);
|
|
const active = trimString(
|
|
catalogConfigState.runtime.activePaymenterProfile
|
|
);
|
|
if (active && !Object.prototype.hasOwnProperty.call(seen, active)) {
|
|
options.push({
|
|
id: active,
|
|
baseUrl: trimString(catalogConfigState.runtime.currentPaymenterBaseUrl),
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function getSelectedPaymenterViewProfile() {
|
|
const chosen = trimString(
|
|
paymenterProfileViewEl && paymenterProfileViewEl.value
|
|
);
|
|
if (chosen) {
|
|
return chosen;
|
|
}
|
|
return trimString(catalogConfigState.runtime.activePaymenterProfile);
|
|
}
|
|
|
|
function getSelectableConnectorProfiles() {
|
|
const merged = {};
|
|
availableConnectorProfiles.forEach(function (profile) {
|
|
const id = trimString(profile && profile.id);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
merged[id] = {
|
|
id: id,
|
|
publicBaseUrl: trimString(profile && profile.publicBaseUrl),
|
|
};
|
|
});
|
|
const routingProfiles =
|
|
connectorRoutingState &&
|
|
connectorRoutingState.profiles &&
|
|
typeof connectorRoutingState.profiles === "object"
|
|
? connectorRoutingState.profiles
|
|
: {};
|
|
Object.keys(routingProfiles).forEach(function (profileId) {
|
|
const id = trimString(profileId);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
const existing =
|
|
merged[id] && typeof merged[id] === "object"
|
|
? merged[id]
|
|
: { id: id, publicBaseUrl: "" };
|
|
const routingBaseUrl = trimString(
|
|
routingProfiles[profileId] && routingProfiles[profileId].baseUrl
|
|
);
|
|
merged[id] = {
|
|
id: id,
|
|
publicBaseUrl: routingBaseUrl || trimString(existing.publicBaseUrl),
|
|
};
|
|
});
|
|
return Object.keys(merged)
|
|
.sort(function (left, right) {
|
|
return left.localeCompare(right, undefined, { sensitivity: "base" });
|
|
})
|
|
.map(function (id) {
|
|
return merged[id];
|
|
});
|
|
}
|
|
|
|
function findSelectableConnectorProfile(profileId) {
|
|
const desiredId = trimString(profileId);
|
|
if (!desiredId) {
|
|
return null;
|
|
}
|
|
return (
|
|
getSelectableConnectorProfiles().find(function (entry) {
|
|
return trimString(entry && entry.id) === desiredId;
|
|
}) || null
|
|
);
|
|
}
|
|
|
|
function renderRuntimeControls() {
|
|
renderConnectorRoutingControls();
|
|
if (paymenterProfileSelectEl) {
|
|
paymenterProfileSelectEl.innerHTML = "";
|
|
const defaultOption = document.createElement("option");
|
|
defaultOption.value = "";
|
|
defaultOption.textContent = "Use connector default";
|
|
paymenterProfileSelectEl.appendChild(defaultOption);
|
|
(catalogConfigState.runtime.availablePaymenterProfiles || []).forEach(
|
|
function (profile) {
|
|
const id = trimString(profile && profile.id);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = id;
|
|
option.textContent =
|
|
id +
|
|
(trimString(profile.baseUrl)
|
|
? " (" + trimString(profile.baseUrl) + ")"
|
|
: "");
|
|
paymenterProfileSelectEl.appendChild(option);
|
|
}
|
|
);
|
|
paymenterProfileSelectEl.value = trimString(
|
|
catalogConfigState.runtime.activePaymenterProfile
|
|
);
|
|
}
|
|
if (catalogScopeProfileSelectEl) {
|
|
const current =
|
|
trimString(catalogConfigState.selectedCatalogScopeProfile) ||
|
|
trimString(catalogConfigState.runtime.activePaymenterProfile);
|
|
catalogScopeProfileSelectEl.innerHTML = "";
|
|
getAvailableCatalogScopeProfiles().forEach(function (profileId) {
|
|
const option = document.createElement("option");
|
|
option.value = profileId;
|
|
option.textContent = profileId;
|
|
catalogScopeProfileSelectEl.appendChild(option);
|
|
});
|
|
if (!catalogScopeProfileSelectEl.options.length) {
|
|
const fallbackOption = document.createElement("option");
|
|
fallbackOption.value = "";
|
|
fallbackOption.textContent = "No profile scopes available";
|
|
catalogScopeProfileSelectEl.appendChild(fallbackOption);
|
|
}
|
|
catalogScopeProfileSelectEl.value = Array.from(
|
|
catalogScopeProfileSelectEl.options
|
|
).some(function (opt) {
|
|
return trimString(opt.value) === current;
|
|
})
|
|
? current
|
|
: trimString(
|
|
catalogScopeProfileSelectEl.options[0] &&
|
|
catalogScopeProfileSelectEl.options[0].value
|
|
);
|
|
catalogConfigState.selectedCatalogScopeProfile = trimString(
|
|
catalogScopeProfileSelectEl.value
|
|
);
|
|
}
|
|
if (paymenterProfileStatusEl) {
|
|
const active =
|
|
trimString(catalogConfigState.runtime.activePaymenterProfile) ||
|
|
"connector default";
|
|
const baseUrl = trimString(
|
|
catalogConfigState.runtime.currentPaymenterBaseUrl
|
|
);
|
|
const scope =
|
|
trimString(catalogConfigState.selectedCatalogScopeProfile) || active;
|
|
paymenterProfileStatusEl.textContent =
|
|
"Active backend: " +
|
|
active +
|
|
(baseUrl ? " | Base URL: " + baseUrl : "") +
|
|
" | Editing catalog scope: " +
|
|
scope;
|
|
}
|
|
if (paymenterProfileViewEl) {
|
|
const current =
|
|
trimString(paymenterProfileViewEl.value) ||
|
|
trimString(catalogConfigState.runtime.activePaymenterProfile);
|
|
paymenterProfileViewEl.innerHTML = "";
|
|
const defaultOption = document.createElement("option");
|
|
defaultOption.value = "";
|
|
defaultOption.textContent = "Use connector default";
|
|
paymenterProfileViewEl.appendChild(defaultOption);
|
|
getSelectablePaymenterProfiles().forEach(function (profile) {
|
|
const id = trimString(profile && profile.id);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = id;
|
|
option.textContent =
|
|
id +
|
|
(trimString(profile.baseUrl)
|
|
? " (" + trimString(profile.baseUrl) + ")"
|
|
: "");
|
|
paymenterProfileViewEl.appendChild(option);
|
|
});
|
|
paymenterProfileViewEl.value = Array.from(
|
|
paymenterProfileViewEl.options
|
|
).some(function (opt) {
|
|
return trimString(opt.value) === current;
|
|
})
|
|
? current
|
|
: "";
|
|
}
|
|
renderRuntimeDiagnostics();
|
|
if (instanceConnectorProfileEl) {
|
|
const current = trimString(instanceConnectorProfileEl.value);
|
|
instanceConnectorProfileEl.innerHTML = "";
|
|
const none = document.createElement("option");
|
|
none.value = "";
|
|
none.textContent = "No profile target";
|
|
instanceConnectorProfileEl.appendChild(none);
|
|
getSelectableConnectorProfiles().forEach(function (profile) {
|
|
const id = trimString(profile && profile.id);
|
|
if (!id) {
|
|
return;
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = id;
|
|
option.textContent =
|
|
id +
|
|
(trimString(profile.publicBaseUrl)
|
|
? " (" + trimString(profile.publicBaseUrl) + ")"
|
|
: "");
|
|
instanceConnectorProfileEl.appendChild(option);
|
|
});
|
|
instanceConnectorProfileEl.value = Array.from(
|
|
instanceConnectorProfileEl.options
|
|
).some(function (opt) {
|
|
return opt.value === current;
|
|
})
|
|
? current
|
|
: "";
|
|
}
|
|
if (activeInstanceDetailData) {
|
|
populateInstanceConnectorControls(
|
|
resolveDetailInstance(activeInstanceDetailData)
|
|
);
|
|
}
|
|
}
|
|
|
|
function trimString(value) {
|
|
return String(value || "").trim();
|
|
}
|
|
|
|
function parseCsv(value) {
|
|
return trimString(value)
|
|
.split(",")
|
|
.map(function (entry) {
|
|
return trimString(entry);
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function toIdString(value) {
|
|
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : "";
|
|
}
|
|
|
|
function mapToRows(productMap, labelMap, summaryMap, priceMap) {
|
|
const rows = [];
|
|
const keys = new Set();
|
|
Object.keys(productMap || {}).forEach(function (key) {
|
|
keys.add(key);
|
|
});
|
|
Object.keys(labelMap || {}).forEach(function (key) {
|
|
keys.add(key);
|
|
});
|
|
Object.keys(summaryMap || {}).forEach(function (key) {
|
|
keys.add(key);
|
|
});
|
|
Object.keys(priceMap || {}).forEach(function (key) {
|
|
keys.add(key);
|
|
});
|
|
keys.forEach(function (code) {
|
|
rows.push({
|
|
code: trimString(code),
|
|
productId: toIdString(productMap && productMap[code]),
|
|
label: trimString(labelMap && labelMap[code]),
|
|
summary: trimString(summaryMap && summaryMap[code]),
|
|
price: trimString(priceMap && priceMap[code]),
|
|
});
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function packageSummaryMapToRows(summaryMap) {
|
|
const rows = [];
|
|
Object.keys(summaryMap || {}).forEach(function (code) {
|
|
rows.push({
|
|
code: trimString(code),
|
|
summary: trimString(summaryMap[code]),
|
|
});
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function getDefaultPackageMeta(code) {
|
|
const normalized = trimString(code);
|
|
for (let index = 0; index < defaultPackageRows.length; index += 1) {
|
|
if (trimString(defaultPackageRows[index].code) === normalized) {
|
|
return defaultPackageRows[index];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function packageMapToRows(packageProductMap, packagePriceMap) {
|
|
const source =
|
|
packageProductMap && typeof packageProductMap === "object"
|
|
? packageProductMap
|
|
: {};
|
|
const priceSource =
|
|
packagePriceMap && typeof packagePriceMap === "object"
|
|
? packagePriceMap
|
|
: {};
|
|
const keys = new Set();
|
|
Object.keys(source).forEach(function (code) {
|
|
keys.add(code);
|
|
});
|
|
Object.keys(priceSource).forEach(function (code) {
|
|
keys.add(code);
|
|
});
|
|
return Array.from(keys).map(function (code) {
|
|
return {
|
|
code: trimString(code),
|
|
productId: toIdString(source[code]),
|
|
summary: "",
|
|
price: trimString(priceSource[code]),
|
|
};
|
|
});
|
|
}
|
|
|
|
function ensureDefaultPackageRows(rows, packageSummaryRows) {
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
const summaryList = Array.isArray(packageSummaryRows)
|
|
? packageSummaryRows
|
|
: [];
|
|
const existing = new Map();
|
|
const summaries = new Map();
|
|
summaryList.forEach(function (row) {
|
|
const code = trimString(row && row.code);
|
|
if (!code || summaries.has(code)) {
|
|
return;
|
|
}
|
|
summaries.set(code, trimString(row && row.summary));
|
|
});
|
|
list.forEach(function (row) {
|
|
const code = trimString(row && row.code);
|
|
if (!code || existing.has(code)) {
|
|
return;
|
|
}
|
|
existing.set(code, {
|
|
code: code,
|
|
productId: toIdString(row.productId),
|
|
summary: trimString(row.summary) || summaries.get(code) || "",
|
|
price: trimString(row.price),
|
|
});
|
|
});
|
|
const ordered = [];
|
|
existing.forEach(function (value, key) {
|
|
const defaults = getDefaultPackageMeta(key);
|
|
if (!trimString(value.summary)) {
|
|
value.summary =
|
|
summaries.get(key) || trimString(defaults && defaults.summary);
|
|
}
|
|
ordered.push(value);
|
|
});
|
|
return ordered;
|
|
}
|
|
|
|
function audienceMapToRows(audienceCatalogs) {
|
|
return Object.keys(audienceCatalogs || {}).map(function (key) {
|
|
const entry = audienceCatalogs[key] || {};
|
|
return {
|
|
key: trimString(key),
|
|
packages: Array.isArray(entry.packages)
|
|
? entry.packages
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
addons: Array.isArray(entry.addons)
|
|
? entry.addons
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
oneTime: Array.isArray(entry.oneTime)
|
|
? entry.oneTime
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
extraOfferCodes: Array.isArray(entry.extraOfferCodes)
|
|
? entry.extraOfferCodes
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
};
|
|
});
|
|
}
|
|
|
|
function createAudienceRow(key) {
|
|
return {
|
|
key: trimString(key),
|
|
packages: [],
|
|
addons: [],
|
|
oneTime: [],
|
|
extraOfferCodes: [],
|
|
};
|
|
}
|
|
|
|
function getDefaultAudienceMeta(key) {
|
|
return (
|
|
DEFAULT_AUDIENCES.find(function (entry) {
|
|
return trimString(entry.key) === trimString(key);
|
|
}) || null
|
|
);
|
|
}
|
|
|
|
function isDefaultAudienceKey(key) {
|
|
return Boolean(getDefaultAudienceMeta(key));
|
|
}
|
|
|
|
function ensureDefaultAudienceRows(rows) {
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
const existingMap = new Map();
|
|
list.forEach(function (row) {
|
|
const key = trimString(row && row.key);
|
|
if (!key || existingMap.has(key)) {
|
|
return;
|
|
}
|
|
existingMap.set(key, {
|
|
key: key,
|
|
packages: Array.isArray(row.packages)
|
|
? row.packages
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
addons: Array.isArray(row.addons)
|
|
? row.addons
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
oneTime: Array.isArray(row.oneTime)
|
|
? row.oneTime
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
extraOfferCodes: Array.isArray(row.extraOfferCodes)
|
|
? row.extraOfferCodes
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
});
|
|
});
|
|
const ordered = [];
|
|
DEFAULT_AUDIENCES.forEach(function (entry) {
|
|
if (existingMap.has(entry.key)) {
|
|
ordered.push(existingMap.get(entry.key));
|
|
existingMap.delete(entry.key);
|
|
return;
|
|
}
|
|
ordered.push(createAudienceRow(entry.key));
|
|
});
|
|
existingMap.forEach(function (value) {
|
|
ordered.push(value);
|
|
});
|
|
return ordered;
|
|
}
|
|
|
|
function getAudienceRowByKey(key) {
|
|
const normalized = trimString(key);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
for (
|
|
let index = 0;
|
|
index < catalogConfigState.audienceRows.length;
|
|
index += 1
|
|
) {
|
|
const row = catalogConfigState.audienceRows[index];
|
|
if (trimString(row && row.key) === normalized) {
|
|
return row;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getFirstAvailableAudienceKey() {
|
|
const first =
|
|
Array.isArray(catalogConfigState.audienceRows) &&
|
|
catalogConfigState.audienceRows.length
|
|
? catalogConfigState.audienceRows[0]
|
|
: null;
|
|
return trimString(first && first.key);
|
|
}
|
|
|
|
function setSelectedAudienceKey(key) {
|
|
const normalized = trimString(key);
|
|
const fallback = getFirstAvailableAudienceKey();
|
|
catalogConfigState.selectedAudienceKey = getAudienceRowByKey(normalized)
|
|
? normalized
|
|
: fallback;
|
|
}
|
|
|
|
function toggleAudienceSelectionValue(audienceKey, field, value, checked) {
|
|
const row = getAudienceRowByKey(audienceKey);
|
|
const normalizedField = trimString(field);
|
|
const normalizedValue = trimString(value);
|
|
if (
|
|
!row ||
|
|
!normalizedValue ||
|
|
!Object.prototype.hasOwnProperty.call(row, normalizedField)
|
|
) {
|
|
return;
|
|
}
|
|
const nextValues = Array.isArray(row[normalizedField])
|
|
? row[normalizedField].slice()
|
|
: [];
|
|
const index = nextValues.indexOf(normalizedValue);
|
|
if (checked) {
|
|
if (index === -1) {
|
|
nextValues.push(normalizedValue);
|
|
}
|
|
} else if (index !== -1) {
|
|
nextValues.splice(index, 1);
|
|
}
|
|
row[normalizedField] = nextValues;
|
|
}
|
|
|
|
function getPackageAudienceOptions() {
|
|
return (
|
|
Array.isArray(catalogConfigState.packageRows)
|
|
? catalogConfigState.packageRows
|
|
: []
|
|
)
|
|
.map(function (row) {
|
|
const code = trimString(row && row.code);
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
const meta = getDefaultPackageMeta(code);
|
|
const productId = toIdString(row.productId);
|
|
const price = trimString(row.price);
|
|
return {
|
|
code: code,
|
|
label: meta && meta.label ? meta.label : code,
|
|
detail:
|
|
(productId ? "Product ID " + productId : "Product ID not set yet") +
|
|
(price ? " · " + price : ""),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function resolveCatalogSummaryForPreview(entry, rows, fallbackSummary) {
|
|
const code = trimString(
|
|
entry && (entry.code || entry.tier || entry.packageTier)
|
|
);
|
|
const directSummary = trimString(entry && entry.summary);
|
|
if (directSummary) {
|
|
return directSummary;
|
|
}
|
|
const row = (Array.isArray(rows) ? rows : []).find(function (candidate) {
|
|
return trimString(candidate && candidate.code) === code;
|
|
});
|
|
if (row && trimString(row.summary)) {
|
|
return trimString(row.summary);
|
|
}
|
|
if (trimString(fallbackSummary)) {
|
|
return trimString(fallbackSummary);
|
|
}
|
|
return "No summary configured.";
|
|
}
|
|
|
|
function resolvePackageSummaryForPreview(entry) {
|
|
const code = trimString(
|
|
entry && (entry.code || entry.tier || entry.packageTier)
|
|
);
|
|
const meta = getDefaultPackageMeta(code);
|
|
return resolveCatalogSummaryForPreview(
|
|
entry,
|
|
catalogConfigState.packageRows,
|
|
meta && meta.summary ? meta.summary : ""
|
|
);
|
|
}
|
|
|
|
function resolveAddonSummaryForPreview(entry) {
|
|
return resolveCatalogSummaryForPreview(
|
|
entry,
|
|
catalogConfigState.addonRows,
|
|
""
|
|
);
|
|
}
|
|
|
|
function resolveOneTimeSummaryForPreview(entry) {
|
|
return resolveCatalogSummaryForPreview(
|
|
entry,
|
|
catalogConfigState.oneTimeRows,
|
|
""
|
|
);
|
|
}
|
|
|
|
function resolveCatalogPriceForPreview(entry, rows) {
|
|
const directPrice = trimString(entry && entry.price);
|
|
if (directPrice) {
|
|
return directPrice;
|
|
}
|
|
const code = trimString(
|
|
entry && (entry.code || entry.tier || entry.packageTier)
|
|
);
|
|
const row = (Array.isArray(rows) ? rows : []).find(function (candidate) {
|
|
return trimString(candidate && candidate.code) === code;
|
|
});
|
|
return trimString(row && row.price);
|
|
}
|
|
|
|
function findCatalogPreviewEntryForRow(kind, row) {
|
|
const code = trimString(row && row.code);
|
|
const productId = toIdString(row && row.productId);
|
|
if (kind === "package") {
|
|
const subscriptions = Array.isArray(
|
|
connectorCatalogPreview && connectorCatalogPreview.subscriptions
|
|
)
|
|
? connectorCatalogPreview.subscriptions
|
|
: [];
|
|
return (
|
|
subscriptions.find(function (entry) {
|
|
const entryProductId = toIdString(entry && entry.productId);
|
|
const entryCode = trimString(
|
|
entry && (entry.code || entry.tier || entry.packageTier)
|
|
);
|
|
if (productId && entryProductId === productId) {
|
|
return true;
|
|
}
|
|
return Boolean(code && entryCode === code);
|
|
}) || null
|
|
);
|
|
}
|
|
const addons = Array.isArray(
|
|
connectorCatalogPreview && connectorCatalogPreview.addons
|
|
)
|
|
? connectorCatalogPreview.addons
|
|
: [];
|
|
return (
|
|
addons.find(function (entry) {
|
|
const entryProductId = toIdString(entry && entry.productId);
|
|
const entryCode = trimString(entry && entry.code);
|
|
const monthly = isMonthlyAddonPreviewEntry(entry);
|
|
if (kind === "addon" && !monthly) {
|
|
return false;
|
|
}
|
|
if (kind === "one_time" && monthly) {
|
|
return false;
|
|
}
|
|
if (productId && entryProductId === productId) {
|
|
return true;
|
|
}
|
|
return Boolean(code && entryCode === code);
|
|
}) || null
|
|
);
|
|
}
|
|
|
|
function prettifyCatalogSourceLabel(value) {
|
|
const normalized = trimString(value).toLowerCase();
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
switch (normalized) {
|
|
case "paymenter_catalog":
|
|
return "Live Paymenter catalog";
|
|
case "config_price_map":
|
|
return "Configured fallback price";
|
|
case "mapped_product_id":
|
|
return "Mapped product lookup";
|
|
case "mapped_config":
|
|
return "Configured fallback mapping";
|
|
case "configured_fallback":
|
|
return "Configured fallback";
|
|
case "warning_fallback":
|
|
return "Configured fallback";
|
|
case "unavailable":
|
|
return "Unavailable";
|
|
default:
|
|
return normalized.replace(/[_-]+/g, " ");
|
|
}
|
|
}
|
|
|
|
function getCatalogPricingClarity(kind, row) {
|
|
const entry = findCatalogPreviewEntryForRow(kind, row);
|
|
const fallbackPrice = trimString(row && row.price);
|
|
if (!entry) {
|
|
return {
|
|
level: fallbackPrice ? "fallback" : "missing",
|
|
statusLabel: fallbackPrice
|
|
? "Configured fallback active"
|
|
: "Paymenter product data unavailable",
|
|
title: "",
|
|
price: fallbackPrice,
|
|
priceSource: fallbackPrice
|
|
? "Configured fallback price"
|
|
: "Unavailable",
|
|
origin: "",
|
|
hasFallback: Boolean(fallbackPrice),
|
|
detail: fallbackPrice
|
|
? "Paymenter does not expose product data for this row, so the configured fallback is shown by design."
|
|
: "Paymenter does not expose product data for this row yet, and no fallback price is saved yet.",
|
|
};
|
|
}
|
|
const title = trimString(
|
|
entry.name || entry.label || entry.code || entry.tier
|
|
);
|
|
const price = trimString(entry.price);
|
|
const priceSourceKey = trimString(entry.priceSource || "").toLowerCase();
|
|
const priceSource = prettifyCatalogSourceLabel(priceSourceKey || "");
|
|
const origin = prettifyCatalogSourceLabel(entry.source || "");
|
|
const isFallbackSource =
|
|
priceSourceKey === "config_price_map" ||
|
|
priceSourceKey === "mapped_config";
|
|
const isLiveSource =
|
|
priceSourceKey === "paymenter_catalog" ||
|
|
priceSourceKey === "mapped_product_id" ||
|
|
(!isFallbackSource && price !== "");
|
|
if (isFallbackSource) {
|
|
return {
|
|
level: "fallback",
|
|
statusLabel: fallbackPrice
|
|
? "Configured fallback in use"
|
|
: "Configured fallback active",
|
|
title: title,
|
|
price: price || fallbackPrice,
|
|
priceSource: priceSource || "Configured fallback price",
|
|
origin: origin,
|
|
hasFallback: Boolean(fallbackPrice),
|
|
detail: fallbackPrice
|
|
? "Paymenter does not expose product data for this row, so the configured fallback is shown by design."
|
|
: "Paymenter does not expose product data for this row, so the configured fallback price is active.",
|
|
};
|
|
}
|
|
if (isLiveSource && price) {
|
|
return {
|
|
level: "live",
|
|
statusLabel: "Live Paymenter price active",
|
|
title: title,
|
|
price: price,
|
|
priceSource: priceSource,
|
|
origin: origin,
|
|
hasFallback: Boolean(fallbackPrice),
|
|
detail: fallbackPrice
|
|
? "A fallback override is saved, but the live Paymenter price is currently taking precedence."
|
|
: "This row is currently resolving a live Paymenter price.",
|
|
};
|
|
}
|
|
if (fallbackPrice) {
|
|
return {
|
|
level: "fallback",
|
|
statusLabel: "Configured fallback in use",
|
|
title: title,
|
|
price: fallbackPrice,
|
|
priceSource: "Configured fallback price",
|
|
origin: origin,
|
|
hasFallback: true,
|
|
detail:
|
|
"Paymenter returned this row without a usable price, so the configured fallback is shown.",
|
|
};
|
|
}
|
|
if (price) {
|
|
return {
|
|
level: "live",
|
|
statusLabel: "Live Paymenter price active",
|
|
title: title,
|
|
price: price,
|
|
priceSource: priceSource || "Live Paymenter price",
|
|
origin: origin,
|
|
hasFallback: Boolean(fallbackPrice),
|
|
detail: fallbackPrice
|
|
? "A fallback override is saved, but the live Paymenter price is currently taking precedence."
|
|
: "This row is currently resolving a live Paymenter price.",
|
|
};
|
|
}
|
|
return {
|
|
level: "missing",
|
|
statusLabel: "Paymenter price unavailable",
|
|
title: title,
|
|
price: "",
|
|
priceSource: priceSource || "Unavailable",
|
|
origin: origin,
|
|
hasFallback: false,
|
|
detail:
|
|
"Paymenter returned the row, but it did not include a usable price and no fallback price is saved yet.",
|
|
};
|
|
}
|
|
|
|
function buildCatalogLivePriceHelperText(kind, row) {
|
|
const clarity = getCatalogPricingClarity(kind, row);
|
|
const fallbackNote =
|
|
"Configured fallback is expected when Paymenter does not expose product data for this row.";
|
|
let text = clarity.statusLabel;
|
|
if (clarity.title) {
|
|
text += " | Catalog item: " + clarity.title;
|
|
}
|
|
if (clarity.price) {
|
|
text += " | Price: " + clarity.price;
|
|
}
|
|
if (clarity.priceSource) {
|
|
text += " | Price source: " + clarity.priceSource;
|
|
}
|
|
if (clarity.origin) {
|
|
text += " | Origin: " + clarity.origin;
|
|
}
|
|
if (clarity.detail) {
|
|
text += ". " + clarity.detail;
|
|
}
|
|
if (clarity.level === "fallback" || clarity.level === "missing") {
|
|
return text + " " + fallbackNote;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function buildCatalogLivePriceHelperElement(kind, row) {
|
|
const clarity = getCatalogPricingClarity(kind, row);
|
|
const wrap = document.createElement("div");
|
|
wrap.className =
|
|
"chd-config-row__pricing chd-config-row__pricing--" + clarity.level;
|
|
|
|
const chips = document.createElement("div");
|
|
chips.className = "chd-config-row__pricing-chips";
|
|
|
|
const primary = document.createElement("span");
|
|
primary.className =
|
|
"chd-config-row__pricing-chip chd-config-row__pricing-chip--" +
|
|
clarity.level;
|
|
primary.textContent = clarity.statusLabel;
|
|
chips.appendChild(primary);
|
|
|
|
if (clarity.hasFallback) {
|
|
const fallbackChip = document.createElement("span");
|
|
fallbackChip.className =
|
|
"chd-config-row__pricing-chip chd-config-row__pricing-chip--neutral";
|
|
fallbackChip.textContent = "Configured fallback";
|
|
chips.appendChild(fallbackChip);
|
|
}
|
|
|
|
if (clarity.priceSource) {
|
|
const sourceChip = document.createElement("span");
|
|
sourceChip.className =
|
|
"chd-config-row__pricing-chip chd-config-row__pricing-chip--neutral";
|
|
sourceChip.textContent = clarity.priceSource;
|
|
chips.appendChild(sourceChip);
|
|
}
|
|
|
|
const detail = document.createElement("div");
|
|
detail.className = "chd-note chd-config-row__helper";
|
|
detail.textContent = buildCatalogLivePriceHelperText(kind, row);
|
|
|
|
wrap.appendChild(chips);
|
|
wrap.appendChild(detail);
|
|
return wrap;
|
|
}
|
|
|
|
function buildAudienceOptionList(rows, emptyLabel) {
|
|
if (!Array.isArray(rows) || !rows.length) {
|
|
return [];
|
|
}
|
|
return rows
|
|
.map(function (row) {
|
|
const code = trimString(row && row.code);
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
const label = trimString(row.label) || code;
|
|
const productId = toIdString(row.productId);
|
|
const price = trimString(row.price);
|
|
return {
|
|
code: code,
|
|
label: label,
|
|
detail:
|
|
(productId ? "Product ID " + productId : emptyLabel) +
|
|
(price ? " · " + price : ""),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function extraOffersToRows(extraOffers) {
|
|
const list = Array.isArray(extraOffers) ? extraOffers : [];
|
|
return list.map(function (entry) {
|
|
return {
|
|
code: trimString(entry.code),
|
|
label: trimString(entry.label),
|
|
url: trimString(entry.url),
|
|
description: trimString(entry.description),
|
|
billingType:
|
|
trimString(entry.billingType || "one_time") === "monthly"
|
|
? "monthly"
|
|
: "one_time",
|
|
audiences: Array.isArray(entry.audiences)
|
|
? entry.audiences.join(", ")
|
|
: "",
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderPackageRows() {
|
|
if (!packageListEl) {
|
|
return;
|
|
}
|
|
packageListEl.innerHTML = "";
|
|
if (!catalogConfigState.packageRows.length) {
|
|
packageListEl.textContent = "No package mappings configured.";
|
|
return;
|
|
}
|
|
catalogConfigState.packageRows.forEach(function (row, index) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "chd-config-row chd-config-row--product";
|
|
|
|
const codeInput = document.createElement("input");
|
|
codeInput.placeholder = "Package code";
|
|
codeInput.value = trimString(row.code);
|
|
codeInput.addEventListener("input", function () {
|
|
catalogConfigState.packageRows[index].code = trimString(
|
|
codeInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const productInput = document.createElement("input");
|
|
productInput.type = "number";
|
|
productInput.min = "1";
|
|
productInput.step = "1";
|
|
productInput.placeholder = "Product ID";
|
|
productInput.value = toIdString(row.productId);
|
|
productInput.addEventListener("input", function () {
|
|
catalogConfigState.packageRows[index].productId = toIdString(
|
|
productInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const priceInput = document.createElement("input");
|
|
priceInput.placeholder = "Fallback override price (optional)";
|
|
priceInput.value = trimString(row.price);
|
|
priceInput.addEventListener("input", function () {
|
|
catalogConfigState.packageRows[index].price = trimString(
|
|
priceInput.value
|
|
);
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const label = document.createElement("div");
|
|
label.className = "chd-note chd-config-row__helper";
|
|
const meta = getDefaultPackageMeta(row.code);
|
|
label.textContent =
|
|
meta && meta.label
|
|
? meta.label +
|
|
" · Fallback price is used only when Paymenter data does not provide one."
|
|
: "Custom package code · Fallback price is used only when Paymenter data does not provide one.";
|
|
|
|
const liveHelper = buildCatalogLivePriceHelperElement("package", row);
|
|
|
|
const summaryInput = document.createElement("textarea");
|
|
summaryInput.className = "chd-config-row__summary";
|
|
summaryInput.rows = 4;
|
|
summaryInput.placeholder =
|
|
"Summary / bullets shown to users. Use one line per bullet if you want multiline output.";
|
|
summaryInput.value = trimString(
|
|
row.summary || (meta && meta.summary) || ""
|
|
);
|
|
summaryInput.addEventListener("input", function () {
|
|
catalogConfigState.packageRows[index].summary = trimString(
|
|
summaryInput.value
|
|
);
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const removeBtn = document.createElement("button");
|
|
removeBtn.type = "button";
|
|
removeBtn.className = "button";
|
|
removeBtn.textContent = "Remove";
|
|
removeBtn.addEventListener("click", function () {
|
|
catalogConfigState.packageRows.splice(index, 1);
|
|
renderPackageRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
wrap.appendChild(codeInput);
|
|
wrap.appendChild(productInput);
|
|
wrap.appendChild(priceInput);
|
|
wrap.appendChild(label);
|
|
wrap.appendChild(liveHelper);
|
|
wrap.appendChild(removeBtn);
|
|
wrap.appendChild(summaryInput);
|
|
packageListEl.appendChild(wrap);
|
|
});
|
|
}
|
|
|
|
function renderAddonRows() {
|
|
if (!addonListEl) {
|
|
return;
|
|
}
|
|
addonListEl.innerHTML = "";
|
|
if (!catalogConfigState.addonRows.length) {
|
|
addonListEl.textContent = "No monthly add-ons configured.";
|
|
return;
|
|
}
|
|
catalogConfigState.addonRows.forEach(function (row, index) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "chd-config-row chd-config-row--product";
|
|
|
|
const codeInput = document.createElement("input");
|
|
codeInput.placeholder = "Code (e.g. capacity_100gb_monthly_basic)";
|
|
codeInput.value = row.code;
|
|
codeInput.addEventListener("input", function () {
|
|
catalogConfigState.addonRows[index].code = trimString(codeInput.value);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const productInput = document.createElement("input");
|
|
productInput.type = "number";
|
|
productInput.min = "1";
|
|
productInput.step = "1";
|
|
productInput.placeholder = "Product ID";
|
|
productInput.value = row.productId;
|
|
productInput.addEventListener("input", function () {
|
|
catalogConfigState.addonRows[index].productId = toIdString(
|
|
productInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const labelInput = document.createElement("input");
|
|
labelInput.placeholder = "Display label";
|
|
labelInput.value = row.label;
|
|
labelInput.addEventListener("input", function () {
|
|
catalogConfigState.addonRows[index].label = trimString(
|
|
labelInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const priceInput = document.createElement("input");
|
|
priceInput.placeholder = "Fallback override price (optional)";
|
|
priceInput.value = trimString(row.price);
|
|
priceInput.addEventListener("input", function () {
|
|
catalogConfigState.addonRows[index].price = trimString(
|
|
priceInput.value
|
|
);
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const summaryInput = document.createElement("textarea");
|
|
summaryInput.className = "chd-config-row__summary";
|
|
summaryInput.rows = 4;
|
|
summaryInput.placeholder =
|
|
"Summary / bullets shown to users. Use one line per bullet if you want multiline output.";
|
|
summaryInput.value = trimString(row.summary);
|
|
summaryInput.addEventListener("input", function () {
|
|
catalogConfigState.addonRows[index].summary = trimString(
|
|
summaryInput.value
|
|
);
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const helper = document.createElement("div");
|
|
helper.className = "chd-note chd-config-row__helper";
|
|
helper.textContent =
|
|
"This text appears in the Monthly Add-ons preview and user-facing catalog output. Fallback price is only used when Paymenter data does not provide one.";
|
|
|
|
const liveHelper = buildCatalogLivePriceHelperElement("addon", row);
|
|
|
|
const removeBtn = document.createElement("button");
|
|
removeBtn.type = "button";
|
|
removeBtn.className = "button";
|
|
removeBtn.textContent = "Remove";
|
|
removeBtn.addEventListener("click", function () {
|
|
catalogConfigState.addonRows.splice(index, 1);
|
|
renderAddonRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
wrap.appendChild(codeInput);
|
|
wrap.appendChild(productInput);
|
|
wrap.appendChild(labelInput);
|
|
wrap.appendChild(priceInput);
|
|
wrap.appendChild(removeBtn);
|
|
wrap.appendChild(summaryInput);
|
|
wrap.appendChild(helper);
|
|
wrap.appendChild(liveHelper);
|
|
addonListEl.appendChild(wrap);
|
|
});
|
|
}
|
|
|
|
function renderOneTimeRows() {
|
|
if (!oneTimeListEl) {
|
|
return;
|
|
}
|
|
oneTimeListEl.innerHTML = "";
|
|
if (!catalogConfigState.oneTimeRows.length) {
|
|
oneTimeListEl.textContent = "No one-time products configured.";
|
|
return;
|
|
}
|
|
catalogConfigState.oneTimeRows.forEach(function (row, index) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "chd-config-row chd-config-row--product";
|
|
|
|
const codeInput = document.createElement("input");
|
|
codeInput.placeholder = "Code (e.g. credits_1000)";
|
|
codeInput.value = row.code;
|
|
codeInput.addEventListener("input", function () {
|
|
catalogConfigState.oneTimeRows[index].code = trimString(
|
|
codeInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const productInput = document.createElement("input");
|
|
productInput.type = "number";
|
|
productInput.min = "1";
|
|
productInput.step = "1";
|
|
productInput.placeholder = "Product ID";
|
|
productInput.value = row.productId;
|
|
productInput.addEventListener("input", function () {
|
|
catalogConfigState.oneTimeRows[index].productId = toIdString(
|
|
productInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const labelInput = document.createElement("input");
|
|
labelInput.placeholder = "Display label";
|
|
labelInput.value = row.label;
|
|
labelInput.addEventListener("input", function () {
|
|
catalogConfigState.oneTimeRows[index].label = trimString(
|
|
labelInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const priceInput = document.createElement("input");
|
|
priceInput.placeholder = "Fallback override price (optional)";
|
|
priceInput.value = trimString(row.price);
|
|
priceInput.addEventListener("input", function () {
|
|
catalogConfigState.oneTimeRows[index].price = trimString(
|
|
priceInput.value
|
|
);
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const summaryInput = document.createElement("textarea");
|
|
summaryInput.className = "chd-config-row__summary";
|
|
summaryInput.rows = 4;
|
|
summaryInput.placeholder =
|
|
"Summary / bullets shown to users. Use one line per bullet if you want multiline output.";
|
|
summaryInput.value = trimString(row.summary);
|
|
summaryInput.addEventListener("input", function () {
|
|
catalogConfigState.oneTimeRows[index].summary = trimString(
|
|
summaryInput.value
|
|
);
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const helper = document.createElement("div");
|
|
helper.className = "chd-note chd-config-row__helper";
|
|
helper.textContent =
|
|
"This text appears in the One-Time Products preview and user-facing catalog output. Fallback price is only used when Paymenter data does not provide one.";
|
|
|
|
const liveHelper = buildCatalogLivePriceHelperElement("one_time", row);
|
|
|
|
const removeBtn = document.createElement("button");
|
|
removeBtn.type = "button";
|
|
removeBtn.className = "button";
|
|
removeBtn.textContent = "Remove";
|
|
removeBtn.addEventListener("click", function () {
|
|
catalogConfigState.oneTimeRows.splice(index, 1);
|
|
renderOneTimeRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
wrap.appendChild(codeInput);
|
|
wrap.appendChild(productInput);
|
|
wrap.appendChild(labelInput);
|
|
wrap.appendChild(priceInput);
|
|
wrap.appendChild(removeBtn);
|
|
wrap.appendChild(summaryInput);
|
|
wrap.appendChild(helper);
|
|
wrap.appendChild(liveHelper);
|
|
oneTimeListEl.appendChild(wrap);
|
|
});
|
|
}
|
|
|
|
function renderAudienceRows() {
|
|
if (!audienceListEl) {
|
|
return;
|
|
}
|
|
audienceListEl.innerHTML = "";
|
|
if (!catalogConfigState.audienceRows.length) {
|
|
audienceListEl.textContent = "No audiences configured yet.";
|
|
if (audienceSelectEl) {
|
|
audienceSelectEl.innerHTML = "";
|
|
}
|
|
if (audienceRemoveCurrentButton) {
|
|
audienceRemoveCurrentButton.disabled = true;
|
|
}
|
|
updatePreviewAudienceSelect();
|
|
renderAudienceEditor();
|
|
return;
|
|
}
|
|
setSelectedAudienceKey(catalogConfigState.selectedAudienceKey);
|
|
if (audienceSelectEl) {
|
|
const current = trimString(catalogConfigState.selectedAudienceKey);
|
|
audienceSelectEl.innerHTML = "";
|
|
catalogConfigState.audienceRows.forEach(function (row) {
|
|
const key = trimString(row.key);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = key;
|
|
option.textContent = key;
|
|
audienceSelectEl.appendChild(option);
|
|
});
|
|
audienceSelectEl.value = current || getFirstAvailableAudienceKey();
|
|
}
|
|
catalogConfigState.audienceRows.forEach(function (row) {
|
|
const key = trimString(row.key);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className =
|
|
"button " +
|
|
(trimString(catalogConfigState.selectedAudienceKey) === key
|
|
? "button-primary"
|
|
: "");
|
|
button.textContent = key;
|
|
button.addEventListener("click", function () {
|
|
setSelectedAudienceKey(key);
|
|
if (previewAudienceEl) {
|
|
previewAudienceEl.value = key;
|
|
}
|
|
renderAudienceRows();
|
|
renderAudiencePreview();
|
|
});
|
|
audienceListEl.appendChild(button);
|
|
});
|
|
if (audienceRemoveCurrentButton) {
|
|
const selectedKey = trimString(catalogConfigState.selectedAudienceKey);
|
|
audienceRemoveCurrentButton.disabled =
|
|
!selectedKey || isDefaultAudienceKey(selectedKey);
|
|
}
|
|
updatePreviewAudienceSelect();
|
|
renderAudienceEditor();
|
|
}
|
|
|
|
function renderAudienceEditorSection(
|
|
parent,
|
|
audienceKey,
|
|
field,
|
|
title,
|
|
note,
|
|
options,
|
|
emptyText
|
|
) {
|
|
const section = document.createElement("section");
|
|
section.className = "chd-audience-editor-section";
|
|
|
|
const heading = document.createElement("h5");
|
|
heading.textContent = title;
|
|
section.appendChild(heading);
|
|
|
|
const noteEl = document.createElement("div");
|
|
noteEl.className = "chd-note";
|
|
noteEl.textContent = note;
|
|
section.appendChild(noteEl);
|
|
|
|
if (!Array.isArray(options) || !options.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "chd-note";
|
|
empty.textContent = emptyText;
|
|
section.appendChild(empty);
|
|
parent.appendChild(section);
|
|
return;
|
|
}
|
|
|
|
const selectedRow = getAudienceRowByKey(audienceKey);
|
|
const selectedValues = new Set(
|
|
Array.isArray(selectedRow && selectedRow[field]) ? selectedRow[field] : []
|
|
);
|
|
|
|
const optionWrap = document.createElement("div");
|
|
optionWrap.className = "chd-audience-option-list";
|
|
|
|
options.forEach(function (optionData) {
|
|
const label = document.createElement("label");
|
|
label.className = "chd-audience-option";
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked = selectedValues.has(optionData.code);
|
|
checkbox.addEventListener("change", function () {
|
|
toggleAudienceSelectionValue(
|
|
audienceKey,
|
|
field,
|
|
optionData.code,
|
|
checkbox.checked
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
const copy = document.createElement("div");
|
|
copy.className = "chd-audience-option-copy";
|
|
const titleEl = document.createElement("strong");
|
|
titleEl.textContent = optionData.label;
|
|
const detailEl = document.createElement("span");
|
|
detailEl.textContent =
|
|
optionData.code + (optionData.detail ? " · " + optionData.detail : "");
|
|
copy.appendChild(titleEl);
|
|
copy.appendChild(detailEl);
|
|
label.appendChild(checkbox);
|
|
label.appendChild(copy);
|
|
optionWrap.appendChild(label);
|
|
});
|
|
|
|
section.appendChild(optionWrap);
|
|
parent.appendChild(section);
|
|
}
|
|
|
|
function renderAudienceEditor() {
|
|
if (!audienceEditorEl) {
|
|
return;
|
|
}
|
|
audienceEditorEl.innerHTML = "";
|
|
const selectedKey = trimString(catalogConfigState.selectedAudienceKey);
|
|
const selectedAudience = getAudienceRowByKey(selectedKey);
|
|
if (!selectedAudience) {
|
|
audienceEditorEl.textContent =
|
|
"Select or create an audience to configure its visible catalog items.";
|
|
return;
|
|
}
|
|
|
|
const hero = document.createElement("div");
|
|
hero.className = "chd-audience-editor-hero";
|
|
const title = document.createElement("h5");
|
|
title.textContent = selectedKey;
|
|
hero.appendChild(title);
|
|
const meta = getDefaultAudienceMeta(selectedKey);
|
|
const description = document.createElement("div");
|
|
description.className = "chd-note";
|
|
description.textContent =
|
|
meta && meta.description
|
|
? meta.description
|
|
: "Custom audience key for catalog targeting.";
|
|
hero.appendChild(description);
|
|
if (selectedKey === "NuQloud-team") {
|
|
const teamNote = document.createElement("div");
|
|
teamNote.className = "chd-note chd-note--warning";
|
|
teamNote.textContent =
|
|
"Team member email/QORT roster tracking should live on order or instance records, not in catalog visibility config. For now, keep that data in fulfillment notes and operations tracking.";
|
|
hero.appendChild(teamNote);
|
|
}
|
|
audienceEditorEl.appendChild(hero);
|
|
|
|
renderAudienceEditorSection(
|
|
audienceEditorEl,
|
|
selectedKey,
|
|
"packages",
|
|
"Packages",
|
|
"Choose which subscription package codes this audience should see.",
|
|
getPackageAudienceOptions(),
|
|
"No package codes are available yet."
|
|
);
|
|
renderAudienceEditorSection(
|
|
audienceEditorEl,
|
|
selectedKey,
|
|
"addons",
|
|
"Monthly Add-ons",
|
|
"Choose which recurring add-ons this audience should see.",
|
|
buildAudienceOptionList(
|
|
catalogConfigState.addonRows,
|
|
"Product ID not set yet"
|
|
),
|
|
"Add global monthly add-ons above before assigning them to this audience."
|
|
);
|
|
renderAudienceEditorSection(
|
|
audienceEditorEl,
|
|
selectedKey,
|
|
"oneTime",
|
|
"One-Time Products",
|
|
"Choose which one-time products this audience should see.",
|
|
buildAudienceOptionList(
|
|
catalogConfigState.oneTimeRows,
|
|
"Product ID not set yet"
|
|
),
|
|
"Add global one-time products above before assigning them to this audience."
|
|
);
|
|
renderAudienceEditorSection(
|
|
audienceEditorEl,
|
|
selectedKey,
|
|
"extraOfferCodes",
|
|
"Extra Offers",
|
|
"Choose which extra CHD offers should be shown to this audience.",
|
|
(Array.isArray(catalogConfigState.extraOffers)
|
|
? catalogConfigState.extraOffers
|
|
: []
|
|
)
|
|
.map(function (row) {
|
|
const code = trimString(row && row.code);
|
|
if (!code) {
|
|
return null;
|
|
}
|
|
return {
|
|
code: code,
|
|
label: trimString(row.label) || code,
|
|
detail:
|
|
trimString(row.billingType || "one_time") === "monthly"
|
|
? "Monthly offer"
|
|
: "One-time offer",
|
|
};
|
|
})
|
|
.filter(Boolean),
|
|
"Add global extra offers above before assigning them to this audience."
|
|
);
|
|
}
|
|
|
|
function renderExtraOffersRows() {
|
|
if (!extraOffersListEl) {
|
|
return;
|
|
}
|
|
extraOffersListEl.innerHTML = "";
|
|
if (!catalogConfigState.extraOffers.length) {
|
|
extraOffersListEl.textContent = "No extra offers configured.";
|
|
return;
|
|
}
|
|
catalogConfigState.extraOffers.forEach(function (row, index) {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "chd-config-row chd-config-row--offer";
|
|
|
|
const codeInput = document.createElement("input");
|
|
codeInput.placeholder = "Offer code";
|
|
codeInput.value = row.code;
|
|
codeInput.addEventListener("input", function () {
|
|
catalogConfigState.extraOffers[index].code = trimString(
|
|
codeInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const labelInput = document.createElement("input");
|
|
labelInput.placeholder = "Label";
|
|
labelInput.value = row.label;
|
|
labelInput.addEventListener("input", function () {
|
|
catalogConfigState.extraOffers[index].label = trimString(
|
|
labelInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const urlInput = document.createElement("input");
|
|
urlInput.placeholder = "URL";
|
|
urlInput.value = row.url;
|
|
urlInput.addEventListener("input", function () {
|
|
catalogConfigState.extraOffers[index].url = trimString(urlInput.value);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const billingTypeSelect = document.createElement("select");
|
|
[
|
|
{ value: "one_time", label: "One-time" },
|
|
{ value: "monthly", label: "Monthly" },
|
|
].forEach(function (optionData) {
|
|
const option = document.createElement("option");
|
|
option.value = optionData.value;
|
|
option.textContent = optionData.label;
|
|
if (trimString(row.billingType || "one_time") === optionData.value) {
|
|
option.selected = true;
|
|
}
|
|
billingTypeSelect.appendChild(option);
|
|
});
|
|
billingTypeSelect.addEventListener("change", function () {
|
|
catalogConfigState.extraOffers[index].billingType = trimString(
|
|
billingTypeSelect.value || "one_time"
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
const removeBtn = document.createElement("button");
|
|
removeBtn.type = "button";
|
|
removeBtn.className = "button";
|
|
removeBtn.textContent = "Remove";
|
|
removeBtn.addEventListener("click", function () {
|
|
catalogConfigState.extraOffers.splice(index, 1);
|
|
renderExtraOffersRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
|
|
wrap.appendChild(codeInput);
|
|
wrap.appendChild(labelInput);
|
|
wrap.appendChild(urlInput);
|
|
wrap.appendChild(billingTypeSelect);
|
|
wrap.appendChild(removeBtn);
|
|
extraOffersListEl.appendChild(wrap);
|
|
|
|
const descriptionInput = document.createElement("input");
|
|
descriptionInput.placeholder = "Description";
|
|
descriptionInput.value = row.description;
|
|
descriptionInput.addEventListener("input", function () {
|
|
catalogConfigState.extraOffers[index].description = trimString(
|
|
descriptionInput.value
|
|
);
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
descriptionInput.className = "chd-config-row";
|
|
extraOffersListEl.appendChild(descriptionInput);
|
|
});
|
|
}
|
|
|
|
function updatePreviewAudienceSelect() {
|
|
if (!previewAudienceEl) {
|
|
return;
|
|
}
|
|
const current =
|
|
trimString(previewAudienceEl.value) ||
|
|
trimString(catalogConfigState.selectedAudienceKey);
|
|
previewAudienceEl.innerHTML = "";
|
|
const allOption = document.createElement("option");
|
|
allOption.value = "";
|
|
allOption.textContent = "Default (no audience key)";
|
|
previewAudienceEl.appendChild(allOption);
|
|
catalogConfigState.audienceRows.forEach(function (entry) {
|
|
const key = trimString(entry.key);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = key;
|
|
option.textContent = key;
|
|
previewAudienceEl.appendChild(option);
|
|
});
|
|
previewAudienceEl.value =
|
|
current &&
|
|
Array.from(previewAudienceEl.options).some(function (opt) {
|
|
return opt.value === current;
|
|
})
|
|
? current
|
|
: "";
|
|
}
|
|
|
|
function buildConfigFromForm() {
|
|
persistCurrentCatalogScopeState();
|
|
const packageProductMap = {};
|
|
const packageSummaryMap = {};
|
|
const packagePriceMap = {};
|
|
(Array.isArray(catalogConfigState.packageRows)
|
|
? catalogConfigState.packageRows
|
|
: []
|
|
).forEach(function (row) {
|
|
const code = trimString(row && row.code);
|
|
const productId = toIdString(row && row.productId);
|
|
const summary = trimString(row && row.summary);
|
|
const price = trimString(row && row.price);
|
|
if (code && summary) {
|
|
packageSummaryMap[code] = summary;
|
|
}
|
|
if (code && price) {
|
|
packagePriceMap[code] = price;
|
|
}
|
|
if (!code || !productId) {
|
|
return;
|
|
}
|
|
packageProductMap[code] = Number.parseInt(productId, 10);
|
|
});
|
|
|
|
const addonProductMap = {};
|
|
const addonLabelMap = {};
|
|
const addonSummaryMap = {};
|
|
const addonPriceMap = {};
|
|
catalogConfigState.addonRows.forEach(function (row) {
|
|
const code = trimString(row.code);
|
|
const productId = toIdString(row.productId);
|
|
const label = trimString(row.label);
|
|
const summary = trimString(row.summary);
|
|
const price = trimString(row.price);
|
|
if (!code) {
|
|
return;
|
|
}
|
|
if (productId) {
|
|
addonProductMap[code] = Number.parseInt(productId, 10);
|
|
}
|
|
if (label) {
|
|
addonLabelMap[code] = label;
|
|
}
|
|
if (summary) {
|
|
addonSummaryMap[code] = summary;
|
|
}
|
|
if (price) {
|
|
addonPriceMap[code] = price;
|
|
}
|
|
});
|
|
|
|
const oneTimeProductMap = {};
|
|
const oneTimeLabelMap = {};
|
|
const oneTimeSummaryMap = {};
|
|
const oneTimePriceMap = {};
|
|
catalogConfigState.oneTimeRows.forEach(function (row) {
|
|
const code = trimString(row.code);
|
|
const productId = toIdString(row.productId);
|
|
const label = trimString(row.label);
|
|
const summary = trimString(row.summary);
|
|
const price = trimString(row.price);
|
|
if (!code) {
|
|
return;
|
|
}
|
|
if (productId) {
|
|
oneTimeProductMap[code] = Number.parseInt(productId, 10);
|
|
}
|
|
if (label) {
|
|
oneTimeLabelMap[code] = label;
|
|
}
|
|
if (summary) {
|
|
oneTimeSummaryMap[code] = summary;
|
|
}
|
|
if (price) {
|
|
oneTimePriceMap[code] = price;
|
|
}
|
|
});
|
|
|
|
const audienceCatalogs = {};
|
|
ensureDefaultAudienceRows(catalogConfigState.audienceRows).forEach(
|
|
function (row) {
|
|
const key = trimString(row.key);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
audienceCatalogs[key] = {
|
|
packages: Array.isArray(row.packages)
|
|
? row.packages
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
addons: Array.isArray(row.addons)
|
|
? row.addons
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
oneTime: Array.isArray(row.oneTime)
|
|
? row.oneTime
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
extraOfferCodes: Array.isArray(row.extraOfferCodes)
|
|
? row.extraOfferCodes
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
};
|
|
}
|
|
);
|
|
|
|
const extraOffers = catalogConfigState.extraOffers
|
|
.map(function (row) {
|
|
const code = trimString(row.code);
|
|
const label = trimString(row.label);
|
|
const url = trimString(row.url);
|
|
if (!code || !label || !url) {
|
|
return null;
|
|
}
|
|
return {
|
|
code: code,
|
|
label: label,
|
|
url: url,
|
|
description: trimString(row.description),
|
|
billingType:
|
|
trimString(row.billingType || "one_time") === "monthly"
|
|
? "monthly"
|
|
: "one_time",
|
|
audiences: parseCsv(row.audiences),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const profileConfigs = {};
|
|
Object.keys(catalogConfigState.profileConfigs || {}).forEach(function (
|
|
profileId
|
|
) {
|
|
const normalized = trimString(profileId);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const scope = cloneCatalogProfileState(
|
|
catalogConfigState.profileConfigs[normalized]
|
|
);
|
|
const scopePackageProductMap = {};
|
|
const scopePackageSummaryMap = {};
|
|
const scopePackagePriceMap = {};
|
|
(Array.isArray(scope.packageRows) ? scope.packageRows : []).forEach(
|
|
function (row) {
|
|
const code = trimString(row.code);
|
|
const productId = toIdString(row.productId);
|
|
const summary = trimString(row.summary);
|
|
const price = trimString(row.price);
|
|
if (!code) {
|
|
return;
|
|
}
|
|
if (productId) {
|
|
scopePackageProductMap[code] = Number.parseInt(productId, 10);
|
|
}
|
|
if (summary) {
|
|
scopePackageSummaryMap[code] = summary;
|
|
}
|
|
if (price) {
|
|
scopePackagePriceMap[code] = price;
|
|
}
|
|
}
|
|
);
|
|
const scopeAddonProductMap = {};
|
|
const scopeAddonLabelMap = {};
|
|
const scopeAddonSummaryMap = {};
|
|
const scopeAddonPriceMap = {};
|
|
(Array.isArray(scope.addonRows) ? scope.addonRows : []).forEach(function (
|
|
row
|
|
) {
|
|
const code = trimString(row.code);
|
|
const productId = toIdString(row.productId);
|
|
const label = trimString(row.label);
|
|
const summary = trimString(row.summary);
|
|
const price = trimString(row.price);
|
|
if (!code) {
|
|
return;
|
|
}
|
|
if (productId) {
|
|
scopeAddonProductMap[code] = Number.parseInt(productId, 10);
|
|
}
|
|
if (label) {
|
|
scopeAddonLabelMap[code] = label;
|
|
}
|
|
if (summary) {
|
|
scopeAddonSummaryMap[code] = summary;
|
|
}
|
|
if (price) {
|
|
scopeAddonPriceMap[code] = price;
|
|
}
|
|
});
|
|
const scopeOneTimeProductMap = {};
|
|
const scopeOneTimeLabelMap = {};
|
|
const scopeOneTimeSummaryMap = {};
|
|
const scopeOneTimePriceMap = {};
|
|
(Array.isArray(scope.oneTimeRows) ? scope.oneTimeRows : []).forEach(
|
|
function (row) {
|
|
const code = trimString(row.code);
|
|
const productId = toIdString(row.productId);
|
|
const label = trimString(row.label);
|
|
const summary = trimString(row.summary);
|
|
const price = trimString(row.price);
|
|
if (!code) {
|
|
return;
|
|
}
|
|
if (productId) {
|
|
scopeOneTimeProductMap[code] = Number.parseInt(productId, 10);
|
|
}
|
|
if (label) {
|
|
scopeOneTimeLabelMap[code] = label;
|
|
}
|
|
if (summary) {
|
|
scopeOneTimeSummaryMap[code] = summary;
|
|
}
|
|
if (price) {
|
|
scopeOneTimePriceMap[code] = price;
|
|
}
|
|
}
|
|
);
|
|
const scopeAudienceCatalogs = {};
|
|
ensureDefaultAudienceRows(scope.audienceRows).forEach(function (row) {
|
|
const key = trimString(row.key);
|
|
if (!key) {
|
|
return;
|
|
}
|
|
scopeAudienceCatalogs[key] = {
|
|
packages: Array.isArray(row.packages)
|
|
? row.packages
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
addons: Array.isArray(row.addons)
|
|
? row.addons
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
oneTime: Array.isArray(row.oneTime)
|
|
? row.oneTime
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
extraOfferCodes: Array.isArray(row.extraOfferCodes)
|
|
? row.extraOfferCodes
|
|
.map(function (item) {
|
|
return trimString(item);
|
|
})
|
|
.filter(Boolean)
|
|
: [],
|
|
};
|
|
});
|
|
const scopeExtraOffers = (
|
|
Array.isArray(scope.extraOffers) ? scope.extraOffers : []
|
|
)
|
|
.map(function (row) {
|
|
const code = trimString(row.code);
|
|
const label = trimString(row.label);
|
|
const url = trimString(row.url);
|
|
if (!code || !label || !url) {
|
|
return null;
|
|
}
|
|
return {
|
|
code: code,
|
|
label: label,
|
|
url: url,
|
|
description: trimString(row.description),
|
|
billingType:
|
|
trimString(row.billingType || "one_time") === "monthly"
|
|
? "monthly"
|
|
: "one_time",
|
|
audiences: parseCsv(row.audiences),
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
profileConfigs[normalized] = {
|
|
packageProductMap: scopePackageProductMap,
|
|
packageSummaryMap: scopePackageSummaryMap,
|
|
packagePriceMap: scopePackagePriceMap,
|
|
addonProductMap: scopeAddonProductMap,
|
|
addonLabelMap: scopeAddonLabelMap,
|
|
addonSummaryMap: scopeAddonSummaryMap,
|
|
addonPriceMap: scopeAddonPriceMap,
|
|
oneTimeProductMap: scopeOneTimeProductMap,
|
|
oneTimeLabelMap: scopeOneTimeLabelMap,
|
|
oneTimeSummaryMap: scopeOneTimeSummaryMap,
|
|
oneTimePriceMap: scopeOneTimePriceMap,
|
|
audienceCatalogs: scopeAudienceCatalogs,
|
|
extraOffers: scopeExtraOffers,
|
|
};
|
|
});
|
|
|
|
const useScopedConfigs = Object.keys(profileConfigs).length > 0;
|
|
|
|
return {
|
|
version: 1,
|
|
runtime: {
|
|
activePaymenterProfile: paymenterProfileSelectEl
|
|
? trimString(paymenterProfileSelectEl.value)
|
|
: "",
|
|
},
|
|
packageProductMap: useScopedConfigs ? {} : packageProductMap,
|
|
packageSummaryMap: useScopedConfigs ? {} : packageSummaryMap,
|
|
packagePriceMap: useScopedConfigs ? {} : packagePriceMap,
|
|
addonProductMap: useScopedConfigs ? {} : addonProductMap,
|
|
addonLabelMap: useScopedConfigs ? {} : addonLabelMap,
|
|
addonSummaryMap: useScopedConfigs ? {} : addonSummaryMap,
|
|
addonPriceMap: useScopedConfigs ? {} : addonPriceMap,
|
|
oneTimeProductMap: useScopedConfigs ? {} : oneTimeProductMap,
|
|
oneTimeLabelMap: useScopedConfigs ? {} : oneTimeLabelMap,
|
|
oneTimeSummaryMap: useScopedConfigs ? {} : oneTimeSummaryMap,
|
|
oneTimePriceMap: useScopedConfigs ? {} : oneTimePriceMap,
|
|
audienceCatalogs: useScopedConfigs ? {} : audienceCatalogs,
|
|
extraOffers: useScopedConfigs ? [] : extraOffers,
|
|
profileConfigs: profileConfigs,
|
|
};
|
|
}
|
|
|
|
function applyAudiencePreviewRules(catalog, config, audienceKey) {
|
|
const rules =
|
|
config && config.audienceCatalogs && config.audienceCatalogs[audienceKey]
|
|
? config.audienceCatalogs[audienceKey]
|
|
: null;
|
|
const subscriptions = Array.isArray(catalog && catalog.subscriptions)
|
|
? catalog.subscriptions
|
|
: [];
|
|
const addons = Array.isArray(catalog && catalog.addons)
|
|
? catalog.addons
|
|
: [];
|
|
const extraOffers = Array.isArray(config && config.extraOffers)
|
|
? config.extraOffers
|
|
: [];
|
|
if (!rules) {
|
|
return {
|
|
subscriptions: subscriptions,
|
|
addons: addons,
|
|
extraOffers: audienceKey
|
|
? extraOffers.filter(function (offer) {
|
|
return (
|
|
!Array.isArray(offer.audiences) ||
|
|
offer.audiences.length === 0 ||
|
|
offer.audiences.indexOf(audienceKey) !== -1
|
|
);
|
|
})
|
|
: extraOffers,
|
|
};
|
|
}
|
|
const packageSet = new Set(
|
|
Array.isArray(rules.packages) ? rules.packages : []
|
|
);
|
|
const addonSet = new Set(Array.isArray(rules.addons) ? rules.addons : []);
|
|
const oneTimeSet = new Set(
|
|
Array.isArray(rules.oneTime) ? rules.oneTime : []
|
|
);
|
|
const offersSet = new Set(
|
|
Array.isArray(rules.extraOfferCodes) ? rules.extraOfferCodes : []
|
|
);
|
|
return {
|
|
subscriptions: subscriptions.filter(function (entry) {
|
|
const code = trimString(entry.code || entry.tier || entry.packageTier);
|
|
return code ? packageSet.has(code) : false;
|
|
}),
|
|
addons: addons.filter(function (entry) {
|
|
const code = trimString(entry.code || entry.id || entry.productId);
|
|
if (!code) {
|
|
return false;
|
|
}
|
|
if (isMonthlyAddonPreviewEntry(entry)) {
|
|
return addonSet.has(code);
|
|
}
|
|
return oneTimeSet.has(code);
|
|
}),
|
|
extraOffers: extraOffers.filter(function (offer) {
|
|
return offersSet.has(trimString(offer.code));
|
|
}),
|
|
};
|
|
}
|
|
|
|
function renderAudiencePreview() {
|
|
if (!catalogPreviewEl) {
|
|
return;
|
|
}
|
|
const config = buildConfigFromForm();
|
|
const audienceKey = previewAudienceEl
|
|
? trimString(previewAudienceEl.value)
|
|
: "";
|
|
const preview = applyAudiencePreviewRules(
|
|
connectorCatalogPreview,
|
|
config,
|
|
audienceKey
|
|
);
|
|
const normalizedPreview = {
|
|
subscriptions: (Array.isArray(preview.subscriptions)
|
|
? preview.subscriptions
|
|
: []
|
|
).map(function (entry) {
|
|
return entry && typeof entry === "object" ? { ...entry } : entry;
|
|
}),
|
|
addons: (Array.isArray(preview.addons) ? preview.addons : []).map(
|
|
function (entry) {
|
|
return entry && typeof entry === "object" ? { ...entry } : entry;
|
|
}
|
|
),
|
|
extraOffers: (Array.isArray(preview.extraOffers)
|
|
? preview.extraOffers
|
|
: []
|
|
).map(function (entry) {
|
|
return entry && typeof entry === "object" ? { ...entry } : entry;
|
|
}),
|
|
};
|
|
const payload = {
|
|
explanation: {
|
|
audienceKey: audienceKey || "(default)",
|
|
description: audienceKey
|
|
? "Preview for this audience key. Only mapped items for that audience are shown."
|
|
: "Default preview without audience filtering.",
|
|
},
|
|
whatUserSees: normalizedPreview,
|
|
};
|
|
catalogPreviewEl.textContent = JSON.stringify(payload, null, 2);
|
|
renderAudiencePreviewSurface(payload);
|
|
}
|
|
|
|
function buildPreviewCard(title, summary, meta) {
|
|
const card = document.createElement("div");
|
|
card.className = "chd-preview-card";
|
|
const titleEl = document.createElement("div");
|
|
titleEl.className = "chd-preview-card-title";
|
|
titleEl.textContent = title;
|
|
const summaryEl = document.createElement("div");
|
|
summaryEl.className =
|
|
"chd-preview-card-meta chd-preview-card-meta--summary";
|
|
summaryEl.textContent = summary;
|
|
const metaEl = document.createElement("div");
|
|
metaEl.className = "chd-preview-card-meta";
|
|
metaEl.textContent = meta;
|
|
card.appendChild(titleEl);
|
|
card.appendChild(summaryEl);
|
|
card.appendChild(metaEl);
|
|
return card;
|
|
}
|
|
|
|
function renderAudiencePreviewSurface(payload) {
|
|
if (!catalogPreviewRenderEl) {
|
|
return;
|
|
}
|
|
const userView =
|
|
payload && payload.whatUserSees ? payload.whatUserSees : {};
|
|
const subscriptions = Array.isArray(userView.subscriptions)
|
|
? userView.subscriptions
|
|
: [];
|
|
const allAddons = Array.isArray(userView.addons) ? userView.addons : [];
|
|
const offers = Array.isArray(userView.extraOffers)
|
|
? userView.extraOffers
|
|
: [];
|
|
const monthlyAddons = allAddons.filter(function (entry) {
|
|
return isMonthlyAddonPreviewEntry(entry);
|
|
});
|
|
const oneTimeProducts = allAddons.filter(function (entry) {
|
|
if (isMonthlyAddonPreviewEntry(entry)) {
|
|
return false;
|
|
}
|
|
const purchaseType = trimString(entry.purchaseType || "").toLowerCase();
|
|
const purchaseInterval = trimString(
|
|
entry.purchaseInterval || ""
|
|
).toLowerCase();
|
|
return (
|
|
purchaseType === "one_time" ||
|
|
purchaseInterval === "one_time" ||
|
|
(!purchaseType && purchaseInterval !== "monthly")
|
|
);
|
|
});
|
|
catalogPreviewRenderEl.innerHTML = "";
|
|
|
|
const explanation = document.createElement("div");
|
|
explanation.className = "chd-preview-card-meta";
|
|
explanation.textContent =
|
|
payload && payload.explanation && payload.explanation.description
|
|
? payload.explanation.description
|
|
: "Preview data unavailable.";
|
|
catalogPreviewRenderEl.appendChild(explanation);
|
|
const pricingNote = document.createElement("div");
|
|
pricingNote.className = "chd-preview-card-meta";
|
|
pricingNote.textContent =
|
|
"Pricing, currency, and checkout paths are pulled from Paymenter when mapped product IDs resolve. When Paymenter does not expose product info for a row, the configured fallback is the expected display.";
|
|
catalogPreviewRenderEl.appendChild(pricingNote);
|
|
|
|
const buildSection = function (heading, entries, mapper, emptyText) {
|
|
const section = document.createElement("section");
|
|
section.className = "chd-preview-section";
|
|
const h = document.createElement("h5");
|
|
h.textContent = heading;
|
|
section.appendChild(h);
|
|
if (!entries.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "chd-preview-card-meta";
|
|
empty.textContent = emptyText;
|
|
section.appendChild(empty);
|
|
catalogPreviewRenderEl.appendChild(section);
|
|
return;
|
|
}
|
|
const grid = document.createElement("div");
|
|
grid.className = "chd-preview-grid";
|
|
entries.forEach(function (entry) {
|
|
grid.appendChild(mapper(entry));
|
|
});
|
|
section.appendChild(grid);
|
|
catalogPreviewRenderEl.appendChild(section);
|
|
};
|
|
|
|
buildSection(
|
|
"Subscription Packages",
|
|
subscriptions,
|
|
function (entry) {
|
|
const title = trimString(
|
|
entry.name || entry.label || entry.code || entry.tier || "Package"
|
|
);
|
|
const summary = resolvePackageSummaryForPreview(entry);
|
|
const price = resolveCatalogPriceForPreview(
|
|
entry,
|
|
catalogConfigState.packageRows
|
|
);
|
|
const source = trimString(entry.source || "");
|
|
const sourceLabel = prettifyCatalogSourceLabel(source);
|
|
const meta =
|
|
"Code: " +
|
|
trimString(entry.code || entry.tier || "") +
|
|
" | Product ID: " +
|
|
trimString(entry.productId || "") +
|
|
(price ? " | " + price : "") +
|
|
(sourceLabel ? " | source: " + sourceLabel : "");
|
|
return buildPreviewCard(title, summary, meta);
|
|
},
|
|
"No packages for this audience."
|
|
);
|
|
|
|
buildSection(
|
|
"Monthly Add-ons",
|
|
monthlyAddons,
|
|
function (entry) {
|
|
const title = trimString(
|
|
entry.name || entry.label || entry.code || "Monthly Add-on"
|
|
);
|
|
const summary = resolveAddonSummaryForPreview(entry);
|
|
const price = resolveCatalogPriceForPreview(
|
|
entry,
|
|
catalogConfigState.addonRows
|
|
);
|
|
const source = trimString(entry.source || "");
|
|
const sourceLabel = prettifyCatalogSourceLabel(source);
|
|
const meta =
|
|
"Code: " +
|
|
trimString(entry.code || "") +
|
|
" | Product ID: " +
|
|
trimString(entry.productId || "") +
|
|
(price ? " | " + price : "") +
|
|
(sourceLabel ? " | source: " + sourceLabel : "");
|
|
return buildPreviewCard(title, summary, meta);
|
|
},
|
|
"No monthly add-ons for this audience."
|
|
);
|
|
|
|
buildSection(
|
|
"One-Time Products",
|
|
oneTimeProducts,
|
|
function (entry) {
|
|
const title = trimString(
|
|
entry.name || entry.label || entry.code || "One-time Product"
|
|
);
|
|
const summary = resolveOneTimeSummaryForPreview(entry);
|
|
const price = resolveCatalogPriceForPreview(
|
|
entry,
|
|
catalogConfigState.oneTimeRows
|
|
);
|
|
const source = trimString(entry.source || "");
|
|
const sourceLabel = prettifyCatalogSourceLabel(source);
|
|
const meta =
|
|
"Code: " +
|
|
trimString(entry.code || "") +
|
|
" | Product ID: " +
|
|
trimString(entry.productId || "") +
|
|
(price ? " | " + price : "") +
|
|
(sourceLabel ? " | source: " + sourceLabel : "");
|
|
return buildPreviewCard(title, summary, meta);
|
|
},
|
|
"No one-time products for this audience."
|
|
);
|
|
|
|
buildSection(
|
|
"Extra CHD Offers",
|
|
offers,
|
|
function (entry) {
|
|
const title = trimString(entry.label || entry.code || "Offer");
|
|
const summary = trimString(
|
|
entry.description || "No description configured."
|
|
);
|
|
const billingType =
|
|
trimString(entry.billingType || "one_time") === "monthly"
|
|
? "monthly"
|
|
: "one-time";
|
|
const source = trimString(entry.source || "");
|
|
const sourceLabel = prettifyCatalogSourceLabel(source);
|
|
const meta =
|
|
"Code: " +
|
|
trimString(entry.code || "") +
|
|
" | Billing: " +
|
|
billingType +
|
|
" | URL: " +
|
|
trimString(entry.url || "") +
|
|
(sourceLabel ? " | source: " + sourceLabel : "");
|
|
return buildPreviewCard(title, summary, meta);
|
|
},
|
|
"No extra offers for this audience."
|
|
);
|
|
}
|
|
|
|
function renderCatalogConfigState() {
|
|
persistCurrentCatalogScopeState();
|
|
catalogConfigState.packageRows = ensureDefaultPackageRows(
|
|
catalogConfigState.packageRows
|
|
);
|
|
catalogConfigState.audienceRows = ensureDefaultAudienceRows(
|
|
catalogConfigState.audienceRows
|
|
);
|
|
setSelectedAudienceKey(catalogConfigState.selectedAudienceKey);
|
|
renderRuntimeControls();
|
|
renderPackageRows();
|
|
renderAddonRows();
|
|
renderOneTimeRows();
|
|
renderAudienceRows();
|
|
renderExtraOffersRows();
|
|
renderAudiencePreview();
|
|
}
|
|
|
|
async function loadConnectorCatalogConfig() {
|
|
if (!catalogConfigStatusEl) {
|
|
return;
|
|
}
|
|
if (!connectorCatalogConfigUrl) {
|
|
catalogConfigStatusEl.textContent =
|
|
"Connector catalog config endpoint is not configured.";
|
|
return;
|
|
}
|
|
catalogConfigStatusEl.textContent = "Loading...";
|
|
try {
|
|
const payload = await requestJson(connectorCatalogConfigUrl, {
|
|
method: "GET",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
});
|
|
applyConnectorPackageMeta(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
applyConnectorRuntimeMeta(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
const configValue = normalizeCatalogConfigPayload(payload.data || {});
|
|
const nextProfileConfigs =
|
|
buildProfileConfigsFromConfigValue(configValue);
|
|
const desiredScope =
|
|
trimString(catalogConfigState.selectedCatalogScopeProfile) ||
|
|
trimString(
|
|
configValue.runtime && configValue.runtime.activePaymenterProfile
|
|
) ||
|
|
trimString(catalogConfigState.runtime.activePaymenterProfile);
|
|
catalogConfigState = {
|
|
selectedCatalogScopeProfile: desiredScope,
|
|
profileConfigs: nextProfileConfigs,
|
|
runtime: catalogConfigState.runtime,
|
|
packageRows: [],
|
|
addonRows: [],
|
|
oneTimeRows: [],
|
|
audienceRows: [],
|
|
extraOffers: [],
|
|
selectedAudienceKey: "",
|
|
};
|
|
applyCatalogScopeState(desiredScope);
|
|
connectorCatalogPreview =
|
|
payload &&
|
|
payload.data &&
|
|
payload.data.catalogPreview &&
|
|
typeof payload.data.catalogPreview === "object"
|
|
? payload.data.catalogPreview
|
|
: { subscriptions: [], addons: [] };
|
|
renderCatalogConfigState();
|
|
catalogConfigStatusEl.textContent = JSON.stringify(
|
|
payload.data || {},
|
|
null,
|
|
2
|
|
);
|
|
void loadPaymenterClients();
|
|
void loadPaymenterOrders();
|
|
void loadPaymenterInvoices();
|
|
void loadPaymenterInvoiceItems();
|
|
if (trimString(activeInstanceId)) {
|
|
void loadPaymenterSnapshotForActiveInstance();
|
|
}
|
|
} catch (error) {
|
|
catalogConfigStatusEl.textContent =
|
|
"Failed to load connector catalog config: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
async function saveConnectorCatalogConfig() {
|
|
if (!catalogConfigStatusEl) {
|
|
return;
|
|
}
|
|
if (!saveConnectorCatalogConfigUrl) {
|
|
catalogConfigStatusEl.textContent =
|
|
"Connector catalog config save endpoint is not configured.";
|
|
return;
|
|
}
|
|
const parsed = buildConfigFromForm();
|
|
catalogConfigStatusEl.textContent = "Saving...";
|
|
try {
|
|
const payload = await requestJson(saveConnectorCatalogConfigUrl, {
|
|
method: "POST",
|
|
headers: getRequestHeaders("application/json"),
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
config: parsed,
|
|
}),
|
|
});
|
|
applyConnectorPackageMeta(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
applyConnectorRuntimeMeta(
|
|
payload && payload.data && typeof payload.data === "object"
|
|
? payload.data
|
|
: {}
|
|
);
|
|
const configValue = normalizeCatalogConfigPayload(payload.data || {});
|
|
const nextProfileConfigs =
|
|
buildProfileConfigsFromConfigValue(configValue);
|
|
const desiredScope =
|
|
trimString(catalogConfigState.selectedCatalogScopeProfile) ||
|
|
trimString(
|
|
configValue.runtime && configValue.runtime.activePaymenterProfile
|
|
) ||
|
|
trimString(catalogConfigState.runtime.activePaymenterProfile);
|
|
catalogConfigState = {
|
|
selectedCatalogScopeProfile: desiredScope,
|
|
profileConfigs: nextProfileConfigs,
|
|
runtime: catalogConfigState.runtime,
|
|
packageRows: [],
|
|
addonRows: [],
|
|
oneTimeRows: [],
|
|
audienceRows: [],
|
|
extraOffers: [],
|
|
selectedAudienceKey: "",
|
|
};
|
|
applyCatalogScopeState(desiredScope);
|
|
connectorCatalogPreview =
|
|
payload &&
|
|
payload.data &&
|
|
payload.data.catalogPreview &&
|
|
typeof payload.data.catalogPreview === "object"
|
|
? payload.data.catalogPreview
|
|
: connectorCatalogPreview;
|
|
renderCatalogConfigState();
|
|
catalogConfigStatusEl.textContent = JSON.stringify(
|
|
payload.data || {},
|
|
null,
|
|
2
|
|
);
|
|
void loadPaymenterClients();
|
|
void loadPaymenterOrders();
|
|
void loadPaymenterInvoices();
|
|
void loadPaymenterInvoiceItems();
|
|
if (trimString(activeInstanceId)) {
|
|
void loadPaymenterSnapshotForActiveInstance();
|
|
}
|
|
} catch (error) {
|
|
catalogConfigStatusEl.textContent =
|
|
"Failed to save connector catalog config: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
if (refreshButton) {
|
|
refreshButton.addEventListener("click", function () {
|
|
void loadOverview();
|
|
});
|
|
}
|
|
|
|
if (connectorRoutingSaveButton) {
|
|
connectorRoutingSaveButton.addEventListener("click", function () {
|
|
void saveConnectorRoutingConfig();
|
|
});
|
|
}
|
|
|
|
if (connectorAdminTokenSaveButton) {
|
|
connectorAdminTokenSaveButton.addEventListener("click", function () {
|
|
void saveConnectorAdminToken();
|
|
});
|
|
}
|
|
|
|
if (billingAutoFulfillmentSaveButton) {
|
|
billingAutoFulfillmentSaveButton.addEventListener("click", function () {
|
|
void saveBillingAutoFulfillment();
|
|
});
|
|
}
|
|
|
|
if (billingAutoFulfillmentRunButton) {
|
|
billingAutoFulfillmentRunButton.addEventListener("click", function () {
|
|
void runBillingAutoFulfillment();
|
|
});
|
|
}
|
|
|
|
if (billingAutoFulfillmentBackfillButton) {
|
|
billingAutoFulfillmentBackfillButton.addEventListener("click", function () {
|
|
void backfillBillingAutoFulfillment();
|
|
});
|
|
}
|
|
|
|
if (connectorRefreshButton) {
|
|
connectorRefreshButton.addEventListener("click", function () {
|
|
void loadConnectorSummary();
|
|
});
|
|
}
|
|
|
|
if (instancesRefreshButton) {
|
|
instancesRefreshButton.addEventListener("click", function () {
|
|
void loadConnectorInstances();
|
|
});
|
|
}
|
|
|
|
if (instanceLoadButton) {
|
|
instanceLoadButton.addEventListener("click", function () {
|
|
const selectedId = instanceSelectEl
|
|
? String(instanceSelectEl.value || "").trim()
|
|
: "";
|
|
void loadConnectorInstanceDetail(selectedId);
|
|
});
|
|
}
|
|
|
|
if (instanceSelectEl) {
|
|
instanceSelectEl.addEventListener("change", function () {
|
|
const selectedId = String(instanceSelectEl.value || "").trim();
|
|
void loadConnectorInstanceDetail(selectedId);
|
|
});
|
|
}
|
|
|
|
if (instanceConnectorProfileEl) {
|
|
instanceConnectorProfileEl.addEventListener("change", function () {
|
|
const selectedProfile = trimString(instanceConnectorProfileEl.value);
|
|
const profileEntry = findSelectableConnectorProfile(selectedProfile);
|
|
if (
|
|
instanceConnectorUrlEl &&
|
|
profileEntry &&
|
|
trimString(instanceConnectorUrlEl.value) === ""
|
|
) {
|
|
instanceConnectorUrlEl.value = trimString(profileEntry.publicBaseUrl);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (instanceConnectorSaveButton) {
|
|
instanceConnectorSaveButton.addEventListener("click", function () {
|
|
void saveInstanceConnectorTarget();
|
|
});
|
|
}
|
|
|
|
if (instanceMetadataSaveButton) {
|
|
instanceMetadataSaveButton.addEventListener("click", function () {
|
|
void saveInstanceMetadata();
|
|
});
|
|
}
|
|
|
|
if (instanceRemoveButton) {
|
|
instanceRemoveButton.addEventListener("click", function () {
|
|
const instanceId = trimString(activeInstanceId);
|
|
if (!instanceId) {
|
|
if (instanceConnectorStatusEl) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Load an instance before removing it from the active configuration.";
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
!window.confirm(
|
|
"Remove " + instanceId + " from the active connector configuration?"
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
void saveConnectorInstanceState(instanceId, "remove").catch(function (
|
|
error
|
|
) {
|
|
if (instanceConnectorStatusEl) {
|
|
instanceConnectorStatusEl.textContent =
|
|
"Failed to update instance state: " +
|
|
(error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
if (paymenterProfileViewEl) {
|
|
paymenterProfileViewEl.addEventListener("change", function () {
|
|
void loadPaymenterClients();
|
|
void loadPaymenterOrders();
|
|
void loadPaymenterInvoices();
|
|
void loadPaymenterInvoiceItems();
|
|
void loadPaymenterSnapshotForActiveInstance();
|
|
});
|
|
}
|
|
|
|
if (paymenterClientsRefreshButton) {
|
|
paymenterClientsRefreshButton.addEventListener("click", function () {
|
|
void loadPaymenterClients();
|
|
});
|
|
}
|
|
|
|
if (paymenterOrdersRefreshButton) {
|
|
paymenterOrdersRefreshButton.addEventListener("click", function () {
|
|
void loadPaymenterOrders();
|
|
});
|
|
}
|
|
|
|
if (paymenterSnapshotRefreshButton) {
|
|
paymenterSnapshotRefreshButton.addEventListener("click", function () {
|
|
void loadPaymenterSnapshotForActiveInstance();
|
|
});
|
|
}
|
|
|
|
if (paymenterBackupsRefreshButton) {
|
|
paymenterBackupsRefreshButton.addEventListener("click", function () {
|
|
void loadPaymenterBackups();
|
|
});
|
|
}
|
|
|
|
if (paymenterInvoicesRefreshButton) {
|
|
paymenterInvoicesRefreshButton.addEventListener("click", function () {
|
|
void loadPaymenterInvoices();
|
|
});
|
|
}
|
|
|
|
if (paymenterInvoiceItemsRefreshButton) {
|
|
paymenterInvoiceItemsRefreshButton.addEventListener("click", function () {
|
|
void loadPaymenterInvoiceItems();
|
|
});
|
|
}
|
|
|
|
if (paymenterSyncDryRunButton) {
|
|
paymenterSyncDryRunButton.addEventListener("click", function () {
|
|
void runPaymenterSourceSync(true).catch(function (error) {
|
|
setPaymenterStatus(
|
|
"Dry run failed: " +
|
|
(error && error.message ? error.message : String(error))
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (paymenterSyncRunButton) {
|
|
paymenterSyncRunButton.addEventListener("click", function () {
|
|
const scopeLabel = trimString(activeInstanceId)
|
|
? "instance " + trimString(activeInstanceId)
|
|
: "all active connector instances";
|
|
if (
|
|
!window.confirm(
|
|
"Run Paymenter source-of-truth sync for " + scopeLabel + "?"
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
void runPaymenterSourceSync(false).catch(function (error) {
|
|
setPaymenterStatus(
|
|
"Source-of-truth sync failed: " +
|
|
(error && error.message ? error.message : String(error))
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (catalogConfigRefreshButton) {
|
|
catalogConfigRefreshButton.addEventListener("click", function () {
|
|
void loadConnectorCatalogConfig();
|
|
});
|
|
}
|
|
|
|
if (catalogConfigSaveButton) {
|
|
catalogConfigSaveButton.addEventListener("click", function () {
|
|
void saveConnectorCatalogConfig();
|
|
});
|
|
}
|
|
|
|
if (catalogScopeProfileSelectEl) {
|
|
catalogScopeProfileSelectEl.addEventListener("change", function () {
|
|
persistCurrentCatalogScopeState();
|
|
applyCatalogScopeState(trimString(catalogScopeProfileSelectEl.value));
|
|
renderCatalogConfigState();
|
|
});
|
|
}
|
|
|
|
if (packageAddButton) {
|
|
packageAddButton.addEventListener("click", function () {
|
|
catalogConfigState.packageRows.push({
|
|
code: "",
|
|
productId: "",
|
|
price: "",
|
|
summary: "",
|
|
});
|
|
renderPackageRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (addonAddButton) {
|
|
addonAddButton.addEventListener("click", function () {
|
|
catalogConfigState.addonRows.push({
|
|
code: "",
|
|
productId: "",
|
|
label: "",
|
|
price: "",
|
|
summary: "",
|
|
});
|
|
renderAddonRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (oneTimeAddButton) {
|
|
oneTimeAddButton.addEventListener("click", function () {
|
|
catalogConfigState.oneTimeRows.push({
|
|
code: "",
|
|
productId: "",
|
|
label: "",
|
|
price: "",
|
|
summary: "",
|
|
});
|
|
renderOneTimeRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (audienceAddButton) {
|
|
audienceAddButton.addEventListener("click", function () {
|
|
const requestedKey = trimString(
|
|
audienceCreateNameEl ? audienceCreateNameEl.value : ""
|
|
);
|
|
if (!requestedKey) {
|
|
if (audienceManageStatusEl) {
|
|
audienceManageStatusEl.textContent =
|
|
"Enter an audience name before creating it.";
|
|
}
|
|
return;
|
|
}
|
|
if (getAudienceRowByKey(requestedKey)) {
|
|
if (audienceManageStatusEl) {
|
|
audienceManageStatusEl.textContent =
|
|
"Audience already exists: " + requestedKey;
|
|
}
|
|
setSelectedAudienceKey(requestedKey);
|
|
renderAudienceRows();
|
|
renderAudiencePreview();
|
|
return;
|
|
}
|
|
catalogConfigState.audienceRows.push(createAudienceRow(requestedKey));
|
|
setSelectedAudienceKey(requestedKey);
|
|
if (previewAudienceEl) {
|
|
previewAudienceEl.value = requestedKey;
|
|
}
|
|
if (audienceCreateNameEl) {
|
|
audienceCreateNameEl.value = "";
|
|
}
|
|
if (audienceManageStatusEl) {
|
|
audienceManageStatusEl.textContent =
|
|
"Created audience: " + requestedKey;
|
|
}
|
|
renderAudienceRows();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (audienceRemoveCurrentButton) {
|
|
audienceRemoveCurrentButton.addEventListener("click", function () {
|
|
const selectedKey = trimString(catalogConfigState.selectedAudienceKey);
|
|
if (!selectedKey || isDefaultAudienceKey(selectedKey)) {
|
|
if (audienceManageStatusEl) {
|
|
audienceManageStatusEl.textContent = selectedKey
|
|
? "Default audiences stay available by default and cannot be removed here."
|
|
: "Select an audience first.";
|
|
}
|
|
return;
|
|
}
|
|
catalogConfigState.audienceRows = catalogConfigState.audienceRows.filter(
|
|
function (row) {
|
|
return trimString(row && row.key) !== selectedKey;
|
|
}
|
|
);
|
|
setSelectedAudienceKey("");
|
|
if (previewAudienceEl) {
|
|
previewAudienceEl.value = trimString(
|
|
catalogConfigState.selectedAudienceKey
|
|
);
|
|
}
|
|
if (audienceManageStatusEl) {
|
|
audienceManageStatusEl.textContent = "Removed audience: " + selectedKey;
|
|
}
|
|
renderAudienceRows();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (extraOffersAddButton) {
|
|
extraOffersAddButton.addEventListener("click", function () {
|
|
catalogConfigState.extraOffers.push({
|
|
code: "",
|
|
label: "",
|
|
url: "",
|
|
description: "",
|
|
billingType: "one_time",
|
|
audiences: "",
|
|
});
|
|
renderExtraOffersRows();
|
|
renderAudienceEditor();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (audienceSelectEl) {
|
|
audienceSelectEl.addEventListener("change", function () {
|
|
setSelectedAudienceKey(audienceSelectEl.value);
|
|
if (previewAudienceEl) {
|
|
previewAudienceEl.value = trimString(
|
|
catalogConfigState.selectedAudienceKey
|
|
);
|
|
}
|
|
if (audienceManageStatusEl) {
|
|
audienceManageStatusEl.textContent = "";
|
|
}
|
|
renderAudienceRows();
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (previewAudienceEl) {
|
|
previewAudienceEl.addEventListener("change", function () {
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (previewRefreshButton) {
|
|
previewRefreshButton.addEventListener("click", function () {
|
|
renderAudiencePreview();
|
|
});
|
|
}
|
|
|
|
if (operationsRefreshButton) {
|
|
operationsRefreshButton.addEventListener("click", function () {
|
|
if (!activeInstanceId) {
|
|
window.alert("Select an instance first.");
|
|
return;
|
|
}
|
|
void loadInstanceOperations(activeInstanceId, activeInstanceDetailData);
|
|
});
|
|
}
|
|
|
|
if (operationsFormEl) {
|
|
operationsFormEl.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
const instanceId = cleanString(activeInstanceId);
|
|
if (!instanceId) {
|
|
window.alert("Select an instance before adding a task.");
|
|
return;
|
|
}
|
|
const title = cleanString(
|
|
operationsTitleInputEl && operationsTitleInputEl.value
|
|
);
|
|
if (!title) {
|
|
window.alert("Task title is required.");
|
|
return;
|
|
}
|
|
const selectedOption =
|
|
operationsUserSelectEl &&
|
|
operationsUserSelectEl.selectedOptions.length > 0
|
|
? operationsUserSelectEl.selectedOptions[0]
|
|
: null;
|
|
const task = {
|
|
source: "manual",
|
|
taskType:
|
|
cleanString(operationsTypeSelectEl && operationsTypeSelectEl.value) ||
|
|
"manual",
|
|
title: title,
|
|
status: "pending",
|
|
nextcloudUserId: selectedOption
|
|
? cleanString(selectedOption.dataset.nextcloudUserId)
|
|
: "",
|
|
paymenterCustomerId: selectedOption
|
|
? cleanString(selectedOption.dataset.paymenterCustomerId)
|
|
: "",
|
|
userLabel: selectedOption
|
|
? cleanString(selectedOption.dataset.userLabel)
|
|
: "",
|
|
productCode: cleanString(
|
|
operationsCodeInputEl && operationsCodeInputEl.value
|
|
),
|
|
orderRef: cleanString(
|
|
operationsCodeInputEl && operationsCodeInputEl.value
|
|
),
|
|
notes: cleanString(
|
|
operationsNotesInputEl && operationsNotesInputEl.value
|
|
),
|
|
};
|
|
void saveInstanceOperationTask(instanceId, task)
|
|
.then(function () {
|
|
if (operationsTitleInputEl) {
|
|
operationsTitleInputEl.value = "";
|
|
}
|
|
if (operationsCodeInputEl) {
|
|
operationsCodeInputEl.value = "";
|
|
}
|
|
if (operationsNotesInputEl) {
|
|
operationsNotesInputEl.value = "";
|
|
}
|
|
return loadInstanceOperations(instanceId, activeInstanceDetailData);
|
|
})
|
|
.catch(function (error) {
|
|
window.alert(
|
|
"Failed to save operation task: " +
|
|
(error && error.message ? error.message : String(error))
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
Object.keys(infoPanels).forEach(function (panelKey) {
|
|
bindInfoPanelControls(infoPanels[panelKey]);
|
|
});
|
|
setInstanceDetailIdle("Select an instance to view details.");
|
|
|
|
updateChdcOpenUrl();
|
|
void loadOverview();
|
|
void loadConnectorRoutingConfig();
|
|
void loadConnectorSummary();
|
|
void loadConnectorInstances().then(function () {
|
|
if (instanceSelectEl && instanceSelectEl.value) {
|
|
return loadConnectorInstanceDetail(instanceSelectEl.value);
|
|
}
|
|
if (connectorInstancesCache.length > 0) {
|
|
const first = connectorInstancesCache[0];
|
|
const firstId =
|
|
first && typeof first === "object"
|
|
? String(first.instanceId || "").trim()
|
|
: "";
|
|
if (firstId) {
|
|
return loadConnectorInstanceDetail(firstId);
|
|
}
|
|
}
|
|
return Promise.resolve();
|
|
});
|
|
void loadConnectorCatalogConfig();
|
|
void loadPaymenterBackups();
|
|
})();
|