Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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/)
|
||||
@@ -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>"
|
||||
}
|
||||
}
|
||||
@@ -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`))
|
||||
@@ -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()
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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.*",
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user