Transformaciones de Datos

La capa de transformación convierte los datos raw de Medusa a los tipos de dominio de Cannahub. Esta abstracción simplifica el consumo de datos en el frontend y estandariza la estructura de la información.

Loading diagram...

Propósito de la Capa de Transformación

La transformación de datos cumple varios objetivos críticos:

  • Name
    Abstracción
    Type
    core
    Description

    Oculta la complejidad del modelo de Medusa al frontend.

  • Name
    Estandarización
    Type
    core
    Description

    Convierte snake_case a camelCase de forma consistente.

  • Name
    Simplificación
    Type
    core
    Description

    Extrae datos anidados en metadata a propiedades de primer nivel.

  • Name
    Tipado
    Type
    typescript
    Description

    Proporciona tipos TypeScript seguros para todo el dominio.

  • Name
    Desacoplamiento
    Type
    architecture
    Description

    Permite cambiar el backend sin afectar el frontend.

Ubicación de transformadores

// cannahub-webapp/src/lib/medusa/
├── client.ts          // Config SDK
├── products.ts        // Productos
├── customer.ts        // Clientes → Users
├── customerGroup.ts   // Groups → Memberships
├── cart.ts            // Carritos
├── categories.ts      // Categorías
├── priceList.ts       // Listas de precios
├── user.ts            // Staff/Admin
└── notifications.ts   // Notificaciones

Patrones de Transformación

1. Mapeo de Campos (snake_case → camelCase)

Todos los campos de Medusa usan snake_case mientras que Cannahub usa camelCase.

  • Name
    first_name
    Type
    string
    Description

    Se transforma a firstName.

  • Name
    last_name
    Type
    string
    Description

    Se transforma a lastName.

  • Name
    created_at
    Type
    string
    Description

    Se transforma a createdAt.

  • Name
    updated_at
    Type
    string
    Description

    Se transforma a updatedAt.

  • Name
    display_id
    Type
    number
    Description

    Se transforma a displayId.

  • Name
    fulfillment_status
    Type
    string
    Description

    Se transforma a fulfillmentStatus.

Transformación de campos

// Medusa Customer (raw)
interface MedusaCustomer {
  id: string
  first_name: string
  last_name: string
  email: string
  created_at: string
  updated_at: string
  metadata: Record<string, any>
}

// Cannahub User (transformado)
interface User {
  id: string
  firstName: string
  lastName: string
  email: string
  createdAt: Date
  updatedAt: Date
  // metadata extraído a propiedades
}

2. Extracción de Metadata

Medusa almacena datos de dominio en el campo metadata. Cannahub los extrae a propiedades de primer nivel.

Metadata de Customer

  • Name
    metadata.status
    Type
    MemberStatus
    Description

    Estado del miembro en el club.

  • Name
    metadata.medicalRecord
    Type
    MedicalRecord
    Description

    Expediente médico completo.

  • Name
    metadata.membership
    Type
    UserMembership
    Description

    Información de membresía activa.

  • Name
    metadata.onboardingStep
    Type
    OnboardingStep
    Description

    Paso actual del onboarding.

  • Name
    metadata.hasReprocann
    Type
    boolean
    Description

    Si tiene código REPROCANN.

Extracción de metadata

function parseMedusaCustomer(
  medusa: MedusaCustomer
): User {
  return {
    id: medusa.id,
    email: medusa.email,
    firstName: medusa.first_name,
    lastName: medusa.last_name,

    // Extraído de metadata
    status: medusa.metadata?.status
      ?? MemberStatus.INVITED,
    medicalRecord: medusa.metadata?.medicalRecord,
    membership: medusa.metadata?.membership,
    onboardingStep: medusa.metadata?.onboardingStep,
    hasReprocann: medusa.metadata?.hasReprocann
      ?? false,

    createdAt: new Date(medusa.created_at),
    updatedAt: new Date(medusa.updated_at),
  }
}

3. Conversión de Fechas

Medusa usa strings ISO para fechas, mientras que metadata puede tener formatos locales.

  • Name
    ISO String
    Type
    standard
    Description

    2024-12-31T00:00:00.000Z - Formato de Medusa.

  • Name
    DD/MM/YYYY
    Type
    metadata
    Description

    31/12/2024 - Formato usado en metadata.

  • Name
    Date Object
    Type
    cannahub
    Description

    Date - Tipo de salida en Cannahub.

Conversión de fechas

import { parse, parseISO } from 'date-fns'

// Fecha ISO estándar de Medusa
const createdAt = parseISO(medusa.created_at)

// Fecha en formato local (metadata)
function parseDateString(
  dateStr: string
): Date | undefined {
  if (!dateStr) return undefined

  // Intentar formato DD/MM/YYYY
  const localDate = parse(
    dateStr,
    'dd/MM/yyyy',
    new Date()
  )
  if (!isNaN(localDate.getTime())) {
    return localDate
  }

  // Fallback a ISO
  return parseISO(dateStr)
}

// Uso para REPROCANN expiration
const expiration = parseDateString(
  medusa.metadata?.reprocannExpiration
)

4. Agregación de Precios

Medusa almacena múltiples precios por variante. Cannahub simplifica esto.

  • Name
    prices[]
    Type
    array
    Description

    Array de precios por currency/price list.

  • Name
    price
    Type
    number
    Description

    Precio base (menor precio encontrado).

  • Name
    discountPrice
    Type
    number
    Description

    Precio con descuento de price list.

  • Name
    discountPercentage
    Type
    number
    Description

    Porcentaje de descuento calculado.

Lógica de Selección

  1. Filtrar por currency_code: 'ars'
  2. Separar precio base (sin price_list_id) del descuento (con price_list_id)
  3. Calcular porcentaje de descuento

Agregación de precios

function parseVariantPrices(
  prices: MedusaPrice[]
): VariantPricing {
  // Filtrar por moneda
  const arsPrices = prices.filter(
    p => p.currency_code === 'ars'
  )

  // Precio base (sin price list)
  const basePrice = arsPrices.find(
    p => !p.price_list_id
  )

  // Precio con descuento (de price list)
  const discountedPrice = arsPrices.find(
    p => p.price_list_id
  )

  const price = basePrice?.amount ?? 0
  const discountPrice = discountedPrice?.amount

  return {
    price: price / 100, // Medusa usa centavos
    discountPrice: discountPrice
      ? discountPrice / 100
      : undefined,
    discountPercentage: discountPrice
      ? Math.round(
          (1 - discountPrice / price) * 100
        )
      : undefined
  }
}

5. Multiplicador de Inventario

Para productos de flor, el inventario se multiplica por el peso de la variante.

  • Name
    inventoryQuantity
    Type
    number
    Description

    Unidades físicas en stock.

  • Name
    weight
    Type
    number
    Description

    Peso en gramos por unidad.

  • Name
    inventoryTotal
    Type
    number
    Description

    Total en gramos (quantity × weight).

Ejemplo

  • Variante: "Blue Dream 7g"
  • inventoryQuantity: 10 unidades
  • weight: 7 gramos
  • inventoryTotal: 70 gramos

Multiplicador de inventario

function calculateInventoryTotal(
  variant: MedusaVariant,
  inventoryQuantity: number
): number {
  // Obtener peso de metadata
  const weight = variant.metadata?.weight
    ?? variant.metadata?.quantity
    ?? 1

  // Multiplicar para obtener total en gramos
  return inventoryQuantity * weight
}

// Uso en transformación de variante
const variant: ProductVariant = {
  id: medusa.id,
  name: medusa.title,
  weight: medusa.metadata?.weight ?? 1,
  inventoryQuantity: medusa.inventory_quantity,
  inventoryTotal: calculateInventoryTotal(
    medusa,
    medusa.inventory_quantity
  ),
  // ...
}

6. Resolución de Referencias de Cepa

Los productos pueden referenciar cepas de dos formas.

Patrones de Referencia

  • Name
    metadata.strain
    Type
    object
    Description

    Objeto completo de cepa (legacy).

  • Name
    metadata.strainId
    Type
    string
    Description

    ID de referencia a cepa.

  • Name
    metadata.strainName
    Type
    string
    Description

    Nombre de cepa para display rápido.

El transformador soporta ambos patrones para compatibilidad.

Resolución de cepa

function parseProductStrain(
  metadata: Record<string, any>
): Strain | undefined {
  // Patrón legacy: objeto completo
  if (metadata?.strain?.id) {
    return {
      id: metadata.strain.id,
      name: metadata.strain.name,
      type: metadata.strain.type,
      cannabinoids: metadata.strain.cannabinoids,
      // ...
    }
  }

  // Patrón nuevo: referencia ligera
  if (metadata?.strainId) {
    return {
      id: metadata.strainId,
      name: metadata.strainName ?? 'Unknown',
      type: metadata.strainType ?? 'hybrid',
      // Requiere fetch adicional para datos completos
    }
  }

  return undefined
}

Transformaciones por Entidad

Customer → User

Campo MedusaCampo CannahubTransformación
ididDirecto
emailemailDirecto
first_namefirstNameRename
last_namelastNameRename
phonephoneDirecto
created_atcreatedAtParse Date
metadata.statusstatusExtract
metadata.medicalRecordmedicalRecordExtract
metadata.membershipmembershipExtract
metadata.onboardingSteponboardingStepExtract
groups[0]membershipTierExtract first
addresses[]addresses[]Map fields

parseMedusaCustomer

export function parseMedusaCustomer(
  customer: MedusaCustomer
): User {
  const { metadata } = customer

  return {
    id: customer.id,
    email: customer.email,
    firstName: customer.first_name ?? '',
    lastName: customer.last_name ?? '',
    phone: customer.phone ?? '',

    role: metadata?.role ?? Role.MEMBER,
    status: metadata?.status
      ?? MemberStatus.INVITED,

    medicalRecord: metadata?.medicalRecord,
    membership: metadata?.membership,
    onboardingStep: metadata?.onboardingStep,
    hasReprocann: metadata?.hasReprocann ?? false,

    addresses: (customer.addresses ?? [])
      .map(parseAddress),

    createdAt: new Date(customer.created_at),
    updatedAt: new Date(customer.updated_at),
  }
}

Product → Product

Campo MedusaCampo CannahubTransformación
ididDirecto
titlenameRename
descriptiondescriptionDirecto
handlehandleDirecto
statusstatusDirecto
thumbnailimageUrlRename
images[]images[]Extract URLs
variants[]variants[]Parse each
metadata.strainstrainParse strain
metadata.isFlowerisFlowerExtract
categories[0]categoryExtract name

parseMedusaProduct

export function parseMedusaProduct(
  product: MedusaProduct
): Product {
  const { metadata } = product

  const variants = (product.variants ?? [])
    .map(parseMedusaVariant)

  // Calcular precio más bajo
  const lowestPrice = Math.min(
    ...variants.map(v => v.price)
  )

  return {
    id: product.id,
    name: product.title,
    description: product.description ?? '',
    handle: product.handle,
    status: product.status,

    imageUrl: product.thumbnail ?? '',
    images: (product.images ?? [])
      .map(img => img.url),

    variants,
    strain: parseProductStrain(metadata),
    isFlower: metadata?.isFlower ?? false,

    category: product.categories?.[0]?.name
      ?? 'Sin categoría',
    price: lowestPrice,
    currency: 'ARS',

    inventoryQuantity: variants.reduce(
      (sum, v) => sum + v.inventoryQuantity,
      0
    ),
  }
}

Order → Order

Campo MedusaCampo CannahubTransformación
ididDirecto
display_iddisplayIdRename + String
statusstatusDirecto
fulfillment_statusfulfillmentStatuscamelCase
payment_statuspaymentStatuscamelCase
items[]items[]Simplify
payment_collections[]payments[]Extract
totaltotal/ 100
shipping_addressshippingAddressParse
metadata.activity_logsactivityLogsExtract

parseMedusaOrder

export function parseMedusaOrder(
  order: MedusaOrder
): Order {
  return {
    id: order.id,
    displayId: String(order.display_id),

    status: order.status,
    fulfillmentStatus: order.fulfillment_status,
    paymentStatus: order.payment_status,

    customer: parseMedusaCustomer(order.customer),

    items: (order.items ?? []).map(item => ({
      id: item.id,
      title: item.title,
      thumbnail: item.thumbnail,
      variantId: item.variant_id,
      quantity: item.quantity,
      unitPrice: item.unit_price / 100,
      total: item.total / 100,
    })),

    payments: parsePaymentCollections(
      order.payment_collections
    ),

    subtotal: order.subtotal / 100,
    shippingTotal: order.shipping_total / 100,
    taxTotal: order.tax_total / 100,
    total: order.total / 100,
    currency: order.currency_code?.toUpperCase()
      ?? 'ARS',

    shippingAddress: order.shipping_address
      ? parseAddress(order.shipping_address)
      : undefined,

    activityLogs: order.metadata?.activity_logs,

    createdAt: new Date(order.created_at),
    updatedAt: new Date(order.updated_at),
  }
}

CustomerGroup → Membership

Campo MedusaCampo CannahubTransformación
ididDirecto
namenameDirecto
metadata.descriptiondescriptionExtract
metadata.pricepriceExtract
metadata.benefitsbenefitsExtract
metadata.limitslimitsExtract
metadata.priceListIdpriceListIdExtract
metadata.isActiveisActiveExtract
metadata.sortOrdersortOrderExtract
metadata.colorcolorExtract

parseMedusaCustomerGroup

export function parseMedusaCustomerGroup(
  group: MedusaCustomerGroup
): Membership {
  const { metadata } = group

  return {
    id: group.id,
    name: group.name,
    description: metadata?.description ?? '',
    price: metadata?.price ?? 0,
    currency: 'ARS',

    benefits: metadata?.benefits ?? [],
    limits: metadata?.limits,

    priceListId: metadata?.priceListId ?? '',
    isActive: metadata?.isActive ?? true,
    sortOrder: metadata?.sortOrder ?? 0,
    color: metadata?.color,

    metadata: metadata ?? {},
  }
}

Funciones de Utilidad

Parse Address

parseAddress

export function parseAddress(
  addr: MedusaAddress
): Address {
  return {
    id: addr.id,
    firstName: addr.first_name ?? '',
    lastName: addr.last_name ?? '',
    address1: addr.address_1 ?? '',
    address2: addr.address_2,
    city: addr.city ?? '',
    province: addr.province ?? '',
    postalCode: addr.postal_code ?? '',
    countryCode: addr.country_code ?? 'AR',
    phone: addr.phone,
    company: addr.company,
  }
}

Parse Payment Collections

parsePaymentCollections

export function parsePaymentCollections(
  collections: MedusaPaymentCollection[]
): OrderPayment[] {
  return (collections ?? []).flatMap(collection =>
    (collection.payments ?? []).map(payment => ({
      id: payment.id,
      amount: payment.amount / 100,
      currency: payment.currency_code?.toUpperCase() ?? 'ARS',
      provider: payment.provider_id,
      status: collection.status,
      capturedAt: payment.captured_at
        ? new Date(payment.captured_at)
        : undefined,
    }))
  )
}

Diagrama de Flujo Completo

Loading diagram...

Próximos Pasos

Was this page helpful?