Organizaciones y Multi-tenancy

Cannahub soporta múltiples organizaciones (clubs de cannabis) en una única instancia de la plataforma. Cada organización opera de forma independiente con sus propios miembros, productos, inventario y configuración.


Arquitectura Multi-tenant

Loading diagram...

El modelo de Organización

  • Name
    id
    Type
    string
    Description

    Identificador único de la organización.

  • Name
    slug
    Type
    string
    Description

    Slug URL-friendly usado en subdominios.

  • Name
    name
    Type
    string
    Description

    Nombre legal de la organización.

  • Name
    displayName
    Type
    string
    Description

    Nombre para mostrar en la UI.

  • Name
    salesChannelId
    Type
    string
    Description

    ID del sales channel en Medusa.

  • Name
    stockLocationId
    Type
    string
    Description

    ID de la ubicación de stock principal.

  • Name
    publishableApiKey
    Type
    string
    Description

    API key pública para el frontend.

  • Name
    config
    Type
    OrganizationConfig
    Description

    Configuración específica del club.

  • Name
    theme
    Type
    OrganizationTheme
    Description

    Tema visual personalizado.

Interface Organization

interface Organization {
  id: string
  slug: string
  name: string
  displayName: string

  // Medusa integration
  salesChannelId: string
  stockLocationId: string
  publishableApiKey: string
  regionId: string

  // Configuration
  config: {
    requiresReprocann: boolean
    allowsShipping: boolean
    allowsPickup: boolean
    membershipRequired: boolean
    defaultMembershipId?: string
  }

  // Branding
  theme: {
    primaryColor: string
    logoUrl: string
    faviconUrl: string
  }

  // Contact
  contact: {
    email: string
    phone: string
    address: Address
    socialMedia: {
      instagram?: string
      whatsapp?: string
    }
  }

  // Legal
  legal: {
    cuit: string
    legalName: string
    registrationNumber: string
  }
}

Organizaciones Soportadas

SlugNombreSubdomain
high-upHigh Up Clubhigh-up.cannahub.tech
don-marcelinoDon Marcelinodon-marcelino.cannahub.tech
circulo-rojoCírculo Rojocirculo-rojo.cannahub.tech
superflySuperfly Clubsuperfly.cannahub.tech
blowBlow Cannabisblow.cannahub.tech
weplantWePlantweplant.cannahub.tech

Registro de organizaciones

const ORGANIZATIONS: Record<string, OrgConfig> = {
  'high-up': {
    salesChannelId: 'sc_01HQ_high_up',
    stockLocationId: 'sloc_01HQ_high_up',
    publishableApiKey: 'pk_high_up_xxx',
    regionId: 'reg_argentina',
    config: {
      requiresReprocann: true,
      allowsShipping: false,
      allowsPickup: true,
      membershipRequired: true,
    }
  },
  'don-marcelino': {
    salesChannelId: 'sc_01HQ_don_marcelino',
    stockLocationId: 'sloc_01HQ_don_marcelino',
    publishableApiKey: 'pk_don_marcelino_xxx',
    regionId: 'reg_argentina',
    config: {
      requiresReprocann: true,
      allowsShipping: true,
      allowsPickup: true,
      membershipRequired: true,
    }
  },
  // ... más organizaciones
}

Identificación de Organización

Por Subdomain

El método principal para identificar la organización es a través del subdomain.

Extracción de organización

// middleware.ts
export function getOrganizationFromRequest(
  request: NextRequest
): Organization | null {
  const host = request.headers.get('host')
  if (!host) return null

  // Extraer subdomain
  // high-up.cannahub.tech → high-up
  // high-up.localhost:3000 → high-up
  const subdomain = host.split('.')[0]

  // Buscar en registro
  const org = ORGANIZATIONS[subdomain]
  if (!org) return null

  return {
    slug: subdomain,
    ...org,
  }
}

Uso en middleware

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const org = getOrganizationFromRequest(request)

  if (!org) {
    // Redirigir a página de error
    return NextResponse.redirect(
      new URL('/org-not-found', request.url)
    )
  }

  // Agregar headers para uso downstream
  const response = NextResponse.next()
  response.headers.set('x-org-slug', org.slug)
  response.headers.set(
    'x-org-sales-channel',
    org.salesChannelId
  )

  return response
}

Por Header

Para requests programáticos, se puede especificar la organización vía header.

  • Name
    x-org-slug
    Type
    header
    Description

    Slug de la organización.

  • Name
    x-publishable-api-key
    Type
    header
    Description

    API key pública del sales channel.

Request con header

curl https://api.cannahub.tech/api/products \
  -H "x-org-slug: high-up" \
  -H "x-publishable-api-key: pk_high_up_xxx" \
  -H "Authorization: Bearer {token}"

Componentes por Organización

Sales Channel

Cada organización tiene un sales channel en Medusa que determina qué productos están disponibles.

  • Name
    id
    Type
    string
    Description

    ID del sales channel en Medusa.

  • Name
    name
    Type
    string
    Description

    Nombre del canal.

  • Name
    products
    Type
    Product[]
    Description

    Productos asociados al canal.

Filtrar productos por sales channel

// Solo productos del club específico
const products = await medusa.admin.products.list({
  sales_channel_id: org.salesChannelId,
  status: 'published',
})

// En BFF route
export async function GET(request: Request) {
  const org = getOrganization(request)

  const { products } = await medusa.admin.products.list({
    sales_channel_id: org.salesChannelId,
  })

  return NextResponse.json({
    products: products.map(parseMedusaProduct),
  })
}

Stock Location

El inventario está aislado por ubicación de stock asociada a cada organización.

  • Name
    stockLocationId
    Type
    string
    Description

    ID de la ubicación principal del club.

  • Name
    additionalLocations
    Type
    string[]
    Description

    Ubicaciones adicionales (depósitos, etc).

Inventario por organización

// Obtener inventario solo del club
const inventory = await medusa.admin.inventoryItems.list({
  location_id: org.stockLocationId,
})

// En productos con inventario
const productWithInventory = {
  ...product,
  variants: product.variants.map(variant => ({
    ...variant,
    inventory: variant.inventory.filter(
      inv => inv.locationId === org.stockLocationId
    ),
  })),
}

Price Lists (Membresías)

Cada organización puede tener sus propias listas de precios asociadas a membresías.

  • Name
    priceListId
    Type
    string
    Description

    ID de la lista de precios.

  • Name
    membershipId
    Type
    string
    Description

    ID del customer group (membresía).

  • Name
    discountPercentage
    Type
    number
    Description

    Porcentaje de descuento aplicado.

Precios por membresía

interface MembershipPricing {
  membershipId: string
  membershipName: string
  priceListId: string
  discountType: 'percentage' | 'fixed'
  discountValue: number
}

// Ejemplo de configuración por org
const orgPriceLists = {
  'high-up': {
    standard: {
      priceListId: 'pl_hu_standard',
      discountValue: 0,
    },
    premium: {
      priceListId: 'pl_hu_premium',
      discountValue: 10, // 10% off
    },
    vip: {
      priceListId: 'pl_hu_vip',
      discountValue: 20, // 20% off
    },
  },
}

Configuración de Organización

OrganizationConfig

  • Name
    requiresReprocann
    Type
    boolean
    Description

    Si requiere código REPROCANN para completar onboarding.

  • Name
    allowsShipping
    Type
    boolean
    Description

    Si permite envío a domicilio.

  • Name
    allowsPickup
    Type
    boolean
    Description

    Si permite retiro en sede.

  • Name
    membershipRequired
    Type
    boolean
    Description

    Si requiere membresía activa para comprar.

  • Name
    defaultMembershipId
    Type
    string
    Description

    Membresía asignada por defecto a nuevos miembros.

  • Name
    onboardingFlow
    Type
    string[]
    Description

    Pasos personalizados del onboarding.

Interface OrganizationConfig

interface OrganizationConfig {
  // Requisitos
  requiresReprocann: boolean
  membershipRequired: boolean

  // Fulfillment
  allowsShipping: boolean
  allowsPickup: boolean
  shippingZones?: string[]

  // Membresías
  defaultMembershipId?: string
  availableMemberships: string[]

  // Onboarding
  onboardingFlow: OnboardingStep[]

  // Límites
  purchaseLimits?: {
    daily?: number    // gramos/día
    weekly?: number   // gramos/semana
    monthly?: number  // gramos/mes
  }

  // Features
  features: {
    consultations: boolean
    strainRecommendations: boolean
    loyaltyProgram: boolean
  }
}

OrganizationTheme

  • Name
    primaryColor
    Type
    string
    Description

    Color principal (hex).

  • Name
    secondaryColor
    Type
    string
    Description

    Color secundario.

  • Name
    logoUrl
    Type
    string
    Description

    URL del logo.

  • Name
    faviconUrl
    Type
    string
    Description

    URL del favicon.

  • Name
    fonts
    Type
    object
    Description

    Fuentes personalizadas.

Interface OrganizationTheme

interface OrganizationTheme {
  // Colores
  primaryColor: string
  secondaryColor: string
  accentColor: string
  backgroundColor: string

  // Branding
  logoUrl: string
  logoUrlDark: string
  faviconUrl: string

  // Tipografía
  fonts: {
    heading: string
    body: string
  }

  // Custom CSS
  customCss?: string
}

// Ejemplo High Up
const highUpTheme: OrganizationTheme = {
  primaryColor: '#10B981',  // Emerald
  secondaryColor: '#059669',
  accentColor: '#34D399',
  backgroundColor: '#F0FDF4',
  logoUrl: '/logos/high-up.svg',
  logoUrlDark: '/logos/high-up-dark.svg',
  faviconUrl: '/favicons/high-up.ico',
  fonts: {
    heading: 'Inter',
    body: 'Inter',
  },
}

GET/api/organization

Obtener organización actual

Endpoint que retorna la información de la organización actual basada en el subdomain o header.

Request

GET
/api/organization
curl https://high-up.cannahub.tech/api/organization

Response

{
  "organization": {
    "slug": "high-up",
    "name": "High Up Cannabis Club",
    "displayName": "High Up",
    "config": {
      "requiresReprocann": true,
      "allowsShipping": false,
      "allowsPickup": true,
      "membershipRequired": true
    },
    "theme": {
      "primaryColor": "#10B981",
      "logoUrl": "/logos/high-up.svg"
    },
    "contact": {
      "email": "info@highup.club",
      "phone": "+5491123456789",
      "address": {
        "city": "Buenos Aires",
        "province": "CABA"
      }
    }
  }
}

GET/api/organizations

Listar organizaciones

Lista todas las organizaciones disponibles. Útil para la página de selección de club.

Parámetros

  • Name
    active
    Type
    boolean
    Description

    Filtrar solo organizaciones activas.

Request

GET
/api/organizations
curl https://cannahub.tech/api/organizations?active=true

Response

{
  "organizations": [
    {
      "slug": "high-up",
      "displayName": "High Up",
      "logoUrl": "/logos/high-up.svg",
      "url": "https://high-up.cannahub.tech"
    },
    {
      "slug": "don-marcelino",
      "displayName": "Don Marcelino",
      "logoUrl": "/logos/don-marcelino.svg",
      "url": "https://don-marcelino.cannahub.tech"
    },
    {
      "slug": "circulo-rojo",
      "displayName": "Círculo Rojo",
      "logoUrl": "/logos/circulo-rojo.svg",
      "url": "https://circulo-rojo.cannahub.tech"
    }
  ],
  "count": 6
}

Integración con Strapi

La configuración extendida de organizaciones se almacena en Strapi CMS.

Contenido en Strapi

  • Name
    Organizacion
    Type
    collection
    Description

    Datos de configuración de cada club.

  • Name
    Paginas
    Type
    collection
    Description

    Contenido de páginas estáticas por club.

  • Name
    Noticias
    Type
    collection
    Description

    Noticias y actualizaciones por club.

  • Name
    Promociones
    Type
    collection
    Description

    Promociones activas por club.

Fetch de Strapi

// api/strapi/organization.ts
export async function getOrganizationFromStrapi(
  slug: string
): Promise<StrapiOrganization | null> {
  const response = await fetch(
    `${STRAPI_URL}/api/organizaciones?` +
    `filters[slug][$eq]=${slug}&` +
    `populate=*`
  )

  const { data } = await response.json()
  return data[0]?.attributes ?? null
}

// Combinar con config local
export async function getFullOrganization(
  slug: string
): Promise<Organization> {
  const [localConfig, strapiConfig] = await Promise.all([
    getLocalOrgConfig(slug),
    getOrganizationFromStrapi(slug),
  ])

  return {
    ...localConfig,
    ...strapiConfig,
  }
}

Aislamiento de Datos

Reglas de Aislamiento

EntidadMétodo de Aislamiento
ProductosSales Channel
InventarioStock Location
MiembrosMetadata (orgSlug)
PedidosSales Channel + Customer
MembresíasCustomer Group + Metadata
PreciosPrice List por org

Validación en Middleware

Validación de acceso

// middleware/org-validation.ts
export async function validateOrgAccess(
  request: Request,
  userId: string
): Promise<boolean> {
  const org = getOrganization(request)
  const user = await getUser(userId)

  // Verificar que el usuario pertenece a esta org
  if (user.metadata?.orgSlug !== org.slug) {
    throw new ForbiddenError(
      'User does not belong to this organization'
    )
  }

  return true
}

Próximos Pasos

Was this page helpful?