@zaamx/netme-bundle
v0.0.6
Published
A starter for Medusa plugins.
Downloads
486
Maintainers
Readme
@zaamx/netme-bundle
A Medusa v2 plugin for creating and managing product bundles — linking a parent product to one or more child products with configurable quantities, pricing rules, and metadata.
Version: 0.0.4 | License: MIT | Requires: Medusa >= 2.4.0 | Node: >= 20
Table of Contents
- Overview
- Installation
- Registration
- Architecture
- Data Model
- Module
- Module Link
- Workflows
- API Reference
- Admin UI
- Types
- Database Schema
- Build & Development
Overview
@zaamx/netme-bundle adds full product bundle management to a Medusa v2 backend. It exposes:
- A
bundlemodule with its own database table and service - A module link connecting Medusa's
ProductModuleto thebundlemodule - Workflows for creating, updating, and deleting bundles
- Admin REST API routes for CRUD operations
- Store REST API routes for storefront consumption and cart integration with dynamic pricing
- An Admin UI page with full CRUD, infinite scroll product selection, and metadata management
Installation
npm install @zaamx/netme-bundle
# or
yarn add @zaamx/netme-bundleRegistration
Add the plugin to your medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
plugins: [
{
resolve: "@zaamx/netme-bundle",
options: {},
},
],
// ...
})After registering, run database migrations:
npx medusa db:migrateArchitecture
@zaamx/netme-bundle
├── modules/bundles — Bundle data model, service, migrations
├── links/product-bundle — Module link: Product ↔ Bundle
├── workflows/ — update-product-bundle, delete-product-bundle
├── api/admin/ — Admin REST routes
├── api/store/ — Store REST routes
└── admin/ — Admin dashboard UI (routes, components)The plugin follows Medusa's modular architecture: the bundle module is isolated from the core ProductModule and connected via a module link. All write operations go through workflows that invoke the bundle service.
Data Model
File: src/modules/bundles/models/bundle.ts
| Field | Type | Default | Description |
|---|---|---|---|
| id | TEXT | — | Primary key, matches the associated product ID |
| child_products | JSONB | {"data":[]} | Array of child product configurations |
| bundle_meta | JSONB | {"data":[]} | Array of arbitrary key-value metadata pairs |
| is_bundle | BOOLEAN | false | Whether this record represents an active bundle |
| created_at | TIMESTAMPTZ | now() | Creation timestamp |
| updated_at | TIMESTAMPTZ | now() | Last update timestamp |
| deleted_at | TIMESTAMPTZ | NULL | Soft delete timestamp |
An index IDX_bundle_deleted_at is created on deleted_at for efficient active-bundle queries.
Module
File: src/modules/bundles/index.ts
export const BUNDLE_MODULE = "bundle"
export default Module(BUNDLE_MODULE, { service: BundleService })BundleService
Extends MedusaService with the Bundle model. Inherits CRUD methods:
| Method | Description |
|---|---|
| listBundles(filters?, config?) | List bundles with optional filters |
| createBundles(data) | Create one or more bundles |
| updateBundles(data) | Update one or more bundles |
| deleteBundles(ids) | Soft-delete bundles by ID |
| attachBundleToProduct(productId) | Retrieve bundle data for a product with child_products relation |
Module Link
File: src/links/product-bundle.ts
export default defineLink(
ProductModule.linkable.product,
BundleModule.linkable.bundle
)This link associates each bundle record with a Medusa product while keeping both modules independent. It enables querying products with their bundle data through Medusa's query layer.
Workflows
updateProductBundle
File: src/workflows/update-product-bundle.ts
Creates or updates a bundle for a given product.
Input (UpdateProductBundleInput):
{
product_id: string
is_bundle?: boolean
child_product_ids?: Array<{
id: string
min_quantity?: number
max_quantity?: number
default_quantity?: number
optional?: boolean
separate_shipping?: boolean
individual_price?: boolean
}>
bundle_meta?: Array<{
key: string
value: string
}>
}Steps:
- Resolve bundle and product services via
MedusaModules - Check if a bundle already exists for
product_id - If none exists and
is_bundle=true: create a new bundle record - If one exists: update with the provided fields
- Return the product with enriched bundle data
deleteProductBundle
File: src/workflows/delete-product-bundle.ts
Removes the bundle association from a product.
Input (DeleteProductBundleInput):
{
product_id: string
}Steps:
- Find the bundle record linked to
product_id - Delete it if found
- Return the updated product
API Reference
Admin Routes
All admin routes are authenticated and accessible under /admin/.
GET /admin/bundled-products
Returns all bundled products with enriched child product data.
Query params: limit, offset
Response:
{
"bundled_products": [
{
"id": "bundle_01J...",
"title": "Product Title",
"product": { "id": "prod_01J..." },
"items": [
{
"id": "prod_child_01J...",
"product": { "id": "prod_01J...", "title": "Child Title" },
"quantity": 1,
"min_quantity": 1,
"max_quantity": 5,
"optional": false,
"separate_shipping": false,
"individual_price": false
}
],
"bundle_meta": [{ "key": "promo", "value": "summer" }],
"is_bundle": true,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}
],
"count": 10,
"limit": 15,
"offset": 0
}POST /admin/bundled-products
Creates a new bundled product.
Body: UpdateProductBundleInput (see Workflows)
GET /admin/products/:id/bundle
Retrieves bundle data for a specific product.
Response: Bundle object for the product, or empty if none.
POST /admin/products/:id/bundle
Creates or replaces the bundle for a product. Always deletes the existing bundle first, then applies the new configuration.
Body: UpdateProductBundleInput
DELETE /admin/products/:id/bundle
Removes the bundle from a product.
GET /admin/plugin
Health check. Returns HTTP 200.
Store Routes
Unauthenticated routes for the storefront.
GET /store/products/:id/bundle
Returns the bundle configuration for a product, with enriched child product data.
Response: Same structure as the admin single-product bundle endpoint.
POST /store/carts/:id/bundle-line-items
Adds items to a cart with bundle-aware dynamic pricing.
Body:
{
items: Array<{
variant_id: string
quantity: number
unit_price?: number
metadata?: {
bundle_id?: string
bundled_by?: string // if set → price = 0.001 (free)
bundle_sticks_price?: {
nuevoPrecioPaquete: number // if set → use this price
}
price_rules?: {
nuevoPrecio: number // if set → use this price
}
}
}>
}Pricing logic (applied per item):
| Condition | Price Applied |
|---|---|
| metadata.bundled_by is set | 0.001 (effectively free child item) |
| metadata.bundle_sticks_price is set | bundle_sticks_price.nuevoPrecioPaquete |
| metadata.price_rules is set | price_rules.nuevoPrecio |
| None of the above | Default Medusa pricing |
GET /store/plugin
Health check. Returns HTTP 200.
Admin UI
The plugin registers a custom page in the Medusa Admin dashboard.
Bundled Products Page
Route: /bundled-products (sidebar entry with a cube icon, label "Bundled Products")
Features:
| Feature | Details | |---|---| | Bundle list | Table with columns: ID, Title, Items count, Product link, Status | | Pagination | 15 items per page | | Create bundle | Modal with product selector and item configuration | | Edit bundle | Same modal, product selector disabled during edit | | Delete bundle | Confirmation + API call | | Infinite scroll | Product selector loads products incrementally via Intersection Observer | | Bundle metadata | Key-value pairs, add/remove dynamically |
Per-Item Configuration (in Create/Edit modal)
Each child product entry in a bundle supports:
- Product selector (dropdown with search, infinite scroll)
min_quantity,max_quantity,default_quantitynumber inputs- Toggle:
optional - Toggle:
separate_shipping - Toggle:
individual_price - Remove button
Types
// Child product configuration stored in bundle.child_products
type ChildProductConfig = {
id: string
min_quantity?: number
max_quantity?: number
default_quantity?: number
optional?: boolean
separate_shipping?: boolean
individual_price?: boolean
}
// Key-value metadata stored in bundle.bundle_meta
type BundleMeta = {
key: string
value: string
}
// Input for create/update workflow
type UpdateProductBundleInput = {
product_id: string
is_bundle?: boolean
child_product_ids?: ChildProductConfig[]
bundle_meta?: BundleMeta[]
}
// Input for delete workflow
type DeleteProductBundleInput = {
product_id: string
}Database Schema
Migration: src/modules/bundles/migrations/Migration20241229065751.ts
CREATE TABLE IF NOT EXISTS "bundle" (
"id" TEXT NOT NULL,
"child_products" JSONB NOT NULL DEFAULT '{"data":[]}',
"bundle_meta" JSONB NOT NULL DEFAULT '{"data":[]}',
"is_bundle" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"deleted_at" TIMESTAMPTZ DEFAULT NULL,
CONSTRAINT "bundle_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "IDX_bundle_deleted_at"
ON "bundle" ("deleted_at");Build & Development
Scripts:
| Command | Description |
|---|---|
| yarn build | Build plugin for production (medusa plugin:build) |
| yarn dev | Start plugin in development mode (medusa plugin:develop) |
Output directory: .medusa/server/
Package exports (from built output):
| Export | Path |
|---|---|
| @zaamx/netme-bundle/workflows | .medusa/server/src/workflows/index.js |
| @zaamx/netme-bundle/modules/* | .medusa/server/src/modules/*/index.js |
| @zaamx/netme-bundle/admin | .medusa/server/src/admin/index.mjs |
| @zaamx/netme-bundle/* | .medusa/server/src/*.js |
