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,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)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user