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.
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
- Filtrar por
currency_code: 'ars' - Separar precio base (sin
price_list_id) del descuento (conprice_list_id) - 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 unidadesweight: 7 gramosinventoryTotal: 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 Medusa | Campo Cannahub | Transformación |
|---|---|---|
id | id | Directo |
email | email | Directo |
first_name | firstName | Rename |
last_name | lastName | Rename |
phone | phone | Directo |
created_at | createdAt | Parse Date |
metadata.status | status | Extract |
metadata.medicalRecord | medicalRecord | Extract |
metadata.membership | membership | Extract |
metadata.onboardingStep | onboardingStep | Extract |
groups[0] | membershipTier | Extract 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 Medusa | Campo Cannahub | Transformación |
|---|---|---|
id | id | Directo |
title | name | Rename |
description | description | Directo |
handle | handle | Directo |
status | status | Directo |
thumbnail | imageUrl | Rename |
images[] | images[] | Extract URLs |
variants[] | variants[] | Parse each |
metadata.strain | strain | Parse strain |
metadata.isFlower | isFlower | Extract |
categories[0] | category | Extract 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 Medusa | Campo Cannahub | Transformación |
|---|---|---|
id | id | Directo |
display_id | displayId | Rename + String |
status | status | Directo |
fulfillment_status | fulfillmentStatus | camelCase |
payment_status | paymentStatus | camelCase |
items[] | items[] | Simplify |
payment_collections[] | payments[] | Extract |
total | total | / 100 |
shipping_address | shippingAddress | Parse |
metadata.activity_logs | activityLogs | Extract |
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 Medusa | Campo Cannahub | Transformación |
|---|---|---|
id | id | Directo |
name | name | Directo |
metadata.description | description | Extract |
metadata.price | price | Extract |
metadata.benefits | benefits | Extract |
metadata.limits | limits | Extract |
metadata.priceListId | priceListId | Extract |
metadata.isActive | isActive | Extract |
metadata.sortOrder | sortOrder | Extract |
metadata.color | color | Extract |
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,
}))
)
}