Source: services/webhookService.js

/**
 * @fileoverview Service for managing webhooks in Satoshi Showdown.
 * Handles the lifecycle of webhooks including creation, processing, and deletion.
 * Interacts with the BlockCypher API for blockchain event monitoring and manages local
 * database records for webhooks. This service is integral to the platform's event-driven
 * architecture, particularly for monitoring blockchain transactions.
 *
 * @module services/webhookService
 * @requires models/webhookModel - Data model for webhooks.
 * @requires services/transactionService - For updating transaction records.
 * @requires services/walletService - For managing wallet operations.
 * @requires utils/apiUtil - For making external API requests.
 * @requires utils/errorUtil - For custom error handling.
 * @requires utils/logUtil - For application-wide logging.
 */

const Webhook = require("../models/webhookModel");
const { updateTransactionById } = require("./transactionService");
const { updateWalletBalanceById, addUTXOToWallet } = require("./walletService");
const { postAPI, deleteAPI } = require("../utils/apiUtil");
const { NotFoundError } = require("../utils/errorUtil");
const log = require("../utils/logUtil");

// Configuration for external API interaction
const apiBaseUrl = process.env.BLOCKCYPHER_BASE_URL;
const apiToken = process.env.BLOCKCYPHER_TOKEN;

/**
 * Creates a new webhook in the Satoshi Showdown application and registers it with BlockCypher.
 * This webhook monitors blockchain events for a specific address and tracks transaction confirmations.
 *
 * @async
 * @param {string} address - The blockchain address to monitor.
 * @param {string} transactionRef - The reference ID of the associated transaction.
 * @return {Promise<Object>} A promise resolving to the newly created webhook object.
 * @throws {Error} If an error occurs during webhook creation or registration.
 */
const createWebhook = async (
  monitoredAddress,
  walletRef,
  transactionRef,
  userRef,
  eventRef,
) => {
  // Define the new webhook and save it in the database
  const newWebhook = new Webhook({
    monitoredAddress,
    type: "tx-confirmation",
    transactionRef,
    walletRef,
    userRef,
    eventRef,
  });
  await newWebhook.save();

  // Register the webhook with BlockCypher
  const callbackUrl = `${process.env.WEBHOOK_DOMAIN}/webhook/receive/${newWebhook.urlId}`;
  const webhookData = {
    event: "tx-confirmation",
    address: monitoredAddress,
    url: callbackUrl,
    confirmations: 6,
    token: apiToken,
  };
  const response = await postAPI(
    `${apiBaseUrl}/hooks?token=${apiToken}`,
    webhookData,
  );

  // Update the local webhook record with the response
  await Webhook.findByIdAndUpdate(newWebhook._id, { response });
  log.debug(`Webhook created: ${JSON.stringify(response, null, 2)}`);
  return newWebhook;
};

/**
 * Processes a received webhook by updating its status and associated transaction and wallet records.
 * Efficiently handles the headers, body, and confirmation status of the incoming webhook data.
 *
 * @async
 * @param {string} urlId - The unique identifier of the webhook.
 * @param {Object} headers - The HTTP headers of the incoming webhook request.
 * @param {Object} data - The JSON payload of the webhook request.
 * @throws {NotFoundError} If the webhook with the specified URL ID is not found.
 */
const processWebhook = async (urlId, headers, data) => {
  console.log(data);

  let webhook = await _getWebhook(urlId);

  // Update webhook with received data and process its status
  const updateData = {
    headers,
    body: data,
    ...(await _processWebhookStatus(webhook, data.confirmations)),
  };
  webhook = await _updateWebhook(urlId, updateData);

  // Process transaction and wallet updates based on webhook data
  const { transactionUpdate, walletUpdate } =
    await _processWebhookTransactionData(webhook);

  const transaction = await updateTransactionById(
    webhook.transactionRef,
    transactionUpdate,
  );

  const wallet = await updateWalletBalanceById(webhook.walletRef, walletUpdate);

  log.info(
    `Webhook processed: Transaction - ${transaction._id}, Wallet - ${wallet._id}`,
  );
};

/**
 * Retrieves a specific webhook by its URL identifier from the database.
 * This function is internal to the service and not exposed externally.
 *
 * @async
 * @private
 * @param {string} urlId - The URL identifier of the webhook.
 * @return {Promise<Object>} The retrieved webhook object.
 * @throws {NotFoundError} If the webhook with the specified URL ID is not found.
 */
const _getWebhook = async (urlId) => {
  const webhook = await Webhook.findOne({ urlId });
  if (!webhook)
    throw new NotFoundError(`Webhook with URL ID ${urlId} not found`);
  return webhook;
};

/**
 * Updates the status of a webhook based on the current confirmation count.
 * Tracks each confirmation update and marks the webhook as 'processing' or 'success' as appropriate.
 *
 * @async
 * @private
 * @param {Webhook} webhook - The webhook document being processed.
 * @param {number} currentConfirmations - The latest count of confirmations for the transaction.
 * @return {Object} An object with updates for the webhook's status and confirmations.
 * @throws {Error} If there is an issue with processing the webhook status.
 */
const _processWebhookStatus = async (webhook, currentConfirmations) => {
  const statusUpdate = {
    status: webhook.status,
    lastProcessedConfirmation: webhook.currentConfirmation,
    currentConfirmation: currentConfirmations,
    confirmationsReceived: [...webhook.confirmationsReceived],
  };

  if (webhook.status === "pending") statusUpdate.status = "processing";
  statusUpdate.confirmationsReceived[currentConfirmations] = {
    confirmationNumber: currentConfirmations,
    timestamp: new Date(),
  };

  if (currentConfirmations === 6) {
    statusUpdate.status = "success";

    // Delete the webhook from BlockCypher
    await _deleteWebhook(webhook.response.id, webhook._id);
  }

  return statusUpdate;
};

/**
 * Prepares the transaction and wallet update data based on the transaction details from the webhook body.
 * Calculates the amount involved for the monitored address and determines the necessary updates.
 *
 * @async
 * @private
 * @param {Webhook} webhook - The webhook document containing response data.
 * @return {Object} An object containing updates for the transaction and wallet.
 * @throws {Error} Thrown if there is an issue in processing the transaction or wallet update data.
 * @note This function does not directly update the database but prepares data for subsequent updates.
 */
const _processWebhookTransactionData = async (webhook) => {
  const transactionDetails = webhook.body;
  const monitoredAddress = webhook.response.address;

  const amountInvolved = transactionDetails.outputs.reduce((sum, output) => {
    return output.addresses.includes(monitoredAddress)
      ? sum + output.value
      : sum;
  }, 0);

  if (amountInvolved > 0) {
    // Create UTXO records only for the first confirmation
    if (transactionDetails.confirmations === 1) {
      const utxoPromises = transactionDetails.outputs
        .map((output, index) => {
          if (output.addresses.includes(monitoredAddress)) {
            // Prepare UTXO data with the correct output index
            const utxoData = {
              userRef: webhook.userRef,
              eventRef: webhook.eventRef,
              transactionHash: transactionDetails.hash,
              outputIndex: index,
              amount: output.value,
              address: monitoredAddress,
              scriptPubKey: output.script,
              scriptType: output.script_type,
              blockHeight: transactionDetails.block_height,
              timestamp: new Date(transactionDetails.received),
            };

            // Add UTXO to wallet
            return addUTXOToWallet(webhook.walletRef, utxoData);
          }
          return null;
        })
        .filter((promise) => promise !== null);

      try {
        await Promise.all(utxoPromises);
      } catch (error) {
        log.error("Error creating UTXOs:", error);
      }
    }

    // Determine transaction status based on confirmation count
    const isConfirmed = transactionDetails.confirmations >= 6;
    const transactionStatus = isConfirmed ? "completed" : "confirming";

    // Prepare transaction update data
    const transactionUpdate = {
      confirmations: transactionDetails.confirmations,
      status: transactionStatus,
      confirmedAmount: isConfirmed ? amountInvolved : 0,
      unconfirmedAmount: !isConfirmed ? amountInvolved : 0,
    };

    // Prepare wallet update data
    const walletUpdate = {
      confirmedIncrement: isConfirmed ? amountInvolved : 0,
      unconfirmedIncrement:
        transactionDetails.confirmations === 1 ? amountInvolved : 0,
      unconfirmedDecrement: isConfirmed ? amountInvolved : 0,
    };

    return { transactionUpdate, walletUpdate };
  } else {
    log.info(
      `No transaction amount for monitored address: ${monitoredAddress}`,
    );
    return { transactionUpdate: null, walletUpdate: null };
  }
};

/**
 * Updates the database record of a specific webhook with new data.
 * Primarily used to update the status and data payload of a webhook.
 *
 * @async
 * @private
 * @param {string} urlId - The unique identifier of the webhook.
 * @param {Object} updateData - New data for updating the webhook.
 * @return {Promise<Object>} A promise resolving to the updated webhook object.
 * @throws {NotFoundError} If the webhook with the specified URL ID is not found.
 */
const _updateWebhook = async (urlId, updateData) => {
  const updatedWebhook = await Webhook.findOneAndUpdate({ urlId }, updateData, {
    new: true,
  });
  if (!updatedWebhook)
    throw new NotFoundError(
      `Webhook with URL ID ${urlId} not found for update`,
    );
  return updatedWebhook;
};

/**
 * Soft deletes a webhook in the database and removes its registration with BlockCypher.
 * This is a cleanup operation performed when a webhook is no longer needed.
 *
 * @async
 * @private
 * @param {string} blockcypherId - The Blockcypher ID of the webhook to delete.
 * @param {string} webhookId - The ID of the webhook to delete.
 * @throws {Error} If there's an issue with the deletion process.
 */
const _deleteWebhook = async (blockcypherId, webhookId) => {
  // URL for the DELETE request to BlockCypher API
  const deleteUrl = `${apiBaseUrl}/hooks/${blockcypherId}?token=${apiToken}`;

  // Make the DELETE request to BlockCypher
  await deleteAPI(deleteUrl);

  // Additionally, soft delete the webhook record from the local database
  await Webhook.findByIdAndUpdate(webhookId, { isDeleted: true });
  log.info(`Webhook with ID ${blockcypherId} soft deleted.`);
};

module.exports = {
  createWebhook,
  processWebhook,
};