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
+40
View File
@@ -0,0 +1,40 @@
# Backend Medusa (medusa/apps/backend). Copia in `.env` e compila. Non committare `.env`.
# Postgres dedicato a Medusa: credenziali MEDUSA_DB_* del .env di root
DATABASE_URL=postgres://iti_medusa:change-me@localhost:5432/iti_medusa
REDIS_URL=redis://localhost:6379
# CORS (liste separate da virgola). Il dashboard admin in dev gira su :9000/app;
# lo storefront non chiama Medusa direttamente (passa da apps/api), STORE_CORS resta minimale.
STORE_CORS=http://localhost:4200
ADMIN_CORS=http://localhost:9000
AUTH_CORS=http://localhost:9000
# Segreti: genera valori casuali lunghi, es.
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=change-me
COOKIE_SECRET=change-me
AUTH_MFA_ENCRYPTION_KEY=change-me
# Storage file locale. Relativo alla cartella del backend o assoluto (in produzione: volume Docker).
FILE_UPLOAD_DIR=static
FILE_BACKEND_URL=http://localhost:9000/static
MEDUSA_ADMIN_ONBOARDING_TYPE=default
# Stripe (chiavi di TEST in sviluppo: https://dashboard.stripe.com/test/apikeys).
# Senza STRIPE_API_KEY il provider non viene registrato e seed-base.ts si ferma.
# Webhook da registrare in Stripe: {URL pubblico Medusa}/hooks/payment/stripe_stripe
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
# Segreto condiviso con apps/api per le route /internal/* (header x-internal-secret).
# Stesso valore di INTERNAL_API_SECRET in apps/api/.env. Senza, le route rispondono sempre 401.
INTERNAL_API_SECRET=
# Webhook verso apps/api: POST {FASTIFY_WEBHOOK_URL}/webhooks/medusa firmato HMAC-SHA256 (header x-signature).
# WEBHOOK_SECRET deve coincidere con quello di apps/api/.env. Senza una delle due variabili l'invio viene saltato.
FASTIFY_WEBHOOK_URL=http://localhost:3000
WEBHOOK_SECRET=
# Opzionale: base del backoff tra i tentativi (ms, default 2000 -> 2s, 4s, 8s, 16s)
# WEBHOOK_RETRY_BASE_MS=2000
+28
View File
@@ -0,0 +1,28 @@
/dist
.env
.DS_Store
/uploads
/node_modules
yarn-error.log
.idea
coverage
!src/**
./tsconfig.tsbuildinfo
medusa-db.sql
build
.cache
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.medusa
# Upload del modulo file-local (FILE_UPLOAD_DIR)
/static
+62
View File
@@ -0,0 +1,62 @@
<p align="center">
<a href="https://www.medusajs.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
</picture>
</a>
</p>
<h1 align="center">
Medusa
</h1>
<h4 align="center">
<a href="https://docs.medusajs.com">Documentation</a> |
<a href="https://www.medusajs.com">Website</a>
</h4>
<p align="center">
Building blocks for digital commerce
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Compatibility
This starter is compatible with versions >= 2 of `@medusajs/medusa`.
## Getting Started
Visit the [Quickstart Guide](https://docs.medusajs.com/learn/installation) to set up a server.
Visit the [Docs](https://docs.medusajs.com/learn/installation#get-started) to learn more about our system requirements.
## What is Medusa
Medusa is a set of commerce modules and tools that allow you to build rich, reliable, and performant commerce applications without reinventing core commerce logic. The modules can be customized and used to build advanced ecommerce stores, marketplaces, or any product that needs foundational commerce primitives. All modules are open-source and freely available on npm.
Learn more about [Medusa’s architecture](https://docs.medusajs.com/learn/introduction/architecture) and [commerce modules](https://docs.medusajs.com/learn/fundamentals/modules/commerce-modules) in the Docs.
## Community & Contributions
The community and core team are available in [GitHub Discussions](https://github.com/medusajs/medusa/discussions), where you can ask for support, discuss roadmap, and share ideas.
Join our [Discord server](https://discord.com/invite/medusajs) to meet other community members.
## Other channels
- [GitHub Issues](https://github.com/medusajs/medusa/issues)
- [Twitter](https://twitter.com/medusajs)
- [LinkedIn](https://www.linkedin.com/company/medusajs)
- [Medusa Blog](https://medusajs.com/blog/)
+4
View File
@@ -0,0 +1,4 @@
import { defineConfig } from "eslint/config"
import medusa from "@medusajs/eslint-plugin"
export default defineConfig([...medusa.configs.recommended])
@@ -0,0 +1,5 @@
{
"dev": {
"baseUrl": "http://localhost:9000"
}
}
@@ -0,0 +1,8 @@
{
"dev": {
"internalSecret": "<INTERNAL_API_SECRET di medusa/apps/backend/.env>",
"publishableKey": "<MEDUSA_PUBLISHABLE_KEY stampata da seed-base.ts>",
"adminEmail": "admin@iti.local",
"adminPassword": "<password utente admin Medusa>"
}
}
+183
View File
@@ -0,0 +1,183 @@
### Test manuali delle route /internal/* (formato HTTP Client JetBrains/WebStorm).
### Variabili: http-client.env.json (pubbliche) + http-client.private.env.json (segreti, non committato,
### copiare da http-client.private.env.example.json). Ambiente: "dev".
### Prerequisiti: infra su, Medusa avviato (npm run dev in medusa/), seed-base.ts e seed-installatori.ts eseguiti.
### Eseguire le richieste in ordine: ognuna salva le variabili usate dalle successive.
### 0a. Login admin Medusa (serve solo per preparare l'ordine guest di test)
POST {{baseUrl}}/auth/user/emailpass
Content-Type: application/json
{ "email": "{{adminEmail}}", "password": "{{adminPassword}}" }
> {%
client.global.set("adminToken", response.body.token)
client.global.set("testEmail", "Test.Claim" + Date.now() + "@Example.com")
%}
### 0b. Regione Italia
GET {{baseUrl}}/store/regions
x-publishable-api-key: {{publishableKey}}
> {% client.global.set("regionId", response.body.regions[0].id) %}
### 0c. Variante della pompa di calore (prezzo standard 5490, installatori 4650)
GET {{baseUrl}}/store/products?handle=pompa-di-calore-aria-acqua-8kw
x-publishable-api-key: {{publishableKey}}
> {% client.global.set("variantId", response.body.products[0].variants[0].id) %}
### 0d. Sales channel Web
GET {{baseUrl}}/admin/sales-channels?name=Web
Authorization: Bearer {{adminToken}}
> {% client.global.set("salesChannelId", response.body.sales_channels[0].id) %}
### 1a. Senza header -> 401
POST {{baseUrl}}/internal/customers
Content-Type: application/json
{ "email": "a@example.com", "first_name": "A", "last_name": "B" }
> {% client.test("401 senza segreto", () => client.assert(response.status === 401)) %}
### 1b. Segreto errato -> 401
GET {{baseUrl}}/internal/customers/cus_x/orders
x-internal-secret: sbagliato
> {% client.test("401 segreto errato", () => client.assert(response.status === 401)) %}
### 2a. Ordine guest di test: draft order con email (customer guest has_account=false)...
POST {{baseUrl}}/admin/draft-orders
Authorization: Bearer {{adminToken}}
Content-Type: application/json
{
"email": "{{testEmail}}",
"region_id": "{{regionId}}",
"sales_channel_id": "{{salesChannelId}}",
"items": [{ "variant_id": "{{variantId}}", "quantity": 1 }],
"shipping_address": {
"first_name": "Test", "last_name": "Claim", "address_1": "Via Roma 1",
"city": "Milano", "postal_code": "20100", "country_code": "it"
}
}
> {% client.global.set("guestOrderId", response.body.draft_order.id) %}
### 2b. ...convertito in ordine
POST {{baseUrl}}/admin/draft-orders/{{guestOrderId}}/convert-to-order
Authorization: Bearer {{adminToken}}
### 3a. Creazione customer -> 201, created=true (email normalizzata in minuscolo)
POST {{baseUrl}}/internal/customers
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "email": "{{testEmail}}", "first_name": "Test", "last_name": "Claim", "company_name": "Test srl" }
> {%
client.test("customer creato", () => {
client.assert(response.status === 201)
client.assert(response.body.created === true)
client.assert(response.body.customer.has_account === true)
})
client.global.set("customerId", response.body.customer.id)
%}
### 3b. Stessa email -> 200, created=false, stesso id (idempotente)
POST {{baseUrl}}/internal/customers
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "email": "{{testEmail}}", "first_name": "Test", "last_name": "Claim" }
> {%
client.test("idempotente", () => {
client.assert(response.status === 200)
client.assert(response.body.created === false)
client.assert(response.body.customer.id === client.global.get("customerId"))
})
%}
### 4a. Carrello guest con la pompa di calore
POST {{baseUrl}}/store/carts
x-publishable-api-key: {{publishableKey}}
Content-Type: application/json
{ "region_id": "{{regionId}}" }
> {% client.global.set("cartId", response.body.cart.id) %}
### 4b. Riga -> prezzo standard 5490
POST {{baseUrl}}/store/carts/{{cartId}}/line-items
x-publishable-api-key: {{publishableKey}}
Content-Type: application/json
{ "variant_id": "{{variantId}}", "quantity": 1 }
> {% client.test("prezzo standard", () => client.assert(response.body.cart.items[0].unit_price === 5490)) %}
### 5. Customer nel gruppo installatori
POST {{baseUrl}}/internal/customers/{{customerId}}/groups
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "action": "add" }
> {% client.test("membro", () => client.assert(response.body.member === true)) %}
### 6. Associa il carrello al customer -> riprezzato col listino installatori (4650)
POST {{baseUrl}}/internal/carts/{{cartId}}/customer
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "customer_id": "{{customerId}}" }
> {%
client.test("carrello associato e listino applicato", () => {
client.assert(response.body.cart.customer_id === client.global.get("customerId"))
client.assert(response.body.cart.items[0].unit_price === 4650)
})
%}
### 7a. Claim ordini guest con la stessa email -> l'ordine 2b passa al customer
POST {{baseUrl}}/internal/orders/claim
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "customer_id": "{{customerId}}", "email": "{{testEmail}}" }
> {%
client.test("ordine collegato", () => {
client.assert(response.body.claimed.includes(client.global.get("guestOrderId")))
})
%}
### 7b. Claim ripetuto -> niente da collegare
POST {{baseUrl}}/internal/orders/claim
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "customer_id": "{{customerId}}", "email": "{{testEmail}}" }
> {% client.test("idempotente", () => client.assert(response.body.claimed.length === 0)) %}
### 8. Ordini del customer -> contiene l'ordine collegato
GET {{baseUrl}}/internal/customers/{{customerId}}/orders?limit=20&offset=0
x-internal-secret: {{internalSecret}}
> {%
client.test("ordine in elenco", () => {
client.assert(response.body.orders.some((o) => o.id === client.global.get("guestOrderId")))
})
%}
### 9. Rimozione dal gruppo installatori
POST {{baseUrl}}/internal/customers/{{customerId}}/groups
x-internal-secret: {{internalSecret}}
Content-Type: application/json
{ "action": "remove" }
> {% client.test("non più membro", () => client.assert(response.body.member === false)) %}
@@ -0,0 +1,41 @@
// Receiver di test per i webhook Medusa -> Fastify, finché apps/api non implementa /webhooks/medusa.
// Uso (in medusa/apps/backend): node http/webhook-receiver.mjs [porta]
// Legge WEBHOOK_SECRET da .env, verifica la firma x-signature e stampa ogni richiesta.
// Con FAIL=3 risponde 503 alle prime 3 richieste (per vedere i retry).
import { createHmac, timingSafeEqual } from "node:crypto"
import { readFileSync } from "node:fs"
import { createServer } from "node:http"
const port = Number(process.argv[2] ?? 3999)
const secret =
process.env.WEBHOOK_SECRET ??
readFileSync(".env", "utf8").match(/^WEBHOOK_SECRET=(.*)$/m)?.[1]?.trim()
let failuresLeft = Number(process.env.FAIL ?? 0)
if (!secret) {
console.error("WEBHOOK_SECRET non trovato (env o .env)")
process.exit(1)
}
createServer((req, res) => {
let body = ""
req.on("data", (chunk) => (body += chunk))
req.on("end", () => {
const expected = createHmac("sha256", secret).update(body).digest("hex")
const got = String(req.headers["x-signature"] ?? "")
const valid =
got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected))
const status = failuresLeft > 0 ? 503 : valid ? 200 : 401
if (failuresLeft > 0) failuresLeft--
let payload = body
try {
const p = JSON.parse(body)
payload = `${p.event} id=${p.id} occurred_at=${p.occurred_at} data=${JSON.stringify(p.data)}`
} catch {}
console.log(
`${new Date().toISOString()} ${req.method} ${req.url} firma=${valid ? "OK" : "NON VALIDA"} -> ${status}\n ${payload}`
)
res.writeHead(status, { "content-type": "application/json" }).end('{"ok":true}')
})
}).listen(port, () => console.log(`webhook receiver su http://localhost:${port}/webhooks/medusa`))
+24
View File
@@ -0,0 +1,24 @@
// Uncomment this file to enable instrumentation and observability using OpenTelemetry
// Refer to the docs for installation instructions: https://docs.medusajs.com/learn/debugging-and-testing/instrumentation
// import { registerOtel } from "@medusajs/medusa"
// // If using an exporter other than Zipkin, require it here.
// import { ZipkinExporter } from "@opentelemetry/exporter-zipkin"
// // If using an exporter other than Zipkin, initialize it here.
// const exporter = new ZipkinExporter({
// serviceName: 'my-medusa-project',
// })
// export function register() {
// registerOtel({
// serviceName: 'medusajs',
// // pass exporter
// exporter,
// instrument: {
// http: true,
// workflows: true,
// query: true
// },
// })
// }
@@ -0,0 +1,3 @@
const { MetadataStorage } = require("@medusajs/framework/mikro-orm/core")
MetadataStorage.clear()
+27
View File
@@ -0,0 +1,27 @@
const { loadEnv } = require("@medusajs/utils");
loadEnv("test", process.cwd());
module.exports = {
transform: {
"^.+\\.[jt]s$": [
"@swc/jest",
{
jsc: {
parser: { syntax: "typescript", decorators: true },
},
},
],
},
testEnvironment: "node",
moduleFileExtensions: ["js", "ts", "json"],
modulePathIgnorePatterns: ["dist/", "<rootDir>/.medusa/"],
setupFiles: ["./integration-tests/setup.js"],
};
if (process.env.TEST_TYPE === "integration:http") {
module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"];
} else if (process.env.TEST_TYPE === "integration:modules") {
module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"];
} else if (process.env.TEST_TYPE === "unit") {
module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"];
}
+117
View File
@@ -0,0 +1,117 @@
import { loadEnv, defineConfig } from '@medusajs/framework/utils'
loadEnv(process.env.NODE_ENV || 'development', process.cwd())
const redisUrl = process.env.REDIS_URL
// Stripe: il provider lancia un errore all'avvio se manca apiKey, quindi si registra solo se configurato.
// Provider ID risultante: pp_stripe_stripe. Webhook: {MEDUSA_BACKEND_URL}/hooks/payment/stripe_stripe
const stripeApiKey = process.env.STRIPE_API_KEY
if (!stripeApiKey) {
console.warn('[medusa-config] STRIPE_API_KEY non impostata: provider di pagamento Stripe disabilitato')
}
// Produzione: senza JWT_SECRET/COOKIE_SECRET Medusa userebbe il default "supersecret" (token del dashboard
// falsificabili). Si rifiuta di partire con segreti mancanti o corti.
if (process.env.NODE_ENV === 'production') {
const weak = ['JWT_SECRET', 'COOKIE_SECRET', 'INTERNAL_API_SECRET', 'WEBHOOK_SECRET'].filter(
(name) => (process.env[name]?.length ?? 0) < 32
)
if (weak.length) {
throw new Error(`[medusa-config] segreti mancanti o più corti di 32 caratteri: ${weak.join(', ')}`)
}
if (stripeApiKey && !process.env.STRIPE_WEBHOOK_SECRET) {
console.warn('[medusa-config] STRIPE_WEBHOOK_SECRET non impostata: i webhook Stripe verranno rifiutati (firma non verificabile)')
}
}
module.exports = defineConfig({
projectConfig: {
// Postgres dedicato a Medusa (database iti_medusa, vedi docker-compose.dev.yml)
databaseUrl: process.env.DATABASE_URL,
redisUrl,
http: {
storeCors: process.env.STORE_CORS!,
adminCors: process.env.ADMIN_CORS!,
authCors: process.env.AUTH_CORS!,
jwtSecret: process.env.JWT_SECRET,
cookieSecret: process.env.COOKIE_SECRET,
},
},
modules: [
// File: storage locale su disco. Medusa serve sempre <backend>/static su /static:
// se FILE_UPLOAD_DIR punta altrove (es. volume Docker in produzione) va servita da Caddy
// e FILE_BACKEND_URL deve puntare a quell'URL pubblico.
{
resolve: '@medusajs/medusa/file',
options: {
providers: [
{
resolve: '@medusajs/medusa/file-local',
id: 'local',
options: {
upload_dir: process.env.FILE_UPLOAD_DIR || 'static',
backend_url: process.env.FILE_BACKEND_URL || 'http://localhost:9000/static',
},
},
],
},
},
...(stripeApiKey
? [
{
resolve: '@medusajs/medusa/payment',
options: {
providers: [
{
resolve: '@medusajs/medusa/payment-stripe',
id: 'stripe',
options: {
apiKey: stripeApiKey,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
// Cattura automatica all'autorizzazione: niente capture manuale dal dashboard
capture: true,
},
},
],
},
},
]
: []),
// Moduli Redis (configurazione consigliata dalla guida di deployment Medusa)
{
resolve: '@medusajs/medusa/caching',
options: {
providers: [
{
resolve: '@medusajs/caching-redis',
id: 'caching-redis',
is_default: true,
options: { redisUrl },
},
],
},
},
{
resolve: '@medusajs/medusa/event-bus-redis',
options: { redisUrl },
},
{
resolve: '@medusajs/medusa/workflow-engine-redis',
options: { redis: { redisUrl } },
},
{
resolve: '@medusajs/medusa/locking',
options: {
providers: [
{
resolve: '@medusajs/medusa/locking-redis',
id: 'locking-redis',
is_default: true,
options: { redisUrl },
},
],
},
},
],
})
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@dtc/backend",
"version": "0.0.1",
"description": "A starter for Medusa projects.",
"author": "Medusa (https://medusajs.com)",
"license": "MIT",
"keywords": [
"sqlite",
"postgres",
"typescript",
"ecommerce",
"headless",
"medusa"
],
"scripts": {
"build": "medusa build",
"start": "medusa start",
"dev": "medusa develop",
"lint": "medusa lint",
"test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
"test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
"test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"
},
"dependencies": {
"@medusajs/admin-sdk": "2.21.2",
"@medusajs/admin-shared": "2.21.2",
"@medusajs/caching": "2.21.2",
"@medusajs/cli": "2.21.2",
"@medusajs/dashboard": "2.21.2",
"@medusajs/draft-order": "2.21.2",
"@medusajs/framework": "2.21.2",
"@medusajs/medusa": "2.21.2",
"@medusajs/ui": "4.2.6",
"@tanstack/react-query": "5.64.2",
"react-i18next": "13.5.0",
"react-router-dom": "7.18.2",
"zod": "4.2.0"
},
"devDependencies": {
"@medusajs/test-utils": "2.21.2",
"@swc/core": "^1.7.28",
"@swc/jest": "^0.2.36",
"@types/jest": "^29.5.13",
"@types/node": "^20.12.11",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.2.25",
"jest": "^29.7.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"ts-node": "^10.9.2",
"typescript": "^5.6.2",
"vite": "^7.3.6",
"yalc": "^1.0.0-pre.53"
},
"engines": {
"node": ">=22.22.0"
},
"packageManager": "npm@11.12.0"
}
+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)
}
)
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2021",
"esModuleInterop": true,
"module": "Node16",
"moduleResolution": "Node16",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": false,
"sourceMap": false,
"inlineSourceMap": true,
"outDir": "./.medusa/server",
"rootDir": "./",
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"checkJs": false,
"strictNullChecks": true
},
"ts-node": {
"swc": true
},
"include": [
"**/*",
".medusa/types/*"
],
"exclude": [
"node_modules",
".medusa/server",
".medusa/admin",
".cache",
"eslint.config.*",
]
}