Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s

Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Admin Customizations
You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities.
> Learn more about Admin Extensions in [this documentation](https://docs.medusajs.com/learn/fundamentals/admin).
## Example: Create a Widget
A widget is a React component that can be injected into an existing page in the admin dashboard.
For example, create the file `src/admin/widgets/product-widget.tsx` with the following content:
```tsx title="src/admin/widgets/product-widget.tsx"
import { defineWidgetConfig } from "@medusajs/admin-sdk"
// The widget
const ProductWidget = () => {
return (
<div>
<h2>Product Widget</h2>
</div>
)
}
// The widget's configurations
export const config = defineWidgetConfig({
zone: "product.details.after",
})
export default ProductWidget
```
This inserts a widget with the text “Product Widget” at the end of a product’s details page.
@@ -0,0 +1,58 @@
# Admin Customizations Translations
The Medusa Admin dashboard supports multiple languages for its interface. Medusa uses [react-i18next](https://react.i18next.com/) to manage translations in the admin dashboard.
To add translations, create JSON translation files for each language under the `src/admin/i18n/json` directory. For example, create the `src/admin/i18n/json/en.json` file with the following content:
```json
{
"brands": {
"title": "Brands",
"description": "Manage your product brands"
},
"done": "Done"
}
```
Then, export the translations in `src/admin/i18n/index.ts`:
```ts
import en from "./json/en.json" with { type: "json" }
export default {
en: {
translation: en,
},
}
```
Finally, use translations in your admin widgets and routes using the `useTranslation` hook:
```tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { Button, Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
const ProductWidget = () => {
const { t } = useTranslation()
return (
<Container className="p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("brands.title")}</Heading>
<p>{t("brands.description")}</p>
</div>
<div className="flex justify-end px-6 py-4">
<Button variant="primary">{t("done")}</Button>
</div>
</Container>
)
}
export const config = defineWidgetConfig({
zone: "product.details.before",
})
export default ProductWidget
```
Learn more about translating admin extensions in the [Translate Admin Customizations](https://docs.medusajs.com/learn/fundamentals/admin/translations) documentation.
@@ -0,0 +1 @@
export default {}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
".",
"../../.medusa/types/plugin-augmentations.d.ts"
]
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+135
View File
@@ -0,0 +1,135 @@
# Custom API Routes
An API Route is a REST API endpoint.
An API Route is created in a TypeScript or JavaScript file under the `/src/api` directory of your Medusa application. The file’s name must be `route.ts` or `route.js`.
> Learn more about API Routes in [this documentation](https://docs.medusajs.com/learn/fundamentals/api-routes)
For example, to create a `GET` API Route at `/store/hello-world`, create the file `src/api/store/hello-world/route.ts` with the following content:
```ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
export async function GET(req: MedusaRequest, res: MedusaResponse) {
res.json({
message: "Hello world!",
});
}
```
## Supported HTTP methods
The file based routing supports the following HTTP methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file.
For example:
```ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Handle GET requests
}
export async function POST(req: MedusaRequest, res: MedusaResponse) {
// Handle POST requests
}
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
// Handle PUT requests
}
```
## Parameters
To create an API route that accepts a path parameter, create a directory within the route's path whose name is of the format `[param]`.
For example, if you want to define a route that takes a `productId` parameter, you can do so by creating a file called `/api/products/[productId]/route.ts`:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { productId } = req.params;
res.json({
message: `You're looking for product ${productId}`
})
}
```
To create an API route that accepts multiple path parameters, create within the file's path multiple directories whose names are of the format `[param]`.
For example, if you want to define a route that takes both a `productId` and a `variantId` parameter, you can do so by creating a file called `/api/products/[productId]/variants/[variantId]/route.ts`.
## Using the container
The Medusa container is available on `req.scope`. Use it to access modules' main services and other registered resources:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
export const GET = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const productModuleService = req.scope.resolve("product")
const [, count] = await productModuleService.listAndCount()
res.json({
count,
})
}
```
## Middleware
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file must export a configuration object with what middleware you want to apply to which routes.
For example, if you want to apply a custom middleware function to the `/store/custom` route, you can do so by adding the following to your `/api/middlewares.ts` file:
```ts
import { defineMiddlewares } from "@medusajs/framework/http"
import type {
MedusaRequest,
MedusaResponse,
MedusaNextFunction,
} from "@medusajs/framework/http";
async function logger(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
console.log("Request received");
next();
}
export default defineMiddlewares({
routes: [
{
matcher: "/store/custom",
middlewares: [logger],
},
],
})
```
The `matcher` property can be either a string or a regular expression. The `middlewares` property accepts an array of middleware functions.
@@ -0,0 +1,8 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
export async function GET(
req: MedusaRequest,
res: MedusaResponse
) {
res.sendStatus(200);
}
@@ -0,0 +1,39 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { transferCartCustomerWorkflow } from "@medusajs/medusa/core-flows"
import type { InternalSetCartCustomerType } from "../../../validators"
/**
* POST /internal/carts/:id/customer
* Body { customer_id }: associa il carrello al customer (workflow ufficiale transferCartCustomer,
* che aggiorna anche l'email del carrello e riprezza le righe, es. listino installatori).
*/
export async function POST(
req: MedusaRequest<InternalSetCartCustomerType>,
res: MedusaResponse
) {
await transferCartCustomerWorkflow(req.scope).run({
input: { id: req.params.id, customer_id: req.validatedBody.customer_id },
})
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [cart],
} = await query.graph({
entity: "cart",
fields: [
"id",
"email",
"customer_id",
"currency_code",
"total",
"items.id",
"items.variant_id",
"items.quantity",
"items.unit_price",
],
filters: { id: req.params.id },
})
res.json({ cart })
}
@@ -0,0 +1,19 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { setCartGuestEmailWorkflow } from "../../../../../workflows/internal/set-cart-guest-email"
import type { InternalSetCartGuestEmailType } from "../../../validators"
/**
* POST /internal/carts/:id/guest
* Body { email }: checkout guest. Associa il carrello a un customer guest (has_account = false)
* con quell'email, anche se esiste un account registrato con la stessa email (vedi workflow).
* 403 not_allowed se il carrello appartiene già a un account registrato.
*/
export async function POST(
req: MedusaRequest<InternalSetCartGuestEmailType>,
res: MedusaResponse
) {
await setCartGuestEmailWorkflow(req.scope).run({
input: { cart_id: req.params.id, email: req.validatedBody.email },
})
res.json({ cart_id: req.params.id, email: req.validatedBody.email })
}
@@ -0,0 +1,22 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { setInstallerGroupWorkflow } from "../../../../../workflows/internal/set-installer-group"
import type { InternalSetInstallerGroupType } from "../../../validators"
/**
* POST /internal/customers/:id/groups
* Body { action: "add" | "remove" }: aggiunge/rimuove il customer dal gruppo installatori.
*/
export async function POST(
req: MedusaRequest<InternalSetInstallerGroupType>,
res: MedusaResponse
) {
const { result: group } = await setInstallerGroupWorkflow(req.scope).run({
input: { customer_id: req.params.id, action: req.validatedBody.action },
})
res.json({
customer_id: req.params.id,
group,
member: req.validatedBody.action === "add",
})
}
@@ -0,0 +1,66 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { getOrdersListWorkflow } from "@medusajs/medusa/core-flows"
import type { OrderDTO } from "@medusajs/framework/types"
const ORDER_FIELDS = [
"id",
"display_id",
"status",
"email",
"currency_code",
"created_at",
"payment_status",
"fulfillment_status",
"item_total",
"shipping_total",
"tax_total",
"total",
"items.id",
"items.title",
"items.variant_sku",
"items.thumbnail",
"items.quantity",
"items.unit_price",
"items.total",
]
const MAX_LIMIT = 100
const toInt = (value: unknown, fallback: number) => {
const n = Number.parseInt(String(value ?? ""), 10)
return Number.isFinite(n) && n >= 0 ? n : fallback
}
/**
* GET /internal/customers/:id/orders?limit=20&offset=0[&display_id=12]
* Ordini del customer, dal più recente. Con `display_id` restituisce al massimo l'ordine con quel numero,
* solo se è del customer (dettaglio dell'area clienti: apps/api poi rilegge l'ordine per id).
*/
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const limit = Math.min(toInt(req.query.limit, 20), MAX_LIMIT)
const offset = toInt(req.query.offset, 0)
const displayId = toInt(req.query.display_id, -1)
const { result } = await getOrdersListWorkflow(req.scope).run({
input: {
fields: ORDER_FIELDS,
variables: {
filters: {
customer_id: req.params.id,
is_draft_order: false,
...(displayId > 0 ? { display_id: displayId } : {}),
},
skip: offset,
take: limit,
order: { created_at: "DESC" },
},
},
})
const { rows, metadata } = result as {
rows: OrderDTO[]
metadata: { count: number; skip: number; take: number }
}
res.json({ orders: rows, count: metadata.count, offset, limit })
}
@@ -0,0 +1,19 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { upsertAccountCustomerWorkflow } from "../../../workflows/internal/upsert-account-customer"
import type { InternalCreateCustomerType } from "../validators"
/**
* POST /internal/customers
* Crea il customer Medusa (has_account = true) per un utente di apps/api,
* oppure restituisce quello esistente con la stessa email. 201 se creato, 200 se esistente.
*/
export async function POST(
req: MedusaRequest<InternalCreateCustomerType>,
res: MedusaResponse
) {
const { result } = await upsertAccountCustomerWorkflow(req.scope).run({
input: req.validatedBody,
})
res.status(result.created ? 201 : 200).json(result)
}
@@ -0,0 +1,58 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { getOrderDetailWorkflow } from "@medusajs/medusa/core-flows"
/** Campi per le email transazionali di apps/api (conferma, annullamento, spedizione) e il dettaglio ordine dell'area clienti. */
const ORDER_FIELDS = [
"id",
"display_id",
"status",
"email",
"customer_id",
"currency_code",
"created_at",
"canceled_at",
"payment_status",
"fulfillment_status",
"item_total",
"shipping_total",
"tax_total",
"discount_total",
"total",
"items.id",
"items.title",
"items.product_title",
"items.variant_title",
"items.variant_sku",
"items.thumbnail",
"items.quantity",
"items.unit_price",
"items.total",
"shipping_address.first_name",
"shipping_address.last_name",
"shipping_address.company",
"shipping_address.address_1",
"shipping_address.address_2",
"shipping_address.city",
"shipping_address.postal_code",
"shipping_address.province",
"shipping_address.country_code",
"shipping_address.phone",
"shipping_methods.name",
"fulfillments.id",
"fulfillments.created_at",
"fulfillments.shipped_at",
"fulfillments.labels.tracking_number",
"fulfillments.labels.tracking_url",
]
/**
* GET /internal/orders/:id
* Dettaglio ordine con totali calcolati, righe, indirizzo di spedizione e spedizioni (tracking).
* 404 not_found se l'ordine non esiste.
*/
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { result: order } = await getOrderDetailWorkflow(req.scope).run({
input: { order_id: req.params.id, fields: ORDER_FIELDS, filters: { is_draft_order: false } },
})
res.json({ order })
}
@@ -0,0 +1,71 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import {
ContainerRegistrationKeys,
MedusaError,
OrderStatus,
} from "@medusajs/framework/utils"
import { claimGuestOrderWorkflow } from "../../../../workflows/internal/claim-guest-order"
import type { InternalClaimOrdersType } from "../../validators"
/** Escape dei caratteri jolly di ILIKE: l'email viene confrontata case-insensitive ma letterale. */
const escapeLike = (value: string) => value.replace(/[\\%_]/g, "\\$&")
/**
* POST /internal/orders/claim
* Body { customer_id, email }: collega al customer (registrato) tutti gli ordini guest con
* quella email. apps/api deve chiamarla solo dopo aver verificato il possesso dell'email.
* Ogni ordine passa per il flusso ufficiale di trasferimento (vedi claim-guest-order).
*/
export async function POST(
req: MedusaRequest<InternalClaimOrdersType>,
res: MedusaResponse
) {
const { customer_id, email } = req.validatedBody
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [customer],
} = await query.graph({
entity: "customer",
fields: ["id", "email", "has_account"],
filters: { id: customer_id },
})
if (!customer) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, `Customer ${customer_id} non trovato`)
}
if (!customer.has_account || customer.email?.toLowerCase() !== email) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Il customer deve essere un account registrato con la stessa email"
)
}
const { data: orders } = await query.graph({
entity: "order",
fields: ["id", "status", "customer_id", "customer.has_account"],
filters: { email: { $ilike: escapeLike(email) }, is_draft_order: false },
})
const candidates = orders.filter(
(o) =>
o.customer_id !== customer_id &&
!o.customer?.has_account &&
o.status !== OrderStatus.CANCELED
)
const claimed: string[] = []
const failed: { order_id: string; message: string }[] = []
for (const order of candidates) {
try {
await claimGuestOrderWorkflow(req.scope).run({
input: { order_id: order.id, customer_id },
})
claimed.push(order.id)
} catch (e) {
failed.push({ order_id: order.id, message: (e as Error).message })
}
}
res.json({ claimed, failed })
}
@@ -0,0 +1,59 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { ContainerRegistrationKeys, MedusaError, QueryContext } from "@medusajs/framework/utils"
import type { InternalCustomerPricesType } from "../validators"
/** Campi di calculated_price usati qui (il tipo generato della variante non lo include). */
type CalculatedPrice = {
calculated_amount: number | null
original_amount: number | null
currency_code: string | null
calculated_price?: { price_list_type?: string | null } | null
}
/**
* POST /internal/prices
* Body { customer_id, region_id, variant_ids }: prezzi calcolati delle varianti per quel customer
* (contesto con i suoi customer group → price list riservate, es. listino installatori).
* Equivale al calculated_price della Store API con un customer autenticato.
* Risposta { prices: { [variant_id]: { amount, original_amount, currency_code, price_list_type } } }.
*/
export async function POST(req: MedusaRequest<InternalCustomerPricesType>, res: MedusaResponse) {
const { customer_id, region_id, variant_ids } = req.validatedBody
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [region],
} = await query.graph({ entity: "region", fields: ["id", "currency_code"], filters: { id: region_id } })
if (!region) throw new MedusaError(MedusaError.Types.NOT_FOUND, `Regione ${region_id} non trovata`)
const {
data: [customer],
} = await query.graph({ entity: "customer", fields: ["id", "groups.id"], filters: { id: customer_id } })
if (!customer) throw new MedusaError(MedusaError.Types.NOT_FOUND, `Customer ${customer_id} non trovato`)
const { data: variants } = await query.graph({
entity: "variant",
fields: ["id", "calculated_price.*"],
filters: { id: variant_ids },
context: {
calculated_price: QueryContext({
region_id: region.id,
currency_code: region.currency_code,
customer: { groups: (customer.groups ?? []).filter(Boolean).map((g) => ({ id: g!.id })) },
}),
},
})
const prices: Record<string, unknown> = {}
for (const v of variants) {
const p = (v as unknown as { calculated_price?: CalculatedPrice }).calculated_price
if (!p) continue
prices[v.id] = {
amount: p.calculated_amount,
original_amount: p.original_amount,
currency_code: p.currency_code,
price_list_type: p.calculated_price?.price_list_type ?? null,
}
}
res.json({ prices })
}
@@ -0,0 +1,42 @@
import { z } from "@medusajs/framework/zod"
/** Email normalizzata (trim + minuscolo): apps/api deve usare la stessa normalizzazione. */
const email = z
.string()
.trim()
.toLowerCase()
.pipe(z.email())
export const InternalCreateCustomer = z.object({
email,
first_name: z.string().trim().min(1),
last_name: z.string().trim().min(1),
company_name: z.string().trim().min(1).nullish(),
})
export type InternalCreateCustomerType = z.infer<typeof InternalCreateCustomer>
export const InternalSetInstallerGroup = z.object({
action: z.enum(["add", "remove"]),
})
export type InternalSetInstallerGroupType = z.infer<typeof InternalSetInstallerGroup>
export const InternalSetCartCustomer = z.object({
customer_id: z.string().min(1),
})
export type InternalSetCartCustomerType = z.infer<typeof InternalSetCartCustomer>
export const InternalClaimOrders = z.object({
customer_id: z.string().min(1),
email,
})
export type InternalClaimOrdersType = z.infer<typeof InternalClaimOrders>
export const InternalSetCartGuestEmail = z.object({ email })
export type InternalSetCartGuestEmailType = z.infer<typeof InternalSetCartGuestEmail>
export const InternalCustomerPrices = z.object({
customer_id: z.string().min(1),
region_id: z.string().min(1),
variant_ids: z.array(z.string().min(1)).min(1).max(200),
})
export type InternalCustomerPricesType = z.infer<typeof InternalCustomerPrices>
@@ -0,0 +1,67 @@
import {
configureStoreSearch,
defineMiddlewares,
validateAndTransformBody,
} from "@medusajs/framework/http"
import { requireInternalSecret } from "../utils/internal-secret"
import {
InternalClaimOrders,
InternalCreateCustomer,
InternalCustomerPrices,
InternalSetCartGuestEmail,
InternalSetCartCustomer,
InternalSetInstallerGroup,
} from "./internal/validators"
export default defineMiddlewares({
routes: [
// The product index declares filterable `status` and `sales_channel_ids`, so
// the route narrows it to published products in the key's sales channels.
{
method: ["POST"],
matcher: "/store/search",
middlewares: [
configureStoreSearch({
allowed_indexes: {
product: true,
},
}),
],
},
// Route interne: solo apps/api (Fastify), con header x-internal-secret
{
matcher: "/internal*",
middlewares: [requireInternalSecret],
},
{
method: ["POST"],
matcher: "/internal/customers",
middlewares: [validateAndTransformBody(InternalCreateCustomer)],
},
{
method: ["POST"],
matcher: "/internal/customers/:id/groups",
middlewares: [validateAndTransformBody(InternalSetInstallerGroup)],
},
{
method: ["POST"],
matcher: "/internal/carts/:id/customer",
middlewares: [validateAndTransformBody(InternalSetCartCustomer)],
},
{
method: ["POST"],
matcher: "/internal/carts/:id/guest",
middlewares: [validateAndTransformBody(InternalSetCartGuestEmail)],
},
{
method: ["POST"],
matcher: "/internal/prices",
middlewares: [validateAndTransformBody(InternalCustomerPrices)],
},
{
method: ["POST"],
matcher: "/internal/orders/claim",
middlewares: [validateAndTransformBody(InternalClaimOrders)],
},
],
})
@@ -0,0 +1,8 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
export async function GET(
req: MedusaRequest,
res: MedusaResponse
) {
res.sendStatus(200);
}
+38
View File
@@ -0,0 +1,38 @@
# Custom scheduled jobs
A scheduled job is a function executed at a specified interval of time in the background of your Medusa application.
> Learn more about scheduled jobs in [this documentation](https://docs.medusajs.com/learn/fundamentals/scheduled-jobs).
A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory.
For example, create the file `src/jobs/hello-world.ts` with the following content:
```ts
import {
MedusaContainer
} from "@medusajs/framework/types";
export default async function myCustomJob(container: MedusaContainer) {
const productService = container.resolve("product")
const products = await productService.listAndCountProducts();
// Do something with the products
}
export const config = {
name: "daily-product-report",
schedule: "0 0 * * *", // Every day at midnight
};
```
A scheduled job file must export:
- The function to be executed whenever it’s time to run the scheduled job.
- A configuration object defining the job. It has three properties:
- `name`: a unique name for the job.
- `schedule`: a [cron expression](https://crontab.guru/).
- `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed
The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services.
+26
View File
@@ -0,0 +1,26 @@
# Module Links
A module link forms an association between two data models of different modules, while maintaining module isolation.
> Learn more about links in [this documentation](https://docs.medusajs.com/learn/fundamentals/module-links)
For example:
```ts
import BlogModule from "../modules/blog"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
ProductModule.linkable.product,
BlogModule.linkable.post
)
```
This defines a link between the Product Module's `product` data model and the Blog Module (custom module)'s `post` data model.
Then, in the Medusa application, run the following command to sync the links to the database:
```bash
npx medusa db:migrate
```
+117
View File
@@ -0,0 +1,117 @@
# Custom Module
A module is a package of reusable functionalities. It can be integrated into your Medusa application without affecting the overall system. You can create a module as part of a plugin.
> Learn more about modules in [this documentation](https://docs.medusajs.com/learn/fundamentals/modules).
To create a module:
## 1. Create a Data Model
A data model represents a table in the database. You create a data model in a TypeScript or JavaScript file under the `models` directory of a module.
For example, create the file `src/modules/blog/models/post.ts` with the following content:
```ts
import { model } from "@medusajs/framework/utils"
const Post = model.define("post", {
id: model.id().primaryKey(),
title: model.text(),
})
export default Post
```
## 2. Create a Service
A module must define a service. A service is a TypeScript or JavaScript class holding methods related to a business logic or commerce functionality.
For example, create the file `src/modules/blog/service.ts` with the following content:
```ts
import { MedusaService } from "@medusajs/framework/utils"
import Post from "./models/post"
class BlogModuleService extends MedusaService({
Post,
}){
}
export default BlogModuleService
```
## 3. Export Module Definition
A module must have an `index.ts` file in its root directory that exports its definition. The definition specifies the main service of the module.
For example, create the file `src/modules/blog/index.ts` with the following content:
```ts
import BlogModuleService from "./service"
import { Module } from "@medusajs/framework/utils"
export const BLOG_MODULE = "blog"
export default Module(BLOG_MODULE, {
service: BlogModuleService,
})
```
## 4. Add Module to Medusa's Configurations
To start using the module, add it to `medusa-config.ts`:
```ts
module.exports = defineConfig({
projectConfig: {
// ...
},
modules: [
{
resolve: "./src/modules/blog",
},
],
})
```
## 5. Generate and Run Migrations
To generate migrations for your module, run the following command:
```bash
npx medusa db:generate blog
```
Then, to run migrations, run the following command:
```bash
npx medusa db:migrate
```
## Use Module
You can use the module in customizations within the Medusa application, such as workflows and API routes.
For example, to use the module in an API route:
```ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
import BlogModuleService from "../../../modules/blog/service"
import { BLOG_MODULE } from "../../../modules/blog"
export async function GET(
req: MedusaRequest,
res: MedusaResponse
): Promise<void> {
const blogModuleService: BlogModuleService = req.scope.resolve(
BLOG_MODULE
)
const posts = await blogModuleService.listPosts()
res.json({
posts
})
}
```
@@ -0,0 +1,346 @@
import { ExecArgs } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
MedusaError,
Modules,
ProductStatus,
} from "@medusajs/framework/utils"
import {
createApiKeysWorkflow,
createInventoryLevelsWorkflow,
createProductsWorkflow,
createRegionsWorkflow,
createSalesChannelsWorkflow,
createShippingOptionsWorkflow,
createShippingProfilesWorkflow,
createStockLocationsWorkflow,
createTaxRegionsWorkflow,
linkSalesChannelsToApiKeyWorkflow,
linkSalesChannelsToStockLocationWorkflow,
updateRegionsWorkflow,
updateStoresWorkflow,
} from "@medusajs/medusa/core-flows"
/**
* Configurazione base per la vendita in Italia (prompt 04).
* Idempotente: ogni entità viene cercata per nome/handle e creata solo se manca.
*
* Esecuzione (in medusa/apps/backend): npx medusa exec ./src/scripts/seed-base.ts
*/
const COUNTRY = "it"
const CURRENCY = "eur"
const REGION_NAME = "Italia"
const SALES_CHANNEL_NAME = "Web"
const API_KEY_TITLE = "Web (apps/api)"
const STOCK_LOCATION_NAME = "Magazzino ITI"
const FULFILLMENT_SET_NAME = "Magazzino ITI - spedizioni"
const SERVICE_ZONE_NAME = "Italia"
const SHIPPING_OPTION_NAME = "Spedizione standard"
const STRIPE_PROVIDER_ID = "pp_stripe_stripe"
const FULFILLMENT_PROVIDER_ID = "manual_manual"
/** IVA ordinaria. Provvisoria: aliquote agevolate da affinare dopo il lancio (vedi CLAUDE.md). */
const VAT_RATE = 22
const SAMPLE_PRODUCTS = [
{
title: "Addolcitore d'acqua domestico 20 L",
handle: "addolcitore-domestico-20l",
description:
"Addolcitore a scambio ionico con valvola volumetrica, per abitazioni fino a 4 persone.",
sku: "ITI-ADD-20L",
priceEur: 1190,
},
{
title: "Kit fotovoltaico 6 kW con accumulo",
handle: "kit-fotovoltaico-6kw-accumulo",
description:
"Kit residenziale da 6 kWp: moduli monocristallini, inverter ibrido e batteria da 10 kWh.",
sku: "ITI-FV-6KW-ACC",
priceEur: 9900,
},
{
title: "Pompa di calore aria-acqua 8 kW",
handle: "pompa-di-calore-aria-acqua-8kw",
description:
"Pompa di calore monoblocco per riscaldamento, raffrescamento e acqua calda sanitaria.",
sku: "ITI-PDC-8KW",
priceEur: 5490,
},
] as const
export default async function seedBase({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const link = container.resolve(ContainerRegistrationKeys.LINK)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const storeModule = container.resolve(Modules.STORE)
const regionModule = container.resolve(Modules.REGION)
const taxModule = container.resolve(Modules.TAX)
const salesChannelModule = container.resolve(Modules.SALES_CHANNEL)
const apiKeyModule = container.resolve(Modules.API_KEY)
const stockLocationModule = container.resolve(Modules.STOCK_LOCATION)
const fulfillmentModule = container.resolve(Modules.FULFILLMENT)
const productModule = container.resolve(Modules.PRODUCT)
const paymentModule = container.resolve(Modules.PAYMENT)
// --- Prerequisito: provider Stripe registrato (STRIPE_API_KEY impostata) ---
const stripeProviders = await paymentModule.listPaymentProviders({
id: STRIPE_PROVIDER_ID,
})
if (!stripeProviders.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Payment provider ${STRIPE_PROVIDER_ID} non registrato: imposta STRIPE_API_KEY nel .env di medusa/apps/backend.`
)
}
// --- Sales channel "Web" ---
let [salesChannel] = await salesChannelModule.listSalesChannels({
name: SALES_CHANNEL_NAME,
})
if (!salesChannel) {
const { result } = await createSalesChannelsWorkflow(container).run({
input: { salesChannelsData: [{ name: SALES_CHANNEL_NAME }] },
})
salesChannel = result[0]
logger.info(`Sales channel "${SALES_CHANNEL_NAME}" creato`)
}
// --- Publishable API key collegata al sales channel ---
let [apiKey] = await apiKeyModule.listApiKeys({
title: API_KEY_TITLE,
type: "publishable",
})
if (!apiKey) {
const { result } = await createApiKeysWorkflow(container).run({
input: { api_keys: [{ title: API_KEY_TITLE, type: "publishable", created_by: "" }] },
})
apiKey = result[0]
logger.info(`Publishable API key "${API_KEY_TITLE}" creata`)
}
const { data: keyChannels } = await query.graph({
entity: "api_key",
fields: ["sales_channels.id"],
filters: { id: apiKey.id },
})
if (!keyChannels[0]?.sales_channels?.some((sc) => sc?.id === salesChannel.id)) {
await linkSalesChannelsToApiKeyWorkflow(container).run({
input: { id: apiKey.id, add: [salesChannel.id] },
})
}
// --- Regione Italia (EUR, prezzi IVA inclusa, Stripe) ---
let [region] = await regionModule.listRegions({ name: REGION_NAME })
if (!region) {
const { result } = await createRegionsWorkflow(container).run({
input: {
regions: [
{
name: REGION_NAME,
currency_code: CURRENCY,
countries: [COUNTRY],
automatic_taxes: true,
is_tax_inclusive: true,
payment_providers: [STRIPE_PROVIDER_ID],
},
],
},
})
region = result[0]
logger.info(`Regione "${REGION_NAME}" creata`)
} else {
// Riallinea i provider se la regione esiste già (es. Stripe aggiunto dopo)
await updateRegionsWorkflow(container).run({
input: {
selector: { id: region.id },
update: { payment_providers: [STRIPE_PROVIDER_ID], is_tax_inclusive: true },
},
})
}
// --- Tax region IT con aliquota di default 22% ---
const taxRegion = (await taxModule.listTaxRegions({ country_code: COUNTRY })).find(
(tr) => !tr.parent_id
)
if (!taxRegion) {
await createTaxRegionsWorkflow(container).run({
input: [
{
country_code: COUNTRY,
provider_id: "tp_system",
default_tax_rate: { rate: VAT_RATE, code: `IVA${VAT_RATE}`, name: `IVA ${VAT_RATE}%` },
},
],
})
logger.info(`Tax region IT creata (IVA ${VAT_RATE}%)`)
}
// --- Stock location + fulfillment (provider manuale) ---
let [stockLocation] = await stockLocationModule.listStockLocations({
name: STOCK_LOCATION_NAME,
})
if (!stockLocation) {
const { result } = await createStockLocationsWorkflow(container).run({
input: {
locations: [
{ name: STOCK_LOCATION_NAME, address: { address_1: "", country_code: COUNTRY } },
],
},
})
stockLocation = result[0]
await link.create({
[Modules.STOCK_LOCATION]: { stock_location_id: stockLocation.id },
[Modules.FULFILLMENT]: { fulfillment_provider_id: FULFILLMENT_PROVIDER_ID },
})
logger.info(`Stock location "${STOCK_LOCATION_NAME}" creata`)
}
const { data: locationChannels } = await query.graph({
entity: "stock_location",
fields: ["sales_channels.id"],
filters: { id: stockLocation.id },
})
if (!locationChannels[0]?.sales_channels?.some((sc) => sc?.id === salesChannel.id)) {
await linkSalesChannelsToStockLocationWorkflow(container).run({
input: { id: stockLocation.id, add: [salesChannel.id] },
})
}
let [fulfillmentSet] = await fulfillmentModule.listFulfillmentSets(
{ name: FULFILLMENT_SET_NAME },
{ relations: ["service_zones"] }
)
if (!fulfillmentSet) {
fulfillmentSet = await fulfillmentModule.createFulfillmentSets({
name: FULFILLMENT_SET_NAME,
type: "shipping",
service_zones: [
{ name: SERVICE_ZONE_NAME, geo_zones: [{ country_code: COUNTRY, type: "country" }] },
],
})
await link.create({
[Modules.STOCK_LOCATION]: { stock_location_id: stockLocation.id },
[Modules.FULFILLMENT]: { fulfillment_set_id: fulfillmentSet.id },
})
logger.info(`Fulfillment set "${FULFILLMENT_SET_NAME}" creato`)
}
const serviceZone = fulfillmentSet.service_zones[0]
// --- Shipping profile di default ---
let [shippingProfile] = await fulfillmentModule.listShippingProfiles({ type: "default" })
if (!shippingProfile) {
const { result } = await createShippingProfilesWorkflow(container).run({
input: { data: [{ name: "Default Shipping Profile", type: "default" }] },
})
shippingProfile = result[0]
}
// --- Shipping option "Spedizione standard" ---
const [shippingOption] = await fulfillmentModule.listShippingOptions({
name: SHIPPING_OPTION_NAME,
service_zone: { id: serviceZone.id },
})
if (!shippingOption) {
await createShippingOptionsWorkflow(container).run({
input: [
{
name: SHIPPING_OPTION_NAME,
price_type: "flat",
provider_id: FULFILLMENT_PROVIDER_ID,
service_zone_id: serviceZone.id,
shipping_profile_id: shippingProfile.id,
type: {
label: "Standard",
description: "Consegna in 3-5 giorni lavorativi",
code: "standard",
},
// Importi in unità intere (euro), IVA inclusa come da regione. Placeholder da definire.
prices: [
{ currency_code: CURRENCY, amount: 15 },
{ region_id: region.id, amount: 15 },
],
rules: [
{ attribute: "enabled_in_store", value: "true", operator: "eq" },
{ attribute: "is_return", value: "false", operator: "eq" },
],
},
],
})
logger.info(`Shipping option "${SHIPPING_OPTION_NAME}" creata`)
}
// --- Store: EUR (IVA inclusa) come unica valuta, default su Web/Italia/magazzino ---
const [store] = await storeModule.listStores()
await updateStoresWorkflow(container).run({
input: {
selector: { id: store.id },
update: {
name: "ITI TECHNOLOGY",
supported_currencies: [
{ currency_code: CURRENCY, is_default: true, is_tax_inclusive: true },
],
default_sales_channel_id: salesChannel.id,
default_region_id: region.id,
default_location_id: stockLocation.id,
},
},
})
// --- Prodotti di esempio ---
const existing = await productModule.listProducts(
{ handle: SAMPLE_PRODUCTS.map((p) => p.handle) },
{ select: ["handle"] }
)
const existingHandles = new Set(existing.map((p) => p.handle))
const toCreate = SAMPLE_PRODUCTS.filter((p) => !existingHandles.has(p.handle))
if (toCreate.length) {
const { result: products } = await createProductsWorkflow(container).run({
input: {
products: toCreate.map((p) => ({
title: p.title,
handle: p.handle,
description: p.description,
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
sales_channels: [{ id: salesChannel.id }],
options: [{ title: "Modello", values: ["Standard"] }],
variants: [
{
title: "Standard",
sku: p.sku,
manage_inventory: true,
options: { Modello: "Standard" },
prices: [{ currency_code: CURRENCY, amount: p.priceEur }],
},
],
})),
},
})
// Giacenza iniziale nel magazzino per le varianti appena create
const { data: variants } = await query.graph({
entity: "product_variant",
fields: ["inventory_items.inventory_item_id"],
filters: { product_id: products.map((p) => p.id) },
})
const inventoryItemIds = variants.flatMap((v) =>
(v.inventory_items ?? []).map((ii) => ii?.inventory_item_id).filter(Boolean)
) as string[]
if (inventoryItemIds.length) {
await createInventoryLevelsWorkflow(container).run({
input: {
inventory_levels: inventoryItemIds.map((inventory_item_id) => ({
inventory_item_id,
location_id: stockLocation.id,
stocked_quantity: 20,
})),
},
})
}
logger.info(`Prodotti di esempio creati: ${toCreate.map((p) => p.handle).join(", ")}`)
}
logger.info("Seed base completato.")
logger.info(`MEDUSA_PUBLISHABLE_KEY=${apiKey.token}`)
}
@@ -0,0 +1,138 @@
import { CreatePriceListWorkflowInputDTO, ExecArgs } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
MedusaError,
Modules,
PriceListStatus,
PriceListType,
} from "@medusajs/framework/utils"
import {
batchPriceListPricesWorkflow,
createCustomerGroupsWorkflow,
createPriceListsWorkflow,
updatePriceListsWorkflow,
} from "@medusajs/medusa/core-flows"
/**
* Customer group "installatori" + listino riservato (prompt 05).
* Idempotente. Richiede che seed-base.ts sia già stato eseguito (prodotti di esempio).
*
* Esecuzione (in medusa/apps/backend): npx medusa exec ./src/scripts/seed-installatori.ts
*/
const GROUP_NAME = "installatori"
const GROUP_CODE = "installer"
const PRICE_LIST_TITLE = "Listino installatori"
const CURRENCY = "eur"
/** Prezzi riservati (EUR, IVA inclusa come la regione Italia) per handle prodotto → variante "Standard". */
const INSTALLER_PRICES: Record<string, number> = {
"addolcitore-domestico-20l": 990,
"kit-fotovoltaico-6kw-accumulo": 8400,
"pompa-di-calore-aria-acqua-8kw": 4650,
}
export default async function seedInstallatori({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const customerModule = container.resolve(Modules.CUSTOMER)
// --- Customer group "installatori" (metadata.code = "installer") ---
let [group] = await customerModule.listCustomerGroups({ name: GROUP_NAME })
if (!group) {
const { result } = await createCustomerGroupsWorkflow(container).run({
input: {
customersData: [{ name: GROUP_NAME, metadata: { code: GROUP_CODE } }],
},
})
group = result[0]
logger.info(`Customer group "${GROUP_NAME}" creato`)
}
// --- Varianti dei prodotti di esempio ---
const { data: products } = await query.graph({
entity: "product",
fields: ["handle", "variants.id"],
filters: { handle: Object.keys(INSTALLER_PRICES) },
})
const missing = Object.keys(INSTALLER_PRICES).filter(
(h) => !products.some((p) => p.handle === h)
)
if (missing.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Prodotti mancanti (${missing.join(", ")}): eseguire prima src/scripts/seed-base.ts`
)
}
const wantedPrices = products.flatMap((p) =>
p.variants.map((v) => ({
variant_id: v.id,
amount: INSTALLER_PRICES[p.handle],
currency_code: CURRENCY,
}))
)
// --- Price list override, attiva, riservata al gruppo ---
const { data: existingLists } = await query.graph({
entity: "price_list",
fields: ["id", "type"],
filters: { title: PRICE_LIST_TITLE },
})
let priceList: { id: string; type?: string } | undefined = existingLists[0]
if (!priceList) {
// `type` non è nel tipo TS del workflow ma lo step lo inoltra al Pricing Module
// (stesso comportamento di POST /admin/price-lists); il default sarebbe "sale".
const priceListData: CreatePriceListWorkflowInputDTO & { type: PriceListType } = {
title: PRICE_LIST_TITLE,
description: "Prezzi riservati agli installatori approvati (customer group installatori)",
type: PriceListType.OVERRIDE,
status: PriceListStatus.ACTIVE,
rules: { "customer.groups.id": [group.id] },
prices: wantedPrices,
}
const { result } = await createPriceListsWorkflow(container).run({
input: {
price_lists_data: [priceListData],
},
})
priceList = result[0]
logger.info(`Price list "${PRICE_LIST_TITLE}" creata`)
} else {
if (priceList.type !== PriceListType.OVERRIDE) {
logger.warn(`"${PRICE_LIST_TITLE}" ha type "${priceList.type}" invece di "override": correggere dal dashboard`)
}
// Riallinea stato/regola se modificati a mano dal dashboard
await updatePriceListsWorkflow(container).run({
input: {
price_lists_data: [
{
id: priceList.id,
status: PriceListStatus.ACTIVE,
rules: { "customer.groups.id": [group.id] },
},
],
},
})
// Aggiunge solo i prezzi delle varianti che non ne hanno ancora uno nel listino
const { data: listPrices } = await query.graph({
entity: "price_list",
fields: ["prices.price_set.variant.id"],
filters: { id: priceList.id },
})
const pricedVariants = new Set(
(listPrices[0]?.prices ?? []).map((p) => p?.price_set?.variant?.id).filter(Boolean)
)
const toCreate = wantedPrices.filter((p) => !pricedVariants.has(p.variant_id))
if (toCreate.length) {
await batchPriceListPricesWorkflow(container).run({
input: { data: { id: priceList.id, create: toCreate, update: [], delete: [] } },
})
logger.info(`Aggiunti ${toCreate.length} prezzi a "${PRICE_LIST_TITLE}"`)
}
}
logger.info("Seed installatori completato.")
logger.info(`Customer group "${GROUP_NAME}": ${group.id}`)
logger.info(`Price list "${PRICE_LIST_TITLE}": ${priceList.id}`)
}
@@ -0,0 +1,29 @@
export type ProductOptionRow = {
title?: string | null;
values?: ({ value?: string | null } | null)[] | null;
} | null;
/**
* Flattens a product's options into `"<option title>:<value>"` entries, e.g.
* `["Size:S", "Color:Red"]`. One field keeps the index simple; the storefront
* splits on the first `:` to group the facet by option name.
*/
export function toOptionValues(
options: ProductOptionRow[] | null | undefined,
): string[] {
const flattened = (options ?? []).flatMap((option) => {
const title = option?.title?.trim();
if (!title) {
return [];
}
return (option?.values ?? [])
.map((optionValue) => optionValue?.value?.trim())
.filter((value): value is string => Boolean(value))
.map((value) => `${title}:${value}`);
});
// A value shared by several options would otherwise be counted twice.
return Array.from(new Set(flattened));
}
@@ -0,0 +1,176 @@
import type { SearchTypes } from "@medusajs/framework/types";
import { QueryContext, search } from "@medusajs/framework/utils";
/**
* The currencies the index holds prices in. Each gets its own set of price
* fields, so filtering and sorting work per currency. Adding one is a schema
* change: the module reindexes on the next boot. Keep in sync with the
* storefront's `SEARCH_PRICE_CURRENCIES`.
*/
export const PRICE_CURRENCIES = ["eur"] as const;
export type PriceCurrency = (typeof PRICE_CURRENCIES)[number];
// Read once per currency, since the Pricing Module calculates for a single
// currency per query.
const PRICE_GRAPH_FIELDS = [
"id",
"variants.calculated_price.calculated_amount",
"variants.calculated_price.original_amount",
];
const pricingContext = (currency: PriceCurrency) => ({
variants: {
calculated_price: QueryContext({ currency_code: currency }),
},
});
type PricedVariant = {
calculated_price?: {
calculated_amount?: number | null;
original_amount?: number | null;
} | null;
[key: string]: unknown;
} | null;
/** A product's priced variants per currency, as `loadPricing` reads them. */
export type ProductPricingRows = Partial<
Record<PriceCurrency, PricedVariant[] | null>
>;
type PriceFieldName =
`${"min_price" | "max_price" | "original_price"}_${PriceCurrency}`;
type OnSaleFieldName = `on_sale_${PriceCurrency}`;
export type ProductPricing = { [K in PriceFieldName]?: number | null } & {
[K in OnSaleFieldName]?: boolean | null;
};
type PriceFields = {
[K in PriceFieldName]: ReturnType<typeof search.float>;
} & { [K in OnSaleFieldName]: ReturnType<typeof search.boolean> };
/** The per-currency price fields, to spread into the index schema. */
export const priceFields = Object.fromEntries(
PRICE_CURRENCIES.flatMap((currency) => [
[
`min_price_${currency}`,
search
.float()
.filterable()
.sortable()
.facetable({ types: ["stats"] })
.retrievable(),
],
[
`max_price_${currency}`,
search
.float()
.filterable()
.sortable()
.facetable({ types: ["stats"] })
.retrievable(),
],
[`original_price_${currency}`, search.float().retrievable()],
[
`on_sale_${currency}`,
search.boolean().filterable().facetable().retrievable(),
],
]),
) as PriceFields;
/**
* One currency's price fields: the cheapest variant's calculated and original
* price, and the most expensive variant's as the max. Picking one variant for
* the pair keeps the discount describing a real product rather than mixing
* two variants' amounts. A product without a price in the currency writes
* nothing, so it drops out of that currency's price filter and sort.
*/
function toPricing(
currency: PriceCurrency,
variants: PricedVariant[] | null | undefined,
): ProductPricing {
let cheapest: { calculated: number; original: number } | undefined;
let maxPrice: number | undefined;
for (const variant of variants ?? []) {
const price = variant?.calculated_price;
const calculated = price?.calculated_amount;
if (typeof calculated !== "number") {
continue;
}
const original =
typeof price?.original_amount === "number"
? price.original_amount
: calculated;
if (maxPrice === undefined || calculated > maxPrice) {
maxPrice = calculated;
}
if (!cheapest || calculated < cheapest.calculated) {
cheapest = { calculated, original };
}
}
if (!cheapest) {
return {};
}
return {
[`min_price_${currency}`]: cheapest.calculated,
[`max_price_${currency}`]: maxPrice ?? cheapest.calculated,
[`original_price_${currency}`]: cheapest.original,
// Written even when false: "not on sale" is a real facet bucket, unlike a
// missing value.
[`on_sale_${currency}`]: cheapest.original > cheapest.calculated,
} as ProductPricing;
}
/** Every currency's price fields for one product. */
export function toProductPricing(
pricingRows: ProductPricingRows | undefined,
): ProductPricing {
return Object.assign(
{},
...PRICE_CURRENCIES.map((currency) =>
toPricing(currency, pricingRows?.[currency]),
),
);
}
/**
* The priced variants of the given products, per currency: one `query.graph`
* read per currency, however many products there are.
*/
export async function loadPricing(
ids: string[],
{ container }: SearchTypes.SearchIngestionContext,
): Promise<Map<string, ProductPricingRows>> {
const pricing = new Map<string, ProductPricingRows>();
if (!ids.length) {
return pricing;
}
await Promise.all(
PRICE_CURRENCIES.map(async (currency) => {
const { data } = await container.query.graph({
entity: "product",
fields: PRICE_GRAPH_FIELDS,
filters: { id: ids },
context: pricingContext(currency),
});
for (const product of data) {
const rows = pricing.get(product.id) ?? {};
rows[currency] = product.variants as PricedVariant[] | null;
pricing.set(product.id, rows);
}
}),
);
return pricing;
}
@@ -0,0 +1,141 @@
import type {
RemoteQueryFunction,
SearchTypes,
} from "@medusajs/framework/types";
const RESOLVE_BATCH_SIZE = 200;
// Core emits either a single `{ id }` or a batch of them.
function payloadIds(data: unknown): string[] {
return (Array.isArray(data) ? data : [data])
.map((entry) => (entry as { id?: string } | undefined)?.id)
.filter((id): id is string => Boolean(id));
}
/**
* The products behind rows of another entity, read through `query.graph`.
* Deleted rows are soft-deleted, so they're still readable with `withDeleted`,
* which is how a deleted variant or category still leads back to its products.
*/
async function relatedProductIds(
query: RemoteQueryFunction,
entity: string,
fields: string[],
ids: string[],
pick: (row: Record<string, any>) => (string | null | undefined)[],
withDeleted: boolean,
): Promise<string[]> {
const { data } = await query.graph({
entity,
fields,
filters: { id: ids },
withDeleted,
});
return (data as Record<string, any>[])
.flatMap(pick)
.filter((id): id is string => Boolean(id));
}
/**
* Deleting a sales channel removes its product links, so the products can no
* longer be found through `query.graph`. The index still holds the channel on
* each document, which is what's searched here.
*/
async function productIdsInSalesChannels(
query: RemoteQueryFunction,
salesChannelIds: string[],
): Promise<string[]> {
const ids: string[] = [];
let skip = 0;
while (true) {
const { search_result: result } = await query.search({
entity: "product",
fields: ["id"],
filters: { sales_channel_ids: salesChannelIds },
pagination: { skip, take: RESOLVE_BATCH_SIZE },
});
ids.push(...result.hits.map((hit) => hit.id));
if (result.hits.length < RESOLVE_BATCH_SIZE) {
return ids;
}
skip += RESOLVE_BATCH_SIZE;
}
}
/**
* The products an event affects. A product event carries them directly; an
* event about a variant, option, tag, category or sales channel is mapped to
* the products behind it.
*/
export async function resolveProductIds(
event: { name: string; data: unknown },
{ container: { query } }: SearchTypes.SearchIngestionContext,
): Promise<string[]> {
const ids = payloadIds(event.data);
if (!ids.length) {
return [];
}
const [entity] = event.name.split(".");
const deleted = event.name.endsWith(".deleted");
switch (entity) {
case "product":
return ids;
case "product-variant":
return relatedProductIds(
query,
"product_variant",
["product_id"],
ids,
(row) => [row.product_id],
deleted,
);
case "product-option":
return relatedProductIds(
query,
"product_option",
["product_id"],
ids,
(row) => [row.product_id],
deleted,
);
case "product-option-value":
return relatedProductIds(
query,
"product_option_value",
["option.product_id"],
ids,
(row) => [row.option?.product_id],
deleted,
);
case "product-tag":
return relatedProductIds(
query,
"product_tag",
["products.id"],
ids,
(row) => (row.products ?? []).map((product: any) => product?.id),
deleted,
);
case "product-category":
return relatedProductIds(
query,
"product_category",
["products.id"],
ids,
(row) => (row.products ?? []).map((product: any) => product?.id),
deleted,
);
case "sales-channel":
return productIdsInSalesChannels(query, ids);
default:
return [];
}
}
+165
View File
@@ -0,0 +1,165 @@
import type { SearchTypes } from "@medusajs/framework/types";
import {
defineSearchIndex,
graphConsume,
graphSeed,
search,
} from "@medusajs/framework/utils";
import { ProductOptionRow, toOptionValues } from "./helpers/option-values";
import { loadPricing, priceFields, toProductPricing } from "./helpers/pricing";
import { resolveProductIds } from "./helpers/resolve-product-ids";
const PRODUCT_GRAPH_FIELDS = [
"id",
"title",
"description",
"handle",
"thumbnail",
"status",
"created_at",
"sales_channels.id",
"categories.name",
"tags.value",
"options.title",
"options.values.value",
];
type ProductRow = {
id: string;
title?: string | null;
description?: string | null;
handle?: string | null;
thumbnail?: string | null;
status?: string | null;
created_at?: string | Date | null;
deleted_at?: string | Date | null;
sales_channels?: ({ id?: string | null } | null)[] | null;
categories?: ({ name?: string | null } | null)[] | null;
tags?: ({ value?: string | null } | null)[] | null;
options?: ProductOptionRow[] | null;
};
const productFields = search.define({
id: search.keyword().filterable().retrievable(),
// Read by `/store/search` to scope the index; never returned to hits.
status: search.keyword().filterable().retrievable(false),
sales_channel_ids: search.keyword().array().filterable().retrievable(false),
title: search.text().searchable({ weight: 3 }).sortable().retrievable(),
description: search.text().searchable({ weight: 1 }),
handle: search.keyword().retrievable(),
// Returned on hits only: never filtered, sorted or faceted on.
thumbnail: search.keyword().retrievable(),
created_at: search.date().sortable().retrievable(),
category: search.keyword().array().filterable().facetable().retrievable(),
labels: search.keyword().array().filterable().facetable().retrievable(),
option_values: search
.keyword()
.array()
.searchable({ weight: 2 })
.filterable()
.facetable()
.retrievable(),
...priceFields,
});
type ProductDocument = SearchTypes.InferSearchDocumentType<
typeof productFields
>;
function toDocument(
product: ProductRow,
pricing: Parameters<typeof toProductPricing>[0],
): ProductDocument {
const category = (product.categories ?? [])
.map((productCategory) => productCategory?.name?.trim())
.filter((name): name is string => Boolean(name));
const labels = (product.tags ?? [])
.map((tag) => tag?.value?.trim())
.filter((value): value is string => Boolean(value));
const salesChannelIds = (product.sales_channels ?? [])
.map((salesChannel) => salesChannel?.id?.trim())
.filter((id): id is string => Boolean(id));
return {
id: product.id,
status: product.status ?? null,
sales_channel_ids: salesChannelIds,
title: product.title ?? null,
description: product.description ?? null,
handle: product.handle ?? null,
thumbnail: product.thumbnail ?? null,
created_at: product.created_at ?? null,
category,
labels,
option_values: toOptionValues(product.options),
...toProductPricing(pricing),
};
}
/**
* A row that returns no document leaves
* the index: the helpers turn it into a delete on `consume` and on the
* catch-up pass.
*/
const source = {
fields: PRODUCT_GRAPH_FIELDS,
transform: async (
rows: ProductRow[],
context: SearchTypes.SearchIngestionContext,
) => {
const pricing = await loadPricing(
rows.map((row) => row.id),
context,
);
return rows.map((row) => toDocument(row, pricing.get(row.id)));
},
};
/**
* Everything a document is built from, so a change to any of it re-indexes the
* products it touches. Only `product.deleted` removes documents: a deleted
* variant, option, tag or category means the product has to be re-read.
*
* TODO: price lists emit no events (create, update, delete, batch prices).
* TODO: price lists crossing their start or end date change prices silently.
* TODO: linkProductsToSalesChannelWorkflow emits nothing.
* TODO: batchLinkProductsToCategoryWorkflow emits nothing.
* TODO: upsertVariantPricesWorkflow emits nothing when called directly.
*/
const PRODUCT_EVENTS = [
"product.created",
"product.updated",
"product.deleted",
"product-variant.created",
"product-variant.updated",
"product-variant.deleted",
"product-option.created",
"product-option.updated",
"product-option.deleted",
"product-option-value.updated",
"product-option-value.deleted",
"product-tag.updated",
"product-tag.deleted",
"product-category.updated",
"product-category.deleted",
"sales-channel.deleted",
];
export default defineSearchIndex({
name: "product",
entity: "product",
primary_key: "id",
fields: productFields,
settings: {
typo_tolerance: { enabled: true },
},
events: PRODUCT_EVENTS,
consume: graphConsume<typeof productFields, ProductRow>({
...source,
resolve_ids: resolveProductIds,
is_delete: (event) => event.name === "product.deleted",
}),
seed: graphSeed<typeof productFields, ProductRow>(source),
});
@@ -0,0 +1,61 @@
# Custom subscribers
Subscribers handle events emitted in the Medusa application.
> Learn more about Subscribers in [this documentation](https://docs.medusajs.com/learn/fundamentals/events-and-subscribers).
The subscriber is created in a TypeScript or JavaScript file under the `src/subscribers` directory.
For example, create the file `src/subscribers/product-created.ts` with the following content:
```ts
import {
type SubscriberConfig,
} from "@medusajs/framework"
// subscriber function
export default async function productCreateHandler() {
console.log("A product was created")
}
// subscriber config
export const config: SubscriberConfig = {
event: "product.created",
}
```
A subscriber file must export:
- The subscriber function that is an asynchronous function executed whenever the associated event is triggered.
- A configuration object defining the event this subscriber is listening to.
## Subscriber Parameters
A subscriber receives an object having the following properties:
- `event`: An object holding the event's details. It has a `data` property, which is the event's data payload.
- `container`: The Medusa container. Use it to resolve modules' main services and other registered resources.
```ts
import type {
SubscriberArgs,
SubscriberConfig,
} from "@medusajs/framework"
export default async function productCreateHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const productId = data.id
const productModuleService = container.resolve("product")
const product = await productModuleService.retrieveProduct(productId)
console.log(`The product ${product.title} was created`)
}
export const config: SubscriberConfig = {
event: "product.created",
}
```
@@ -0,0 +1,74 @@
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { ContainerRegistrationKeys, OrderWorkflowEvents } from "@medusajs/framework/utils"
import { deliverWebhook, deriveEventId } from "../utils/fastify-webhook"
type OrderEventData = {
id?: string
order_id?: string
fulfillment_id?: string
no_notification?: boolean
}
const ORDER_SNAPSHOT_FIELDS = [
"id",
"display_id",
"status",
"email",
"customer_id",
"currency_code",
"total",
"created_at",
]
/**
* Notifica apps/api (POST {FASTIFY_WEBHOOK_URL}/webhooks/medusa) degli eventi ordine.
* Payload: { id, event, occurred_at, data } con data = dati originali dell'evento + snapshot
* dell'ordine (customer_id ed email servono a Fastify per collegare l'ordine all'utente).
* Gli eventi arrivano dopo il commit dell'ordine: un errore o un retry qui non blocca mai l'ordine.
* Limite noto: i retry vivono in memoria, se Medusa si riavvia durante il backoff l'invio si perde
* (resta il log). Se servirà garanzia di consegna: outbox su DB o job schedulato di riconciliazione.
*/
export default async function fastifyOrderWebhooks({
event,
container,
}: SubscriberArgs<OrderEventData>) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
const occurredAt = new Date().toISOString()
const orderId = event.data.order_id ?? event.data.id
try {
let order: Record<string, unknown> | null = null
if (orderId) {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data } = await query.graph({
entity: "order",
fields: ORDER_SNAPSHOT_FIELDS,
filters: { id: orderId },
})
order = (data[0] as Record<string, unknown> | undefined) ?? null
}
// Consegna (con retry/backoff) in background, senza await: il worker dell'event bus
// elabora un job alla volta e attendere i retry bloccherebbe tutti gli altri subscriber.
void deliverWebhook(
{
id: deriveEventId(event.name, event.data),
event: event.name,
occurred_at: occurredAt,
data: { ...event.data, order },
},
logger
)
} catch (e) {
logger.error(`[webhook ${event.name}] errore inatteso: ${(e as Error).message}`)
}
}
export const config: SubscriberConfig = {
event: [
OrderWorkflowEvents.PLACED,
OrderWorkflowEvents.CANCELED,
OrderWorkflowEvents.FULFILLMENT_CREATED,
],
context: { subscriberId: "fastify-order-webhooks" },
}
@@ -0,0 +1,102 @@
import { createHash, createHmac } from "crypto"
import type { Logger } from "@medusajs/framework/types"
export const SIGNATURE_HEADER = "x-signature"
export const WEBHOOK_PATH = "/webhooks/medusa"
const MAX_ATTEMPTS = 5
const REQUEST_TIMEOUT_MS = 5_000
const DEFAULT_RETRY_BASE_MS = 2_000
/** Namespace (UUID) per gli id evento derivati: costante, non cambiarlo o gli id cambiano. */
const EVENT_ID_NAMESPACE = "6f1d2c3a-8b7e-4f5a-9c0d-1e2f3a4b5c6d"
export type WebhookPayload = {
id: string
event: string
occurred_at: string
data: Record<string, unknown>
}
/**
* UUID v5 deterministico da nome evento + dati dell'evento.
* Lo stesso evento (es. order.placed dello stesso ordine) ha sempre lo stesso id, anche se il
* subscriber viene rieseguito: apps/api lo usa per l'idempotenza (tabella webhook_events).
*/
export function deriveEventId(event: string, eventData: unknown): string {
const ns = Buffer.from(EVENT_ID_NAMESPACE.replace(/-/g, ""), "hex")
const hash = createHash("sha1")
.update(ns)
.update(`${event}:${JSON.stringify(eventData)}`)
.digest()
hash[6] = (hash[6] & 0x0f) | 0x50 // versione 5
hash[8] = (hash[8] & 0x3f) | 0x80 // variante RFC 4122
const hex = hash.subarray(0, 16).toString("hex")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** Firma: HMAC-SHA256 del body grezzo (stringa JSON inviata) con WEBHOOK_SECRET, in esadecimale. */
export function signBody(body: string, secret: string): string {
return createHmac("sha256", secret).update(body).digest("hex")
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Invia il payload a {FASTIFY_WEBHOOK_URL}/webhooks/medusa firmato in x-signature.
* Fino a 5 tentativi con backoff esponenziale (base WEBHOOK_RETRY_BASE_MS: 2s, 4s, 8s, 16s)
* se la risposta non è 2xx o la richiesta fallisce. Non lancia mai: gli errori finiscono nei log
* (l'ultimo con il payload completo, per un eventuale reinvio manuale).
*/
export async function deliverWebhook(
payload: WebhookPayload,
logger: Logger
): Promise<boolean> {
const baseUrl = process.env.FASTIFY_WEBHOOK_URL
const secret = process.env.WEBHOOK_SECRET
const tag = `[webhook ${payload.event} ${payload.id}]`
if (!baseUrl || !secret) {
logger.warn(`${tag} FASTIFY_WEBHOOK_URL o WEBHOOK_SECRET non impostati: invio saltato`)
return false
}
const url = `${baseUrl.replace(/\/+$/, "")}${WEBHOOK_PATH}`
const body = JSON.stringify(payload)
const signature = signBody(body, secret)
const retryBaseMs = Number(process.env.WEBHOOK_RETRY_BASE_MS) || DEFAULT_RETRY_BASE_MS
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let outcome: string
try {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", [SIGNATURE_HEADER]: signature },
body,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (res.ok) {
logger.info(`${tag} consegnato (tentativo ${attempt}/${MAX_ATTEMPTS}, HTTP ${res.status})`)
return true
}
outcome = `HTTP ${res.status}`
} catch (e) {
const err = e as Error & { cause?: { code?: string } }
outcome = err.cause?.code ?? err.name ?? err.message
}
if (attempt < MAX_ATTEMPTS) {
const delay = retryBaseMs * 2 ** (attempt - 1)
logger.warn(
`${tag} tentativo ${attempt}/${MAX_ATTEMPTS} fallito (${outcome}), nuovo tentativo tra ${delay} ms`
)
await sleep(delay)
} else {
logger.error(
`${tag} tentativo ${attempt}/${MAX_ATTEMPTS} fallito (${outcome}): consegna abbandonata. Payload: ${body}`
)
}
}
return false
}
@@ -0,0 +1,37 @@
import { createHash, timingSafeEqual } from "crypto"
import type {
MedusaNextFunction,
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
export const INTERNAL_SECRET_HEADER = "x-internal-secret"
const digest = (value: string) => createHash("sha256").update(value).digest()
/**
* Protegge le route /internal/*: chiamabili solo da apps/api (Fastify) con il segreto condiviso
* INTERNAL_API_SECRET nell'header x-internal-secret.
* Il confronto avviene sui digest SHA-256 (stessa lunghezza) con timingSafeEqual,
* così né il contenuto né la lunghezza del segreto trapelano dai tempi di risposta.
* Se INTERNAL_API_SECRET non è configurato, tutte le richieste vengono rifiutate.
*/
export function requireInternalSecret(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
const expected = process.env.INTERNAL_API_SECRET
const provided = req.headers[INTERNAL_SECRET_HEADER]
if (
!expected ||
typeof provided !== "string" ||
!timingSafeEqual(digest(provided), digest(expected))
) {
res.status(401).json({ type: "unauthorized", message: "Unauthorized" })
return
}
next()
}
@@ -0,0 +1,81 @@
# Custom Workflows
A workflow is a series of queries and actions that complete a task.
The workflow is created in a TypeScript or JavaScript file under the `src/workflows` directory.
> Learn more about workflows in [this documentation](https://docs.medusajs.com/learn/fundamentals/workflows).
For example:
```ts
import {
createStep,
createWorkflow,
WorkflowResponse,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
const step1 = createStep("step-1", async () => {
return new StepResponse(`Hello from step one!`)
})
type WorkflowInput = {
name: string
}
const step2 = createStep(
"step-2",
async ({ name }: WorkflowInput) => {
return new StepResponse(`Hello ${name} from step two!`)
}
)
type WorkflowOutput = {
message1: string
message2: string
}
const helloWorldWorkflow = createWorkflow(
"hello-world",
(input: WorkflowInput) => {
const greeting1 = step1()
const greeting2 = step2(input)
return new WorkflowResponse({
message1: greeting1,
message2: greeting2
})
}
)
export default helloWorldWorkflow
```
## Execute Workflow
You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers.
For example, to execute the workflow in an API route:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework"
import myWorkflow from "../../../workflows/hello-world"
export async function GET(
req: MedusaRequest,
res: MedusaResponse
) {
const { result } = await myWorkflow(req.scope)
.run({
input: {
name: req.query.name as string,
},
})
res.send(result)
}
```
@@ -0,0 +1,79 @@
import {
ChangeActionType,
MedusaError,
Modules,
OrderChangeStatus,
} from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
StepResponse,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
acceptOrderTransferWorkflow,
requestOrderTransferWorkflow,
} from "@medusajs/medusa/core-flows"
export type ClaimGuestOrderInput = {
order_id: string
customer_id: string
}
/** Identificativo registrato come autore della richiesta di trasferimento. */
const INTERNAL_ACTOR = "internal-api"
/** Legge il token della richiesta di trasferimento appena creata per l'ordine. */
export const getOrderTransferTokenStep = createStep(
"get-order-transfer-token",
async (input: { order_id: string }, { container }) => {
const orderModule = container.resolve(Modules.ORDER)
const changes = await orderModule.listOrderChanges(
{ order_id: input.order_id, status: OrderChangeStatus.REQUESTED },
{ relations: ["actions"], order: { created_at: "DESC" } }
)
const action = changes
.filter((change) => change.change_type === "transfer")
.flatMap((change) => change.actions ?? [])
.find((a) => a.action === ChangeActionType.TRANSFER_CUSTOMER)
const token = action?.details?.token
if (typeof token !== "string") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
`Token di trasferimento non trovato per l'ordine ${input.order_id}`
)
}
return new StepResponse(token)
}
)
/**
* Collega un ordine guest a un customer registrato usando il flusso ufficiale di
* trasferimento ordini: request (genera il token) + accept (applica il cambio customer).
* Il token non esce mai da Medusa: la verifica di possesso dell'email la fa apps/api.
*/
export const claimGuestOrderWorkflow = createWorkflow(
"claim-guest-order",
(input: ClaimGuestOrderInput) => {
const requestInput = transform({ input }, ({ input }) => ({
order_id: input.order_id,
customer_id: input.customer_id,
logged_in_user: INTERNAL_ACTOR,
description: "Ordine guest collegato all'account alla registrazione",
}))
requestOrderTransferWorkflow.runAsStep({ input: requestInput })
const token = getOrderTransferTokenStep({ order_id: input.order_id })
const acceptInput = transform({ input, token }, ({ input, token }) => ({
order_id: input.order_id,
token,
}))
acceptOrderTransferWorkflow.runAsStep({ input: acceptInput })
return new WorkflowResponse({ order_id: input.order_id })
}
)
@@ -0,0 +1,78 @@
import { MedusaError, Modules } from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
StepResponse,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { transferCartCustomerWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"
export type SetCartGuestEmailInput = { cart_id: string; email: string }
/**
* Trova o crea il customer GUEST (has_account = false) per l'email.
* Non usa findOrCreateCustomerStep di Medusa: per un carrello senza customer quello cerca per email
* senza filtrare has_account e, se esiste un account registrato con quell'email, gli associa il
* carrello (con i suoi prezzi di gruppo, es. listino installatori). Qui un guest resta sempre guest.
*/
export const findOrCreateGuestCustomerStep = createStep(
"find-or-create-guest-customer",
async (input: { email: string }, { container }) => {
const customerModule = container.resolve(Modules.CUSTOMER)
const [existing] = await customerModule.listCustomers({ email: input.email, has_account: false })
if (existing) return new StepResponse(existing, null)
const created = await customerModule.createCustomers({ email: input.email, has_account: false })
return new StepResponse(created, created.id)
},
async (createdId, { container }) => {
if (!createdId) return
await container.resolve(Modules.CUSTOMER).deleteCustomers([createdId])
}
)
/** Rifiuta i carrelli già associati a un account registrato: un guest non può modificarli. */
const ensureCartNotOwnedByAccountStep = createStep(
"ensure-cart-not-owned-by-account",
async (input: { cart: { id: string; customer?: { has_account?: boolean } | null } | undefined }) => {
if (!input.cart) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Carrello non trovato")
}
if (input.cart.customer?.has_account) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Il carrello è associato a un account registrato"
)
}
return new StepResponse(undefined)
}
)
/**
* Imposta l'email di un carrello guest: customer guest per quell'email + transferCartCustomerWorkflow
* (aggiorna customer_id ed email e riprezza le righe con i prezzi standard).
*/
export const setCartGuestEmailWorkflow = createWorkflow(
"set-cart-guest-email",
(input: SetCartGuestEmailInput) => {
const { data: carts } = useQueryGraphStep({
entity: "cart",
fields: ["id", "customer.has_account"],
filters: { id: input.cart_id },
})
const cart = transform({ carts }, ({ carts }) => carts[0])
ensureCartNotOwnedByAccountStep({ cart })
const customer = findOrCreateGuestCustomerStep({ email: input.email })
transferCartCustomerWorkflow.runAsStep({
input: transform({ input, customer }, ({ input, customer }) => ({
id: input.cart_id,
customer_id: customer.id,
})),
})
return new WorkflowResponse(customer)
}
)
@@ -0,0 +1,67 @@
import { MedusaError, Modules } from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
StepResponse,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
linkCustomersToCustomerGroupWorkflow,
useQueryGraphStep,
} from "@medusajs/medusa/core-flows"
/** Codice del gruppo installatori (customer_group.metadata.code), creato da seed-installatori.ts. */
export const INSTALLER_GROUP_CODE = "installer"
export type SetInstallerGroupInput = {
customer_id: string
action: "add" | "remove"
}
/** Trova il customer group installatori per metadata.code (l'id cambia tra ambienti). */
export const findInstallerGroupStep = createStep(
"find-installer-group",
async (_: void, { container }) => {
const customerModule = container.resolve(Modules.CUSTOMER)
const groups = await customerModule.listCustomerGroups(
{},
{ select: ["id", "name", "metadata"] }
)
const group = groups.find((g) => g.metadata?.code === INSTALLER_GROUP_CODE)
if (!group) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Customer group con metadata.code "${INSTALLER_GROUP_CODE}" non trovato: eseguire seed-installatori.ts`
)
}
return new StepResponse({ id: group.id, name: group.name })
}
)
/** Aggiunge o rimuove un customer dal gruppo installatori (e quindi dal listino riservato). */
export const setInstallerGroupWorkflow = createWorkflow(
"set-installer-group",
(input: SetInstallerGroupInput) => {
// Fallisce con NOT_FOUND se il customer non esiste
useQueryGraphStep({
entity: "customer",
fields: ["id"],
filters: { id: input.customer_id },
options: { throwIfKeyNotFound: true },
})
const group = findInstallerGroupStep()
const linkInput = transform({ input, group }, ({ input, group }) => ({
id: group.id,
[input.action]: [input.customer_id],
}))
linkCustomersToCustomerGroupWorkflow.runAsStep({ input: linkInput })
return new WorkflowResponse(group)
}
)
@@ -0,0 +1,79 @@
import { Modules } from "@medusajs/framework/utils"
import {
createStep,
createWorkflow,
StepResponse,
transform,
when,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { createCustomersWorkflow } from "@medusajs/medusa/core-flows"
import type { CustomerDTO } from "@medusajs/framework/types"
export type UpsertAccountCustomerInput = {
email: string
first_name: string
last_name: string
company_name?: string | null
}
export type UpsertAccountCustomerOutput = {
customer: CustomerDTO
created: boolean
}
/**
* Cerca il customer "con account" (has_account = true) per email.
* I customer guest creati dal checkout hanno has_account = false e restano distinti:
* Medusa ammette un customer per coppia (email, has_account).
*/
export const findAccountCustomerStep = createStep(
"find-account-customer",
async (input: { email: string }, { container }) => {
const customerModule = container.resolve(Modules.CUSTOMER)
const [customer] = await customerModule.listCustomers({
email: input.email,
has_account: true,
})
return new StepResponse(customer ?? null)
}
)
/**
* Crea (o restituisce se esiste) il customer Medusa corrispondente a un utente di apps/api.
* Il customer è marcato has_account = true: l'autenticazione vive in Fastify, ma Medusa deve
* considerarlo un account registrato (requisito per il trasferimento degli ordini guest).
*/
export const upsertAccountCustomerWorkflow = createWorkflow(
"upsert-account-customer",
(input: UpsertAccountCustomerInput) => {
const existing = findAccountCustomerStep({ email: input.email })
const created = when({ existing }, ({ existing }) => !existing).then(() => {
const customersData = transform({ input }, ({ input }) => ({
customersData: [
{
email: input.email,
first_name: input.first_name,
last_name: input.last_name,
company_name: input.company_name ?? null,
has_account: true,
},
],
}))
return createCustomersWorkflow.runAsStep({ input: customersData })
})
const result = transform(
{ existing, created },
({ existing, created }): UpsertAccountCustomerOutput => ({
customer: (existing ?? created?.[0]) as CustomerDTO,
created: !existing,
})
)
return new WorkflowResponse(result)
}
)