npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@webbers/webbers-medusa

v1.1.2

Published

Quality-of-life admin customizations for Medusa v2 (notes, customer anonymization, guest order transfer, price-list discount control)

Downloads

617

Readme

@webbers/webbers-medusa

Quality-of-life admin customizations for Medusa v2, extracted from the Frecious backend so they can be reused across projects.

Features

  • Notes — free-text notes on any entity, shown in the admin detail sidebar. Ships customer and order notes (with links + widgets); the generic note module, /admin/note endpoint and note component are reusable so other plugins can attach notes to their own entities (see "Attaching notes to your own entities").
  • Anonymize customer — AVG/GDPR data scrub for a customer across customer, cart and order records, plus any host-configured extra tables (superadmin only). See "Configuring extra tables".
  • Transfer guest orders — merge unassigned guest orders (matched by email) into a customer account.
  • Edit customer email — change a registered customer's email atomically across customer and auth identity, plus any host-configured extra tables (clears the cached Klaviyo id). See "Configuring extra tables".
  • Order discount breakdown — sidebar widget summarising the discount codes applied to an order, with amounts and percentages.
  • Order notifications — sidebar widget listing the transactional emails sent for an order.
  • Always-free promotion — flag a promotion as "always free shipping". Ships the data model, workflows, link and admin widget; the host app wires the trigger (see below).
  • Disallow price-list discounts — a toggle on the price-list detail page that stops discount/promotion codes from overriding that price list. Ships the data model, link, workflows, admin API + widget, and a store add-to-cart endpoint that enforces it; the storefront wires the endpoint (see below).

Toggling features

The plugin adds a Settings → Webbers QoL page in the admin with a switch per feature. Toggling a feature off:

  • hides its admin widget — every widget reads the flags via useFeatureEnabled(key) and renders nothing when disabled; and
  • blocks its endpoints — a featureGuard middleware returns 403 for the feature's API routes (see src/api/middlewares.ts).

Flags are persisted by the bundled webbers_settings module (table webbers_feature_flag) and default to ON when no row exists, so the plugin is fully active out of the box. The canonical list of features lives in src/feature-flags.ts (shared by the server and admin builds).

Always-free is widget-only gating: disabling it hides the promotion switch, but the actual link is still written by the host app's promotion hooks, so leave the hooks in place if you rely on existing always-free promotions.

After installing, run the migration so the flag table exists:

npx medusa db:migrate

What's included

| Concern | Location | | --- | --- | | Feature catalog (single source of truth) | src/feature-flags.ts | | Settings module + migration (feature flags) | src/modules/webbers-settings | | Settings admin page (Settings → Webbers QoL) | src/admin/routes/settings/webbers-medusa | | Note module + migration | src/modules/note | | Custom-query (custom_query) module (raw SQL helpers, no migration) | src/modules/custom-query | | Always-free (promotion_addition) module + migration | src/modules/promotion-addition | | Price-list-ext (price_list_ext) module + migration | src/modules/price-list-ext | | Links (customer/order note, promotion always-free, price-list-ext) | src/links | | Workflows (note, always-free, price-list-ext) | src/workflows | | Admin widgets | src/admin/widgets | | Admin API routes | src/api/admin | | Store API route (price-list-aware add-to-cart) | src/api/store/carts/[id]/line-item-price-list | | HTTP middlewares (note + edit-email validators, promotion additional_data, price-list-ext) | src/api/middlewares.ts |

Installation

Install the plugin in your Medusa app:

npm install @webbers/webbers-medusa
# or, during local development:
npx medusa plugin:add @webbers/webbers-medusa

Register it in the host app's medusa-config.ts:

plugins: [
  {
    resolve: '@webbers/webbers-medusa',
    options: {},
  },
]

After installing, run the migrations so the plugin's tables exist:

npx medusa db:migrate

The bundled custom_query module

The plugin bundles a custom_query module exposing PSQLQuery / PSQLTransaction — the anonymize, transfer-guest-orders, edit-email and order-notifications routes resolve it from the container by key (custom_query).

Upgrading from ≤ 1.0.0: earlier versions required the host app to register its own custom_query module. That copy must now be removed (both the module directory and its medusa-config.ts entry) — two modules with the same key cannot coexist. The bundled module is a drop-in replacement (same key, same API, no migration/table).

This plugin has no dependency on any subscriptions package (or any other domain module). The anonymize / edit-email routines only touch customer, cart and order tables out of the box; any extra tables (e.g. subscription) are declared by the host via plugin options — see "Configuring extra tables" below.

Configuring extra tables (anonymize / edit-email)

The anonymize and edit-email routines scrub the core Medusa tables on their own. To include tables owned by other modules — without this plugin depending on them — declare them in the plugin options in your medusa-config.ts. Every table/column name is validated (^[A-Za-z_][A-Za-z0-9_]*$) and all statements run inside the same transaction as the core scrub, so atomicity is preserved.

plugins: [
  {
    resolve: '@webbers/webbers-medusa',
    options: {
      anonymize: {
        // Domain for the generated anonymized email (anon+<ts>@<domain>). Default: anonymized.invalid
        emailDomain: 'example.com',
        // Tables with a customer-id column: set email columns to the anon email, null the rest.
        customerScopedTables: [
          { table: 'subscription', emailColumns: ['email'], nullColumns: ['payment_config'] },
        ],
        // Address tables reached via a parent table's billing/shipping FKs (nulled out).
        // customerIdColumn/billingColumn/shippingColumn/nullColumns are optional (sensible defaults).
        addressTables: [
          { addressTable: 'subscription_address', parentTable: 'subscription' },
        ],
      },
      editEmail: {
        // Tables whose email column should follow the customer's new email.
        // emailColumn defaults to 'email', customerIdColumn to 'customer_id'.
        emailSyncTables: [{ table: 'subscription' }],
      },
    },
  },
]

Attaching notes to your own entities

The note module, the /admin/note endpoint (entity_type / type are free-form strings) and the note component are generic. To add notes to another entity from a separate plugin or your app — e.g. subscriptions — depend on @webbers/webbers-medusa and:

  1. Define the link between your module and the note module:

    import { defineLink } from '@medusajs/framework/utils';
    import MyModule from '../modules/my-module';
    import NoteModule from '@webbers/webbers-medusa/modules/note';
    
    export default defineLink(MyModule.linkable.myEntity, NoteModule.linkable.note);
  2. Render a note widget in your entity's detail zone that calls /admin/note with your entity_type (the module's registration key). The notes feature flag guards the endpoint, so the note stays hidden/blocked when notes are disabled in Settings → Webbers QoL.

This keeps the QoL plugin free of any dependency on your module while still owning the note storage.

Activating the always-free promotion behaviour

Toggling the widget writes additional_data.always_free on the promotion (the plugin's middleware whitelists that field). Turning that flag into an always_free link requires a handler on Medusa's promotion workflow hooks. Medusa allows only one handler per workflow hook, and a real app usually does other work in promotionsCreated/promotionsUpdated too — so the plugin does not register these hooks for you. Add them in your app's src/workflows/hooks/promotions:

promotion-created.ts

import { createPromotionsWorkflow } from '@medusajs/medusa/core-flows';
import {
  createAlwaysFreeFromPromotionWorkflow,
  CreateAlwaysFreeFromPromotionWorkflowInput,
} from '@webbers/webbers-medusa/workflows';

createPromotionsWorkflow.hooks.promotionsCreated(async ({ promotions, additional_data }, { container }) => {
  const workflow = createAlwaysFreeFromPromotionWorkflow(container);

  for (const promotion of promotions) {
    // ...any other app-specific work you already do here...
    await workflow.run({
      input: { promotion, additional_data } as CreateAlwaysFreeFromPromotionWorkflowInput,
    });
  }
});

promotion-updated.ts

import { updatePromotionsWorkflow } from '@medusajs/medusa/core-flows';
import { ContainerRegistrationKeys } from '@medusajs/framework/utils';
import {
  createAlwaysFreeFromPromotionWorkflow,
  CreateAlwaysFreeFromPromotionWorkflowInput,
  updateAlwaysFreeFromPromotionWorkflow,
  UpdateAlwaysFreeFromPromotionStepInput,
} from '@webbers/webbers-medusa/workflows';
import PromotionAlwaysFreeLink from '@webbers/webbers-medusa/links/promotion-always-free';

updatePromotionsWorkflow.hooks.promotionsUpdated(async ({ promotions, additional_data }, { container }) => {
  const query = container.resolve(ContainerRegistrationKeys.QUERY);
  const create = createAlwaysFreeFromPromotionWorkflow(container);
  const update = updateAlwaysFreeFromPromotionWorkflow(container);

  for (const promotion of promotions) {
    const existing = await query.graph({
      entity: PromotionAlwaysFreeLink.entryPoint,
      fields: ['*'],
      filters: { promotion_id: promotion.id },
    });

    if (!existing?.data?.length) {
      await create.run({ input: { promotion, additional_data } as CreateAlwaysFreeFromPromotionWorkflowInput });
    } else {
      await update.run({ input: { promotion, additional_data } as UpdateAlwaysFreeFromPromotionStepInput });
    }
  }
});

The plugin emits no .d.ts (declaration: false, to avoid a createWorkflow type-portability error), so these imports resolve to any in the host app unless it sets noImplicitAny. That is intentional — keep the casts above so the hook bodies stay type-checked.

Reading the flag elsewhere (e.g. a fulfillment provider deciding shipping is free) can join the link table directly:

SELECT af.always_free
FROM promotion p
LEFT JOIN promotion_promotion_promotion_addition_always_free paf ON paf.promotion_id = p.id
LEFT JOIN always_free af ON af.id = paf.always_free_id
WHERE p.id = ?

Activating the disallow-price-list-discounts behaviour

The admin widget writes metadata.disallow_discounts onto the price list's extended record (price_list_ext, linked to Medusa's price_list). The toggle only records intent — the enforcement happens when items are added to the cart.

The plugin ships the enforcement endpoint at POST /store/carts/:id/line-item-price-list. It behaves like Medusa's built-in add-to-cart, but when the body's metadata.price_list_id points at a price list flagged disallow_discounts, it sets is_discountable: false on the line item (and stamps metadata.disallowed_discounts_pricelist = true). When the Disallow price-list discounts feature is toggled off, the endpoint skips that logic and behaves as a plain add-to-cart, so it is safe to call unconditionally.

To activate the behaviour, have the storefront add price-list items through this endpoint instead of the default POST /store/carts/:id/line-items:

await sdk.client.fetch(`/store/carts/${cartId}/line-item-price-list`, {
  method: 'POST',
  body: {
    variant_id: variantId,
    quantity,
    metadata: { price_list_id: priceListId },
  },
});

Build

pnpm --filter @webbers/webbers-medusa build