Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
import { getDb } from "@/dataAccess/getDb";
import { getApi } from "@/dataAccess/getApi";
import { convertFromPrefs } from "@/framework/currencies";
import { ChangesetProcessor } from "@/dataAccess/changeset-processor";

// ============================================================================
// Collection Schema Definitions
// ============================================================================

export interface FieldDef {
  name: string;
  type: "string" | "number" | "boolean" | "json" | "ref";
  required: boolean;
  description?: string;
  refCollection?: string;  // For ref types, which collection it references
}

export interface CollectionSchema {
  name: string;
  description: string;
  fields: FieldDef[];
}

/**
 * Schema definitions for all collections allowed in changeset processing.
 * These define the required and optional fields for create/update operations.
 *
 * System fields (id, org, book, created, updated) are added automatically.
 */
export const collectionSchemas: CollectionSchema[] = [
  {
    name: "books",
    description: "Pricebook definitions with parent-child relationships",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
      { name: "parent", type: "ref", required: false, refCollection: "books", description: "Parent book for inheritance" },
    ],
  },
  {
    name: "menus",
    description: "Menu items within books",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
      { name: "books", type: "ref", required: false, refCollection: "books", description: "Associated books" },
    ],
  },
  {
    name: "offers",
    description: "Pricing offers with time and material costs",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
      { name: "costsTime", type: "ref", required: false, refCollection: "costsTime", description: "Time cost items" },
      { name: "costsMaterial", type: "ref", required: false, refCollection: "costsMaterial", description: "Material cost items" },
      { name: "techHandbook", type: "ref", required: false, refCollection: "contentItems", description: "Tech handbook content" },
      { name: "multiplier", type: "number", required: false, description: "Price multiplier" },
    ],
  },
  {
    name: "menuTiers",
    description: "Links menus to tiers and offers",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "menus", type: "ref", required: false, refCollection: "menus", description: "Associated menus" },
      { name: "tiers", type: "ref", required: false, refCollection: "tiers", description: "Associated tier" },
      { name: "offers", type: "ref", required: false, refCollection: "offers", description: "Associated offer" },
      { name: "menuCopy", type: "ref", required: false, refCollection: "menuCopy", description: "Menu copy content" },
      { name: "contentItems", type: "ref", required: false, refCollection: "contentItems", description: "Direct content items" },
    ],
  },
  {
    name: "contentItems",
    description: "Text content blocks (for menus, tech handbook, etc.)",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: false, description: "Display name" },
      { name: "content", type: "string", required: true, description: "Text content" },
    ],
  },
  {
    name: "menuCopy",
    description: "Menu descriptive text with content items",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: false, description: "Display name" },
      { name: "contentItems", type: "ref", required: false, refCollection: "contentItems", description: "Associated content items" },
    ],
  },
  {
    name: "warrantyCopy",
    description: "Warranty text content",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: false, description: "Display name" },
      { name: "contentItems", type: "ref", required: false, refCollection: "contentItems", description: "Associated content items" },
    ],
  },
  {
    name: "problems",
    description: "Service problems/issues",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
      { name: "description", type: "string", required: false, description: "Problem description" },
      { name: "menus", type: "ref", required: false, refCollection: "menus", description: "Associated menus" },
      { name: "problemTags", type: "ref", required: false, refCollection: "problemTags", description: "Associated tags" },
      { name: "problemCategories", type: "ref", required: false, refCollection: "problemCategories", description: "Associated categories" },
    ],
  },
  {
    name: "problemCategories",
    description: "Hierarchical categories for problems",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
      { name: "parent", type: "ref", required: false, refCollection: "problemCategories", description: "Parent category" },
    ],
  },
  {
    name: "problemTags",
    description: "Tags for filtering problems",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
    ],
  },
  {
    name: "checklists",
    description: "Service checklists",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
    ],
  },
  {
    name: "costsMaterial",
    description: "Material cost items (in base currency)",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: false, description: "Display name" },
      { name: "quantity", type: "number", required: true, description: "Cost amount in base currency" },
    ],
  },
  {
    name: "costsTime",
    description: "Time cost items (in hours)",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: false, description: "Display name" },
      { name: "hours", type: "number", required: true, description: "Number of hours" },
    ],
  },
  {
    name: "tierSets",
    description: "Groupings of tiers (e.g., Good/Better/Best)",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
    ],
  },
  {
    name: "tiers",
    description: "Service tier levels",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
      { name: "rank", type: "number", required: true, description: "Tier rank (lower = better)" },
      { name: "tierSets", type: "ref", required: false, refCollection: "tierSets", description: "Associated tier set" },
      { name: "warrantyCopy", type: "ref", required: false, refCollection: "warrantyCopy", description: "Warranty content" },
    ],
  },
  {
    name: "formulas",
    description: "Pricing calculation formulas",
    fields: [
      { name: "refId", type: "string", required: true, description: "Unique identifier within org" },
      { name: "name", type: "string", required: true, description: "Display name" },
    ],
  },
];

/**
 * Get schema for a specific collection
 */
export function getCollectionSchema(collectionName: string): CollectionSchema | undefined {
  return collectionSchemas.find(s => s.name === collectionName);
}

/**
 * Get list of all allowed collection names
 */
export function getAllowedCollections(): string[] {
  return collectionSchemas.map(s => s.name);
}

// ============================================================================
// Changes Data Types and Loading
// ============================================================================

export interface Change {
  id: number;
  book: string | null;
  changeset: any;
  status: string;
  errorMessage: string | null;
  created: string;
}

export interface ChangesData {
  changes: Change[];
  total: number;
}

export interface LoadChangesOptions {
  bookId?: string;
  limit?: number;
  offset?: number;
}

/**
 * Load pricebook changes from the local SQLite database
 * Changes are saved locally when processed via ChangesetProcessor
 */
export async function loadChanges(options: LoadChangesOptions = {}): Promise<ChangesData> {
  const db = await getDb();
  const { bookId, limit = 50, offset = 0 } = options;

  try {
    // Build query with optional book filter
    let whereClause = "";
    if (bookId) {
      whereClause = `WHERE book = '${bookId}'`;
    }

    // Get total count
    const countResult = await db.dbConn.query(
      `SELECT COUNT(*) as total FROM pricebookChanges ${whereClause}`
    );
    const total = countResult.values?.[0]?.total || 0;

    // Get paginated results
    const result = await db.dbConn.query(
      `SELECT * FROM pricebookChanges ${whereClause} ORDER BY created DESC LIMIT ${limit} OFFSET ${offset}`
    );

    const changes: Change[] = (result.values || []).map((row: any) => ({
      id: row.id,
      book: row.book || null,
      changeset: row.changeset ? JSON.parse(row.changeset) : null,
      status: row.status || "success",
      errorMessage: row.error_message || null,
      created: row.created,
    }));

    return {
      changes,
      total,
    };
  } catch (error) {
    console.error("[loadChanges] Error loading changes:", error);
    return { changes: [], total: 0 };
  }
}

/**
 * Get a single change by ID from local database
 */
export async function getChangeById(changeId: number): Promise<Change | null> {
  const db = await getDb();

  try {
    const result = await db.dbConn.query(
      `SELECT * FROM pricebookChanges WHERE id = ${changeId}`
    );

    if (!result.values || result.values.length === 0) {
      return null;
    }

    const row = result.values[0];
    return {
      id: row.id,
      book: row.book || null,
      changeset: row.changeset ? JSON.parse(row.changeset) : null,
      status: row.status || "success",
      errorMessage: row.error_message || null,
      created: row.created,
    };
  } catch (error) {
    console.error("[getChangeById] Error loading change:", error);
    return null;
  }
}

// ============================================================================
// Changeset Creation Helpers
// ============================================================================

export interface CreateCostsMaterialInput {
  refId: string;
  name?: string;
  quantity: number; // In user's preferred currency
}

export interface ChangesetItem {
  collection: string;
  operation: string;
  data: Record<string, any>;
}

/**
 * Create a costsMaterial changeset item with quantity converted from
 * user's preferred currency to base currency (XAG).
 *
 * @param input - The costsMaterial data with quantity in preferred currency
 * @returns Changeset item ready for processing, or null if conversion fails
 */
export async function createCostsMaterialChange(
  input: CreateCostsMaterialInput
): Promise<ChangesetItem | null> {
  const baseQuantity = await convertFromPrefs(input.quantity);

  if (baseQuantity === null) {
    console.error("[createCostsMaterialChange] Currency conversion failed - no prefs set");
    return null;
  }

  return {
    collection: "costsMaterial",
    operation: "create",
    data: {
      refId: input.refId,
      name: input.name,
      quantity: baseQuantity,
    },
  };
}

/**
 * Create multiple costsMaterial changeset items with quantities converted
 * from user's preferred currency to base currency.
 *
 * @param inputs - Array of costsMaterial data with quantities in preferred currency
 * @returns Array of changeset items ready for processing
 */
export async function createCostsMaterialChanges(
  inputs: CreateCostsMaterialInput[]
): Promise<ChangesetItem[]> {
  const results: ChangesetItem[] = [];

  for (const input of inputs) {
    const change = await createCostsMaterialChange(input);
    if (change) {
      results.push(change);
    }
  }

  return results;
}

export interface UpdateCostsMaterialInput {
  refId: string; // Required to identify the record to update
  name?: string;
  quantity?: number; // In user's preferred currency
}

/**
 * Create a costsMaterial update changeset item with quantity converted from
 * user's preferred currency to base currency (XAG).
 *
 * @param input - The costsMaterial data with quantity in preferred currency
 * @returns Changeset item ready for processing, or null if conversion fails
 */
export async function updateCostsMaterialChange(
  input: UpdateCostsMaterialInput
): Promise<ChangesetItem | null> {
  const data: Record<string, any> = {
    refId: input.refId,
  };

  if (input.name !== undefined) {
    data.name = input.name;
  }

  if (input.quantity !== undefined) {
    const baseQuantity = await convertFromPrefs(input.quantity);
    if (baseQuantity === null) {
      console.error("[updateCostsMaterialChange] Currency conversion failed - no prefs set");
      return null;
    }
    data.quantity = baseQuantity;
  }

  return {
    collection: "costsMaterial",
    operation: "update",
    data,
  };
}

/**
 * Create multiple costsMaterial update changeset items with quantities converted
 * from user's preferred currency to base currency.
 *
 * @param inputs - Array of costsMaterial data with quantities in preferred currency
 * @returns Array of changeset items ready for processing
 */
export async function updateCostsMaterialChanges(
  inputs: UpdateCostsMaterialInput[]
): Promise<ChangesetItem[]> {
  const results: ChangesetItem[] = [];

  for (const input of inputs) {
    const change = await updateCostsMaterialChange(input);
    if (change) {
      results.push(change);
    }
  }

  return results;
}

// ============================================================================
// Load Books for Selection
// ============================================================================

export interface Book {
  id: string;
  refId?: string;
  name?: string;
}

/**
 * Load all books from the local SQLite database
 */
export async function loadBooks(): Promise<Book[]> {
  const db = await getDb();
  return (await db.books.getAll()) || [];
}

// ============================================================================
// Download Changes from Pocketbase API
// ============================================================================

export interface DownloadChangesResult {
  success: boolean;
  downloaded: number;
  processed: number;
  errors: string[];
}

/**
 * Download changes from Pocketbase API that are newer than local records,
 * and process them through the ChangesetProcessor.
 *
 * Fetches changes for all books belonging to the user's org, plus their parent books.
 */
export async function downloadChanges(): Promise<DownloadChangesResult> {
  const errors: string[] = [];
  let downloaded = 0;
  let processed = 0;

  try {
    const db = await getDb();
    const { pb } = await getApi();

    // Get the user's active org
    const user = pb.authStore.model;
    const orgId = user?.activeOrg;
    if (!orgId) {
      return {
        success: false,
        downloaded: 0,
        processed: 0,
        errors: ["No active organization found"],
      };
    }

    console.log("[downloadChanges] Starting download for org:", orgId);

    // Get all books for this org
    const orgBooks = await pb.collection("books").getFullList({
      filter: `org = '${orgId}'`,
      expand: "parent",
    });

    console.log("[downloadChanges] Found", orgBooks.length, "books for org");

    // Collect all book IDs we need to fetch changes for (org books + their parents)
    const bookIdsToSync = new Set<string>();
    for (const book of orgBooks) {
      bookIdsToSync.add(book.id);
      if (book.parent) {
        bookIdsToSync.add(book.parent);
      }
    }

    console.log("[downloadChanges] Syncing changes for", bookIdsToSync.size, "books (including parents)");

    // Get the latest created timestamp from local pricebookChanges
    const latestResult = await db.dbConn.query(
      "SELECT MAX(created) as latest FROM pricebookChanges"
    );
    const latestCreated = latestResult.values?.[0]?.latest || null;

    console.log("[downloadChanges] Last local changeset:", latestCreated || "none");

    // Download changesets for all books
    let allChanges: any[] = [];

    for (const bookId of bookIdsToSync) {
      try {
        let filter = `book = '${bookId}'`;
        if (latestCreated) {
          filter += ` && created > "${latestCreated}"`;
        }

        console.log(`[downloadChanges] Fetching for book ${bookId} with filter:`, filter);

        const bookChanges = await pb.collection("pricebookChanges").getFullList({
          filter,
          sort: "created",
        });

        console.log(`[downloadChanges] Book ${bookId}: downloaded ${bookChanges.length} changes`);
        allChanges = allChanges.concat(bookChanges);
      } catch (e) {
        console.error(`[downloadChanges] Failed to fetch changes for book ${bookId}:`, e);
        errors.push(`Failed to fetch changes for book ${bookId}: ${e}`);
      }
    }

    // Sort all changes by created date (oldest first)
    allChanges.sort(
      (a, b) =>
        Number(new Date(a.created as string)) -
        Number(new Date(b.created as string))
    );

    downloaded = allChanges.length;
    console.log("[downloadChanges] Total changes to apply:", downloaded);

    if (allChanges.length === 0) {
      return {
        success: true,
        downloaded: 0,
        processed: 0,
        errors,
      };
    }

    // Process each changeset through the ChangesetProcessor
    const processor = new ChangesetProcessor(db.dbConn);

    for (const record of allChanges) {
      try {
        const changeset = record.changeset;
        if (!changeset || !Array.isArray(changeset)) {
          console.warn(`[downloadChanges] Skipping record ${record.id}: invalid changeset`);
          continue;
        }

        // Process the changeset (this also saves it to local pricebookChanges)
        await processor.processChangeset(
          changeset,
          "clientApp",
          true, // use transaction
          record.book || undefined
        );
        processed++;
      } catch (error) {
        const errorMsg = `Failed to process record ${record.id}: ${error instanceof Error ? error.message : String(error)}`;
        console.error(`[downloadChanges] ${errorMsg}`);
        errors.push(errorMsg);
      }
    }

    // Persist to disk
    await db.saveDb();

    console.log(`[downloadChanges] Complete: ${processed} succeeded, ${errors.length} failed`);

    return {
      success: errors.length === 0,
      downloaded,
      processed,
      errors,
    };
  } catch (error) {
    console.error("[downloadChanges] Error:", error);
    return {
      success: false,
      downloaded,
      processed,
      errors: [error instanceof Error ? error.message : String(error)],
    };
  }
}

// ============================================================================
// Commit Changes to Pocketbase API
// ============================================================================

export interface CommitChangeOptions {
  bookId: string;
  orgId?: string; // Optional - will use activeOrg from session if not provided
}

export interface CommitChangeResult {
  success: boolean;
  recordId?: string;
  error?: string;
}

/**
 * Commit a single changeset item to the Pocketbase API.
 * Creates a record in pricebookChanges which triggers server-side processing.
 *
 * @param change - The changeset item to commit
 * @param options - Options including bookId and optional orgId
 * @returns Result with success status and record ID or error
 */
export async function commitChange(
  change: ChangesetItem,
  options: CommitChangeOptions
): Promise<CommitChangeResult> {
  return commitChanges([change], options);
}

/**
 * Commit multiple changeset items.
 * First processes locally via ChangesetProcessor, then saves to Pocketbase API.
 *
 * @param changes - Array of changeset items to commit
 * @param options - Options including bookId and optional orgId
 * @returns Result with success status and record ID or error
 */
export async function commitChanges(
  changes: ChangesetItem[],
  options: CommitChangeOptions
): Promise<CommitChangeResult> {
  try {
    const db = await getDb();
    const { pb } = await getApi();

    // Get org ID from options or from the authenticated user's activeOrg
    let orgId = options.orgId;
    if (!orgId) {
      const user = pb.authStore.model;
      orgId = user?.activeOrg;
      if (!orgId) {
        return {
          success: false,
          error: "No organization ID provided and no active organization found",
        };
      }
    }

    // First, process locally via ChangesetProcessor
    console.log("[commitChanges] Processing changeset locally:", JSON.stringify(changes, null, 2));
    console.log("[commitChanges] orgId:", orgId, "bookId:", options.bookId);
    const processor = new ChangesetProcessor(db.dbConn);
    try {
      await processor.processChangeset(changes, orgId, true, options.bookId);
      console.log("[commitChanges] Local processing succeeded");
      await db.saveDb();
      console.log("[commitChanges] Database saved");
    } catch (processorError) {
      console.error("[commitChanges] Local processing failed:", processorError);
      return {
        success: false,
        error: `Local processing failed: ${processorError instanceof Error ? processorError.message : String(processorError)}`,
      };
    }

    // If local processing succeeded, save to Pocketbase API
    const record = await pb.collection("pricebookChanges").create({
      book: options.bookId,
      org: orgId,
      changeset: changes,
    });

    return {
      success: true,
      recordId: record.id,
    };
  } catch (error) {
    console.error("[commitChanges] Error:", error);
    return {
      success: false,
      error: error instanceof Error ? error.message : String(error),
    };
  }
}