@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
Maintainers
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
notemodule,/admin/noteendpoint 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
featureGuardmiddleware returns403for the feature's API routes (seesrc/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:migrateWhat'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-medusaRegister 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:migrateThe 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_querymodule. That copy must now be removed (both the module directory and itsmedusa-config.tsentry) — 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,cartandordertables 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:
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);Render a note widget in your entity's detail zone that calls
/admin/notewith yourentity_type(the module's registration key). Thenotesfeature 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 acreateWorkflowtype-portability error), so these imports resolve toanyin the host app unless it setsnoImplicitAny. 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