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
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
| Slug | Nombre | Subdomain |
|---|---|---|
high-up | High Up Club | high-up.cannahub.tech |
don-marcelino | Don Marcelino | don-marcelino.cannahub.tech |
circulo-rojo | Círculo Rojo | circulo-rojo.cannahub.tech |
superfly | Superfly Club | superfly.cannahub.tech |
blow | Blow Cannabis | blow.cannahub.tech |
weplant | WePlant | weplant.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',
},
}
Obtener organización actual
Endpoint que retorna la información de la organización actual basada en el subdomain o header.
Request
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"
}
}
}
}
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
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
| Entidad | Método de Aislamiento |
|---|---|
| Productos | Sales Channel |
| Inventario | Stock Location |
| Miembros | Metadata (orgSlug) |
| Pedidos | Sales Channel + Customer |
| Membresías | Customer Group + Metadata |
| Precios | Price 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
}