Initial commit
This commit is contained in:
commit
4bedad944a
81 changed files with 29470 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.git
|
||||||
|
.medusa
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.cache
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
12
.env.template
Normal file
12
.env.template
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
STORE_CORS=http://localhost:8000,https://docs.medusajs.com
|
||||||
|
ADMIN_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com
|
||||||
|
AUTH_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
JWT_SECRET=supersecret
|
||||||
|
COOKIE_SECRET=supersecret
|
||||||
|
DATABASE_URL=
|
||||||
|
DB_NAME=medusa-v2
|
||||||
|
|
||||||
|
# Sanity CMS
|
||||||
|
SANITY_PROJECT_ID=
|
||||||
|
SANITY_DATASET=
|
||||||
0
.env.test
Normal file
0
.env.test
Normal file
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.template
|
||||||
|
!.env.test
|
||||||
|
.DS_Store
|
||||||
|
/uploads
|
||||||
|
/node_modules
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
||||||
|
coverage
|
||||||
|
|
||||||
|
!src/**
|
||||||
|
src/.DS_Store
|
||||||
|
|
||||||
|
./tsconfig.tsbuildinfo
|
||||||
|
medusa-db.sql
|
||||||
|
build
|
||||||
|
.cache
|
||||||
|
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
.medusa
|
||||||
5
.npmrc
Normal file
5
.npmrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# These are pnpm-specific directives. Uncomment if you switch to pnpm.
|
||||||
|
# public-hoist-pattern[]=*@medusajs/*
|
||||||
|
# public-hoist-pattern[]=@tanstack/react-query
|
||||||
|
# public-hoist-pattern[]=react-i18next
|
||||||
|
# public-hoist-pattern[]=react-router-dom
|
||||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{
|
||||||
|
}
|
||||||
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||||
341
CLAUDE.md
Normal file
341
CLAUDE.md
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
# Medusa Core
|
||||||
|
|
||||||
|
Open-source commerce platform. TypeScript monorepo with 30+ modular commerce packages.
|
||||||
|
|
||||||
|
### 1. Codebase Structure
|
||||||
|
|
||||||
|
**Monorepo Organization:**
|
||||||
|
```
|
||||||
|
/packages/
|
||||||
|
├── medusa/ # Main Medusa package
|
||||||
|
├── core/ # Core framework packages
|
||||||
|
│ ├── framework/ # Core runtime
|
||||||
|
│ ├── types/ # TypeScript definitions
|
||||||
|
│ ├── utils/ # Utilities
|
||||||
|
│ ├── workflows-sdk/ # Workflow composition
|
||||||
|
│ ├── core-flows/ # Predefined workflows
|
||||||
|
│ └── modules-sdk/ # Module development
|
||||||
|
├── modules/ # 30+ commerce modules
|
||||||
|
│ ├── product/, order/, cart/, payment/...
|
||||||
|
│ └── providers/ # 15+ provider implementations
|
||||||
|
├── admin/ # Dashboard packages
|
||||||
|
│ └── dashboard/ # React admin UI
|
||||||
|
├── cli/ # CLI tools
|
||||||
|
└── design-system/ # UI components
|
||||||
|
/integration-tests/ # Full-stack tests
|
||||||
|
/www/ # Documentation site
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Directories:**
|
||||||
|
- `packages/core/framework/` - Core runtime, HTTP, database
|
||||||
|
- `packages/medusa/src/api/` - API routes
|
||||||
|
- `packages/modules/` - Commerce feature modules
|
||||||
|
- `packages/admin/dashboard/` - Admin React app
|
||||||
|
|
||||||
|
### 2. Build System & Commands
|
||||||
|
|
||||||
|
**Package Manager**: Yarn 3.2.1 with node-modules linker
|
||||||
|
|
||||||
|
**Essential Commands:**
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
# Build all packages
|
||||||
|
yarn build
|
||||||
|
# Build specific package
|
||||||
|
yarn workspace @medusajs/medusa build
|
||||||
|
# Watch mode (in package directory)
|
||||||
|
yarn watch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing Commands:**
|
||||||
|
```bash
|
||||||
|
# All unit tests
|
||||||
|
yarn test
|
||||||
|
# Package integration tests
|
||||||
|
yarn test:integration:packages
|
||||||
|
# HTTP integration tests
|
||||||
|
yarn test:integration:http
|
||||||
|
# API integration tests
|
||||||
|
yarn test:integration:api
|
||||||
|
# Module integration tests
|
||||||
|
yarn test:integration:modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Testing Conventions
|
||||||
|
|
||||||
|
**Frameworks:**
|
||||||
|
- Jest 29.7.0 (backend/core)
|
||||||
|
- Vitest 3.0.5 (admin/frontend)
|
||||||
|
|
||||||
|
**Test Locations:**
|
||||||
|
- Unit tests: `__tests__/` directories alongside source
|
||||||
|
- Package integration tests: `packages/*/integration-tests/__tests__/`
|
||||||
|
- HTTP integration tests: `integration-tests/http/__tests__/`
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- File extension: `.spec.ts` or `.test.ts`
|
||||||
|
- Unit test structure: `describe/it` blocks
|
||||||
|
- Integration tests: Use custom test runners with DB setup
|
||||||
|
|
||||||
|
### 4. Code Style Conventions
|
||||||
|
|
||||||
|
**Formatting (Prettier):**
|
||||||
|
- No semicolons
|
||||||
|
- Double quotes
|
||||||
|
- 2 space indentation
|
||||||
|
- ES5 trailing commas
|
||||||
|
- Always use parens in arrow functions
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
- Target: ES2021
|
||||||
|
- Module: Node16
|
||||||
|
- Strict null checks enabled
|
||||||
|
- Decorators enabled (experimental)
|
||||||
|
|
||||||
|
**Naming Conventions:**
|
||||||
|
- Files: kebab-case (`define-config.ts`)
|
||||||
|
- Types/Interfaces/Classes: PascalCase
|
||||||
|
- Functions/Variables: camelCase
|
||||||
|
- Constants: SCREAMING_SNAKE_CASE
|
||||||
|
- DB fields: snake_case
|
||||||
|
|
||||||
|
**Export Patterns:**
|
||||||
|
- Barrel exports via `export * from`
|
||||||
|
- Named re-exports for specific items
|
||||||
|
|
||||||
|
### 5. Architecture Patterns
|
||||||
|
|
||||||
|
#### 5.1 Module Pattern - Services with Decorators
|
||||||
|
|
||||||
|
**Service Structure:**
|
||||||
|
- Extend `MedusaService<T>` with typed model definitions
|
||||||
|
- Inject dependencies via constructor
|
||||||
|
- Use decorators for cross-cutting concerns
|
||||||
|
|
||||||
|
**Key Decorators:**
|
||||||
|
- `@InjectManager()` - Inject entity manager (use on public methods)
|
||||||
|
- `@InjectTransactionManager()` - Inject transaction manager (use on protected methods)
|
||||||
|
- `@MedusaContext()` - Inject shared context as parameter
|
||||||
|
- `@EmitEvents()` - Emit domain events after operation
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
export class OrderModuleService
|
||||||
|
extends MedusaService<{ Order: { dto: OrderDTO } }>({ Order })
|
||||||
|
implements IOrderModuleService
|
||||||
|
{
|
||||||
|
@InjectManager()
|
||||||
|
@EmitEvents()
|
||||||
|
async deleteOrders(
|
||||||
|
ids: string[],
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
return await this.deleteOrders_(ids, sharedContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@InjectTransactionManager()
|
||||||
|
protected async deleteOrders_(
|
||||||
|
ids: string[],
|
||||||
|
@MedusaContext() sharedContext: Context = {}
|
||||||
|
) {
|
||||||
|
await this.orderService_.softDelete(ids, sharedContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reference Files:**
|
||||||
|
- `packages/modules/order/src/services/order-module-service.ts`
|
||||||
|
- `packages/modules/api-key/src/services/api-key-module-service.ts`
|
||||||
|
|
||||||
|
#### 5.2 API Route Pattern
|
||||||
|
|
||||||
|
**Route Structure:**
|
||||||
|
- Named exports for HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`
|
||||||
|
- Type request: `AuthenticatedMedusaRequest<T>` or `MedusaRequest<T>`
|
||||||
|
- Type response: `MedusaResponse<T>`
|
||||||
|
- Access dependencies from `req.scope`
|
||||||
|
- Use workflows from `@medusajs/core-flows`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
import { deleteOrderWorkflow } from "@medusajs/core-flows"
|
||||||
|
import { HttpTypes } from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
|
||||||
|
export const DELETE = async (
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse<HttpTypes.AdminOrderDeleteResponse>
|
||||||
|
) => {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
await deleteOrderWorkflow(req.scope).run({
|
||||||
|
input: { id },
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
id,
|
||||||
|
object: "order",
|
||||||
|
deleted: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Patterns:**
|
||||||
|
- Filters: `req.filterableFields`
|
||||||
|
- Pagination: `req.queryConfig.pagination`
|
||||||
|
- Fields: `req.queryConfig.fields`
|
||||||
|
- Resolve services: `req.scope.resolve(ContainerRegistrationKeys.QUERY)`
|
||||||
|
|
||||||
|
**Reference Files:**
|
||||||
|
- `packages/medusa/src/api/admin/orders/route.ts`
|
||||||
|
- `packages/medusa/src/api/admin/payment-collections/[id]/route.ts`
|
||||||
|
|
||||||
|
#### 5.3 Workflow Pattern
|
||||||
|
|
||||||
|
**Step Definition:**
|
||||||
|
- Create steps with `createStep(id, mainAction, compensationAction?)`
|
||||||
|
- Return `StepResponse(result, compensationData)`
|
||||||
|
- Compensation function handles rollback
|
||||||
|
|
||||||
|
**Workflow Composition:**
|
||||||
|
- Create workflows with `createWorkflow(id, function)`
|
||||||
|
- Use `WorkflowData<T>` for typed input
|
||||||
|
- Return `WorkflowResponse<T>` for typed output
|
||||||
|
- Chain steps, use `transform()`, `when()`, `parallelize()`
|
||||||
|
- Query data with `useQueryGraphStep()`
|
||||||
|
- Emit events with `createHook()`
|
||||||
|
|
||||||
|
**Example Step:**
|
||||||
|
```typescript
|
||||||
|
export const deletePromotionsStep = createStep(
|
||||||
|
"delete-promotions",
|
||||||
|
async (ids: string[], { container }) => {
|
||||||
|
const promotionModule = container.resolve<IPromotionModuleService>(
|
||||||
|
Modules.PROMOTION
|
||||||
|
)
|
||||||
|
await promotionModule.softDeletePromotions(ids)
|
||||||
|
return new StepResponse(void 0, ids)
|
||||||
|
},
|
||||||
|
async (idsToRestore, { container }) => {
|
||||||
|
if (!idsToRestore?.length) return
|
||||||
|
const promotionModule = container.resolve<IPromotionModuleService>(
|
||||||
|
Modules.PROMOTION
|
||||||
|
)
|
||||||
|
await promotionModule.restorePromotions(idsToRestore)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Workflow:**
|
||||||
|
```typescript
|
||||||
|
export const deletePromotionsWorkflow = createWorkflow(
|
||||||
|
"delete-promotions",
|
||||||
|
(input: WorkflowData<{ ids: string[] }>) => {
|
||||||
|
const deletedPromotions = deletePromotionsStep(input.ids)
|
||||||
|
const promotionsDeleted = createHook("promotionsDeleted", {
|
||||||
|
ids: input.ids,
|
||||||
|
})
|
||||||
|
return new WorkflowResponse(deletedPromotions, {
|
||||||
|
hooks: [promotionsDeleted],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reference Files:**
|
||||||
|
- `packages/core/core-flows/src/promotion/steps/delete-promotions.ts`
|
||||||
|
- `packages/core/core-flows/src/promotion/workflows/delete-promotions.ts`
|
||||||
|
- `packages/core/core-flows/src/order/workflows/update-order.ts`
|
||||||
|
|
||||||
|
#### 5.4 Error Handling
|
||||||
|
|
||||||
|
**MedusaError Pattern:**
|
||||||
|
- Use `new MedusaError(type, message)` for all error throwing
|
||||||
|
- Provide contextual, user-friendly error messages
|
||||||
|
- Validate inputs early in services and workflow steps
|
||||||
|
|
||||||
|
**Common Error Types:**
|
||||||
|
- `MedusaError.Types.NOT_FOUND` - Resource not found
|
||||||
|
- `MedusaError.Types.INVALID_DATA` - Invalid input or state
|
||||||
|
- `MedusaError.Types.NOT_ALLOWED` - Operation not permitted
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
import { MedusaError, validateEmail } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
// In service
|
||||||
|
if (!entity) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_FOUND,
|
||||||
|
`Order with id: ${id} was not found`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In workflow step
|
||||||
|
if (input.email) {
|
||||||
|
validateEmail(input.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === "cancelled") {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_ALLOWED,
|
||||||
|
"Cannot update a cancelled order"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reference Files:**
|
||||||
|
- `packages/core/utils/src/modules-sdk/medusa-internal-service.ts`
|
||||||
|
- `packages/core/core-flows/src/order/workflows/update-order.ts`
|
||||||
|
|
||||||
|
#### 5.5 Common Import Patterns
|
||||||
|
|
||||||
|
**Path Aliases (configured in tsconfig.json):**
|
||||||
|
- `@models` - Entity models
|
||||||
|
- `@types` - DTO and type definitions
|
||||||
|
- `@services` - Service dependencies
|
||||||
|
- `@repositories` - Data access layer
|
||||||
|
- `@utils` - Utility functions
|
||||||
|
|
||||||
|
**Framework Imports:**
|
||||||
|
```typescript
|
||||||
|
// Utils and decorators
|
||||||
|
import {
|
||||||
|
InjectManager,
|
||||||
|
InjectTransactionManager,
|
||||||
|
MedusaContext,
|
||||||
|
MedusaError,
|
||||||
|
MedusaService,
|
||||||
|
EmitEvents,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type {
|
||||||
|
Context,
|
||||||
|
DAL,
|
||||||
|
IOrderModuleService,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
|
|
||||||
|
// Workflows
|
||||||
|
import {
|
||||||
|
WorkflowData,
|
||||||
|
WorkflowResponse,
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
transform,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
|
||||||
|
// Core flows
|
||||||
|
import { deleteOrderWorkflow } from "@medusajs/core-flows"
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
import {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
```
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/.medusa ./.medusa
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
EXPOSE 9000
|
||||||
|
CMD ["npx", "medusa", "start"]
|
||||||
76
README.md
Normal file
76
README.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.medusajs.com">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
|
||||||
|
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<h1 align="center">
|
||||||
|
Medusa
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<h4 align="center">
|
||||||
|
<a href="https://docs.medusajs.com">Documentation</a> |
|
||||||
|
<a href="https://www.medusajs.com">Website</a>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Building blocks for digital commerce
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
|
||||||
|
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
|
||||||
|
<a href="https://discord.gg/xpCwq3Kfn8">
|
||||||
|
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
|
||||||
|
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This starter is compatible with versions >= 2 of `@medusajs/medusa`.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
Visit the [Quickstart Guide](https://docs.medusajs.com/learn/installation) to set up a server.
|
||||||
|
|
||||||
|
Visit the [Docs](https://docs.medusajs.com/learn/installation#get-started) to learn more about our system requirements.
|
||||||
|
|
||||||
|
## What is Medusa
|
||||||
|
|
||||||
|
Medusa is a set of commerce modules and tools that allow you to build rich, reliable, and performant commerce applications without reinventing core commerce logic. The modules can be customized and used to build advanced ecommerce stores, marketplaces, or any product that needs foundational commerce primitives. All modules are open-source and freely available on npm.
|
||||||
|
|
||||||
|
Learn more about [Medusa’s architecture](https://docs.medusajs.com/learn/introduction/architecture) and [commerce modules](https://docs.medusajs.com/learn/fundamentals/modules/commerce-modules) in the Docs.
|
||||||
|
|
||||||
|
## Build with AI Agents
|
||||||
|
|
||||||
|
### Claude Code Plugin
|
||||||
|
|
||||||
|
If you use AI agents like Claude Code, check out the [medusa-dev Claude Code plugin](https://github.com/medusajs/medusa-claude-plugins).
|
||||||
|
|
||||||
|
### Other Agents
|
||||||
|
|
||||||
|
If you use AI agents other than Claude Code, copy the [skills directory](https://github.com/medusajs/medusa-claude-plugins/tree/main/plugins/medusa-dev/skills) into your agent's relevant `skills` directory.
|
||||||
|
|
||||||
|
### MCP Server
|
||||||
|
|
||||||
|
You can also add the MCP server `https://docs.medusajs.com/mcp` to your AI agents to answer questions related to Medusa. The `medusa-dev` Claude Code plugin includes this MCP server by default.
|
||||||
|
|
||||||
|
## Community & Contributions
|
||||||
|
|
||||||
|
The community and core team are available in [GitHub Discussions](https://github.com/medusajs/medusa/discussions), where you can ask for support, discuss roadmap, and share ideas.
|
||||||
|
|
||||||
|
Join our [Discord server](https://discord.com/invite/medusajs) to meet other community members.
|
||||||
|
|
||||||
|
## Other channels
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/medusajs/medusa/issues)
|
||||||
|
- [Twitter](https://twitter.com/medusajs)
|
||||||
|
- [LinkedIn](https://www.linkedin.com/company/medusajs)
|
||||||
|
- [Medusa Blog](https://medusajs.com/blog/)
|
||||||
24
instrumentation.ts
Normal file
24
instrumentation.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Uncomment this file to enable instrumentation and observability using OpenTelemetry
|
||||||
|
// Refer to the docs for installation instructions: https://docs.medusajs.com/learn/debugging-and-testing/instrumentation
|
||||||
|
|
||||||
|
// import { registerOtel } from "@medusajs/medusa"
|
||||||
|
// // If using an exporter other than Zipkin, require it here.
|
||||||
|
// import { ZipkinExporter } from "@opentelemetry/exporter-zipkin"
|
||||||
|
|
||||||
|
// // If using an exporter other than Zipkin, initialize it here.
|
||||||
|
// const exporter = new ZipkinExporter({
|
||||||
|
// serviceName: 'my-medusa-project',
|
||||||
|
// })
|
||||||
|
|
||||||
|
// export function register() {
|
||||||
|
// registerOtel({
|
||||||
|
// serviceName: 'medusajs',
|
||||||
|
// // pass exporter
|
||||||
|
// exporter,
|
||||||
|
// instrument: {
|
||||||
|
// http: true,
|
||||||
|
// workflows: true,
|
||||||
|
// query: true
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
// }
|
||||||
29
integration-tests/http/README.md
Normal file
29
integration-tests/http/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Integration Tests
|
||||||
|
|
||||||
|
The `medusa-test-utils` package provides utility functions to create integration tests for your API routes and workflows.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
||||||
|
|
||||||
|
medusaIntegrationTestRunner({
|
||||||
|
testSuite: ({ api, getContainer }) => {
|
||||||
|
describe("Custom endpoints", () => {
|
||||||
|
describe("GET /store/custom", () => {
|
||||||
|
it("returns correct message", async () => {
|
||||||
|
const response = await api.get(
|
||||||
|
`/store/custom`
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
expect(response.data).toHaveProperty("message")
|
||||||
|
expect(response.data.message).toEqual("Hello, World!")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Learn more in [this documentation](https://docs.medusajs.com/learn/debugging-and-testing/testing-tools/integration-tests).
|
||||||
15
integration-tests/http/health.spec.ts
Normal file
15
integration-tests/http/health.spec.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
|
||||||
|
jest.setTimeout(60 * 1000)
|
||||||
|
|
||||||
|
medusaIntegrationTestRunner({
|
||||||
|
inApp: true,
|
||||||
|
env: {},
|
||||||
|
testSuite: ({ api }) => {
|
||||||
|
describe("Ping", () => {
|
||||||
|
it("ping the server health endpoint", async () => {
|
||||||
|
const response = await api.get('/health')
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
3
integration-tests/setup.js
Normal file
3
integration-tests/setup.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
const { MetadataStorage } = require("@medusajs/framework/mikro-orm/core")
|
||||||
|
|
||||||
|
MetadataStorage.clear()
|
||||||
27
jest.config.js
Normal file
27
jest.config.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
const { loadEnv } = require("@medusajs/utils");
|
||||||
|
loadEnv("test", process.cwd());
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s$": [
|
||||||
|
"@swc/jest",
|
||||||
|
{
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: "typescript", decorators: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
testEnvironment: "node",
|
||||||
|
moduleFileExtensions: ["js", "ts", "json"],
|
||||||
|
modulePathIgnorePatterns: ["dist/", "<rootDir>/.medusa/"],
|
||||||
|
setupFiles: ["./integration-tests/setup.js"],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.TEST_TYPE === "integration:http") {
|
||||||
|
module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"];
|
||||||
|
} else if (process.env.TEST_TYPE === "integration:modules") {
|
||||||
|
module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"];
|
||||||
|
} else if (process.env.TEST_TYPE === "unit") {
|
||||||
|
module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"];
|
||||||
|
}
|
||||||
80
medusa-config.ts
Normal file
80
medusa-config.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { loadEnv, defineConfig } from "@medusajs/framework/utils";
|
||||||
|
|
||||||
|
loadEnv(process.env.NODE_ENV || "development", process.cwd());
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
admin: {
|
||||||
|
vite: () => ({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "html-title",
|
||||||
|
transformIndexHtml(html: string) {
|
||||||
|
return html.replace("<head>", "<head><title>Medusa</title>")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
projectConfig: {
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
http: {
|
||||||
|
storeCors: process.env.STORE_CORS!,
|
||||||
|
adminCors: process.env.ADMIN_CORS!,
|
||||||
|
authCors: process.env.AUTH_CORS!,
|
||||||
|
jwtSecret: process.env.JWT_SECRET,
|
||||||
|
cookieSecret: process.env.COOKIE_SECRET,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modules: [
|
||||||
|
{ resolve: "./src/modules/downloadGrant" },
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/auth-emailpass",
|
||||||
|
id: "emailpass",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/file",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/file-s3",
|
||||||
|
id: "s3",
|
||||||
|
options: {
|
||||||
|
file_url: process.env.HETZNER_S3_FILE_URL,
|
||||||
|
access_key_id: process.env.HETZNER_S3_ACCESS_KEY,
|
||||||
|
secret_access_key: process.env.HETZNER_S3_SECRET_KEY,
|
||||||
|
region: process.env.HETZNER_S3_REGION,
|
||||||
|
bucket: process.env.HETZNER_S3_BUCKET,
|
||||||
|
endpoint: process.env.HETZNER_S3_ENDPOINT,
|
||||||
|
additional_client_config: {
|
||||||
|
forcePathStyle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/payment",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
resolve: "@variablevic/mollie-payments-medusa/providers/mollie",
|
||||||
|
id: "mollie",
|
||||||
|
options: {
|
||||||
|
apiKey: process.env.MOLLIE_API_KEY,
|
||||||
|
redirectUrl: process.env.MOLLIE_REDIRECT_URL,
|
||||||
|
medusaUrl: process.env.MEDUSA_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
22366
package-lock.json
generated
Normal file
22366
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
57
package.json
Normal file
57
package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"name": "trptk-medusa",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "A starter for Medusa projects.",
|
||||||
|
"author": "Medusa (https://medusajs.com)",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"sqlite",
|
||||||
|
"postgres",
|
||||||
|
"typescript",
|
||||||
|
"ecommerce",
|
||||||
|
"headless",
|
||||||
|
"medusa"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "medusa build",
|
||||||
|
"seed": "medusa exec ./src/scripts/seed.ts",
|
||||||
|
"create-products": "medusa exec ./src/scripts/create-products.ts",
|
||||||
|
"update-store": "medusa exec ./src/scripts/update-store.ts",
|
||||||
|
"start": "medusa start",
|
||||||
|
"dev": "medusa develop",
|
||||||
|
"test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
|
||||||
|
"test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
|
||||||
|
"test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.990.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.990.0",
|
||||||
|
"@medusajs/admin-sdk": "2.13.1",
|
||||||
|
"@medusajs/cli": "2.13.1",
|
||||||
|
"@medusajs/framework": "2.13.1",
|
||||||
|
"@medusajs/medusa": "2.13.1",
|
||||||
|
"@sanity/client": "^7.14.1",
|
||||||
|
"@variablevic/mollie-payments-medusa": "^0.0.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@medusajs/test-utils": "2.13.1",
|
||||||
|
"@swc/core": "^1.7.28",
|
||||||
|
"@swc/jest": "^0.2.36",
|
||||||
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/node": "^20.12.11",
|
||||||
|
"@types/react": "^18.3.2",
|
||||||
|
"@types/react-dom": "^18.2.25",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.14",
|
||||||
|
"yalc": "^1.0.0-pre.53"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"packageManager": "npm@11.6.2"
|
||||||
|
}
|
||||||
33
src/admin/README.md
Normal file
33
src/admin/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Admin Customizations
|
||||||
|
|
||||||
|
You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities.
|
||||||
|
|
||||||
|
> Learn more about Admin Extensions in [this documentation](https://docs.medusajs.com/learn/fundamentals/admin).
|
||||||
|
|
||||||
|
## Example: Create a Widget
|
||||||
|
|
||||||
|
A widget is a React component that can be injected into an existing page in the admin dashboard.
|
||||||
|
|
||||||
|
For example, create the file `src/admin/widgets/product-widget.tsx` with the following content:
|
||||||
|
|
||||||
|
```tsx title="src/admin/widgets/product-widget.tsx"
|
||||||
|
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||||
|
|
||||||
|
// The widget
|
||||||
|
const ProductWidget = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Product Widget</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The widget's configurations
|
||||||
|
export const config = defineWidgetConfig({
|
||||||
|
zone: "product.details.after",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ProductWidget
|
||||||
|
```
|
||||||
|
|
||||||
|
This inserts a widget with the text “Product Widget” at the end of a product’s details page.
|
||||||
58
src/admin/i18n/README.md
Normal file
58
src/admin/i18n/README.md
Normal file
|
|
@ -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.
|
||||||
1
src/admin/i18n/index.ts
Normal file
1
src/admin/i18n/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export default {}
|
||||||
797
src/admin/routes/download-grants/page.tsx
Normal file
797
src/admin/routes/download-grants/page.tsx
Normal file
|
|
@ -0,0 +1,797 @@
|
||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||||
|
import { ArrowDownTray } from "@medusajs/icons"
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Badge,
|
||||||
|
Select,
|
||||||
|
FocusModal,
|
||||||
|
toast,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react"
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchasedDownload {
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
order_id: string
|
||||||
|
order_display_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrantRecord {
|
||||||
|
id: string
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
type: "grant" | "block"
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedRow {
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
source: "purchased" | "granted"
|
||||||
|
status: "active" | "blocked"
|
||||||
|
order_id?: string
|
||||||
|
order_display_id?: string | null
|
||||||
|
grant_id?: string
|
||||||
|
block_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Variant {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sku: string | null
|
||||||
|
metadata: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
variants: Variant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hooks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useDebouncedCallback<T extends (...args: any[]) => void>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
) {
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(...args: Parameters<T>) => {
|
||||||
|
if (timer.current) clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => fn(...args), delay)
|
||||||
|
},
|
||||||
|
[fn, delay]
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build unified rows ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildUnifiedRows(
|
||||||
|
purchased: PurchasedDownload[],
|
||||||
|
grants: GrantRecord[]
|
||||||
|
): UnifiedRow[] {
|
||||||
|
const blockMap = new Map<string, string>()
|
||||||
|
const grantOnlyRecords: GrantRecord[] = []
|
||||||
|
|
||||||
|
for (const g of grants) {
|
||||||
|
if (g.type === "block") {
|
||||||
|
blockMap.set(g.sku, g.id)
|
||||||
|
} else {
|
||||||
|
grantOnlyRecords.push(g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: UnifiedRow[] = []
|
||||||
|
|
||||||
|
// Deduplicate purchased by SKU
|
||||||
|
const seenPurchasedSkus = new Set<string>()
|
||||||
|
for (const p of purchased) {
|
||||||
|
if (seenPurchasedSkus.has(p.sku)) continue
|
||||||
|
seenPurchasedSkus.add(p.sku)
|
||||||
|
|
||||||
|
const isBlocked = blockMap.has(p.sku)
|
||||||
|
rows.push({
|
||||||
|
product_title: p.product_title,
|
||||||
|
variant_title: p.variant_title,
|
||||||
|
sku: p.sku,
|
||||||
|
source: "purchased",
|
||||||
|
status: isBlocked ? "blocked" : "active",
|
||||||
|
order_id: p.order_id,
|
||||||
|
order_display_id: p.order_display_id,
|
||||||
|
block_id: isBlocked ? blockMap.get(p.sku) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const g of grantOnlyRecords) {
|
||||||
|
rows.push({
|
||||||
|
product_title: g.product_title,
|
||||||
|
variant_title: g.variant_title,
|
||||||
|
sku: g.sku,
|
||||||
|
source: "granted",
|
||||||
|
status: "active",
|
||||||
|
grant_id: g.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DownloadGrantsPage = () => {
|
||||||
|
// Customer search
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [searchResults, setSearchResults] = useState<Customer[]>([])
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false)
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Downloads data
|
||||||
|
const [purchased, setPurchased] = useState<PurchasedDownload[]>([])
|
||||||
|
const [grants, setGrants] = useState<GrantRecord[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Product search in modal
|
||||||
|
const [productSearch, setProductSearch] = useState("")
|
||||||
|
const [productResults, setProductResults] = useState<Product[]>([])
|
||||||
|
const [productSearchLoading, setProductSearchLoading] = useState(false)
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
||||||
|
const [selectedVariantId, setSelectedVariantId] = useState("")
|
||||||
|
|
||||||
|
// ── Customer search ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchCustomers = useCallback(async (query: string) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setSearchResults([])
|
||||||
|
setSearchLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/customers?q=${encodeURIComponent(query)}&limit=10`,
|
||||||
|
{ credentials: "include" }
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error("Search failed")
|
||||||
|
const data = await res.json()
|
||||||
|
setSearchResults(data.customers ?? [])
|
||||||
|
} catch {
|
||||||
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const debouncedFetchCustomers = useDebouncedCallback(fetchCustomers, 300)
|
||||||
|
|
||||||
|
const handleCustomerSearch = (value: string) => {
|
||||||
|
setSearch(value)
|
||||||
|
if (value.length < 2) {
|
||||||
|
setSearchResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchLoading(true)
|
||||||
|
debouncedFetchCustomers(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Product search ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fetchProducts = useCallback(async (query: string) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setProductResults([])
|
||||||
|
setProductSearchLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setProductSearchLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/products?q=${encodeURIComponent(query)}&limit=20&fields=id,title,variants.id,variants.title,variants.sku,variants.metadata`,
|
||||||
|
{ credentials: "include" }
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error("Search failed")
|
||||||
|
const data = await res.json()
|
||||||
|
const filtered = (data.products ?? []).filter((p: Product) =>
|
||||||
|
p.variants?.some(
|
||||||
|
(v) => v.sku && v.metadata?.type === "digital"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setProductResults(filtered)
|
||||||
|
} catch {
|
||||||
|
setProductResults([])
|
||||||
|
} finally {
|
||||||
|
setProductSearchLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const debouncedFetchProducts = useDebouncedCallback(fetchProducts, 300)
|
||||||
|
|
||||||
|
const handleProductSearch = (value: string) => {
|
||||||
|
setProductSearch(value)
|
||||||
|
setSelectedProduct(null)
|
||||||
|
setSelectedVariantId("")
|
||||||
|
if (value.length < 2) {
|
||||||
|
setProductResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProductSearchLoading(true)
|
||||||
|
debouncedFetchProducts(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectProduct = (product: Product) => {
|
||||||
|
setSelectedProduct(product)
|
||||||
|
setProductSearch(product.title)
|
||||||
|
setProductResults([])
|
||||||
|
|
||||||
|
const digitalVariants = product.variants.filter(
|
||||||
|
(v) => v.sku && v.metadata?.type === "digital"
|
||||||
|
)
|
||||||
|
if (digitalVariants.length === 1) {
|
||||||
|
setSelectedVariantId(digitalVariants[0].id)
|
||||||
|
} else {
|
||||||
|
setSelectedVariantId("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load all downloads ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadAllDownloads = useCallback(async (customerId: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [allDownloadsRes, grantsRes] = await Promise.all([
|
||||||
|
fetch(`/admin/customers/${customerId}/all-downloads`, {
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
|
fetch(`/admin/customers/${customerId}/download-grants`, {
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!allDownloadsRes.ok) throw new Error("Failed to load downloads")
|
||||||
|
if (!grantsRes.ok) throw new Error("Failed to load grants")
|
||||||
|
|
||||||
|
const allDownloadsData = await allDownloadsRes.json()
|
||||||
|
const grantsData = await grantsRes.json()
|
||||||
|
|
||||||
|
setPurchased(allDownloadsData.purchased ?? [])
|
||||||
|
// Use grants from download-grants endpoint (includes type field)
|
||||||
|
// combined with grants from all-downloads (which also includes blocks)
|
||||||
|
setGrants(allDownloadsData.grants ?? grantsData.download_grants ?? [])
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load downloads")
|
||||||
|
setPurchased([])
|
||||||
|
setGrants([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectCustomer = (customer: Customer) => {
|
||||||
|
setSelectedCustomer(customer)
|
||||||
|
setSearchResults([])
|
||||||
|
setSearch("")
|
||||||
|
loadAllDownloads(customer.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleRevoke = async (row: UnifiedRow) => {
|
||||||
|
if (!selectedCustomer) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/customers/${selectedCustomer.id}/download-grants`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sku: row.sku,
|
||||||
|
product_title: row.product_title,
|
||||||
|
variant_title: row.variant_title,
|
||||||
|
type: "block",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to revoke")
|
||||||
|
|
||||||
|
toast.success("Download revoked")
|
||||||
|
loadAllDownloads(selectedCustomer.id)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to revoke download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (row: UnifiedRow) => {
|
||||||
|
if (!selectedCustomer || !row.block_id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/customers/${selectedCustomer.id}/download-grants/${row.block_id}`,
|
||||||
|
{ method: "DELETE", credentials: "include" }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to restore")
|
||||||
|
|
||||||
|
toast.success("Download restored")
|
||||||
|
loadAllDownloads(selectedCustomer.id)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to restore download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = async (row: UnifiedRow) => {
|
||||||
|
if (!selectedCustomer || !row.grant_id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/customers/${selectedCustomer.id}/download-grants/${row.grant_id}`,
|
||||||
|
{ method: "DELETE", credentials: "include" }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to remove grant")
|
||||||
|
|
||||||
|
toast.success("Download grant removed")
|
||||||
|
loadAllDownloads(selectedCustomer.id)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to remove grant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!selectedCustomer || !selectedProduct || !selectedVariantId) {
|
||||||
|
toast.error("Select a product and variant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = selectedProduct.variants.find(
|
||||||
|
(v) => v.id === selectedVariantId
|
||||||
|
)
|
||||||
|
if (!variant?.sku) {
|
||||||
|
toast.error("Selected variant has no SKU")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/admin/customers/${selectedCustomer.id}/download-grants`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sku: variant.sku,
|
||||||
|
product_title: selectedProduct.title,
|
||||||
|
variant_title: variant.title,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json()
|
||||||
|
throw new Error(err.message || "Failed to create grant")
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Download grant created")
|
||||||
|
setModalOpen(false)
|
||||||
|
resetModal()
|
||||||
|
loadAllDownloads(selectedCustomer.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to create grant")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetModal = () => {
|
||||||
|
setProductSearch("")
|
||||||
|
setProductResults([])
|
||||||
|
setSelectedProduct(null)
|
||||||
|
setSelectedVariantId("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const customerName = (c: Customer) =>
|
||||||
|
[c.first_name, c.last_name].filter(Boolean).join(" ") || c.email
|
||||||
|
|
||||||
|
const digitalVariants =
|
||||||
|
selectedProduct?.variants.filter(
|
||||||
|
(v) => v.sku && v.metadata?.type === "digital"
|
||||||
|
) ?? []
|
||||||
|
|
||||||
|
const unifiedRows = buildUnifiedRows(purchased, grants)
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h1">Download Grants</Heading>
|
||||||
|
<Text className="text-ui-fg-subtle" size="small">
|
||||||
|
Manage download access for customers
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer search */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<Label htmlFor="customer-search">Search customer</Label>
|
||||||
|
<Input
|
||||||
|
id="customer-search"
|
||||||
|
placeholder="Type a name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => handleCustomerSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchLoading && (
|
||||||
|
<Text size="small" className="text-ui-fg-muted mt-2">
|
||||||
|
Searching...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-col rounded-md border border-ui-border-base">
|
||||||
|
{searchResults.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectCustomer(c)}
|
||||||
|
className="flex items-center gap-x-3 px-4 py-2.5 text-left transition-colors hover:bg-ui-bg-subtle-hover first:rounded-t-md last:rounded-b-md"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<Text size="small" weight="plus">
|
||||||
|
{customerName(c)}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{c.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected customer + unified table */}
|
||||||
|
{selectedCustomer && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-x-3">
|
||||||
|
<div>
|
||||||
|
<Text size="small" weight="plus">
|
||||||
|
{customerName(selectedCustomer)}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{selectedCustomer.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Badge color="blue" size="small">
|
||||||
|
{unifiedRows.length} download{unifiedRows.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
>
|
||||||
|
Add Grant
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center px-6 py-12">
|
||||||
|
<Text className="text-ui-fg-muted">Loading downloads...</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && unifiedRows.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center px-6 py-12">
|
||||||
|
<Text className="text-ui-fg-muted">
|
||||||
|
No downloads for this customer yet.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && unifiedRows.length > 0 && (
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ui-border-base bg-ui-bg-subtle">
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Product
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Variant
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
SKU
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Source
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{unifiedRows.map((row, idx) => (
|
||||||
|
<tr
|
||||||
|
key={`${row.sku}-${row.source}-${idx}`}
|
||||||
|
className="border-b border-ui-border-base"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Text size="small">{row.product_title}</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Text size="small">{row.variant_title}</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Text size="small" className="font-mono">
|
||||||
|
{row.sku}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{row.source === "purchased" ? (
|
||||||
|
<a href={`/app/orders/${row.order_id}`}>
|
||||||
|
<Badge
|
||||||
|
color="grey"
|
||||||
|
size="small"
|
||||||
|
className="cursor-pointer hover:opacity-80"
|
||||||
|
>
|
||||||
|
Order
|
||||||
|
</Badge>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Badge color="purple" size="small">
|
||||||
|
Granted
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Badge
|
||||||
|
color={row.status === "active" ? "green" : "red"}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{row.status === "active" ? "Active" : "Blocked"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">
|
||||||
|
{row.source === "purchased" && row.status === "active" && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleRevoke(row)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.source === "purchased" && row.status === "blocked" && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleRestore(row)}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{row.source === "granted" && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleRemove(row)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Create grant modal */}
|
||||||
|
<FocusModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setModalOpen(open)
|
||||||
|
if (!open) resetModal()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FocusModal.Content>
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
<FocusModal.Header>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
<FocusModal.Close asChild>
|
||||||
|
<Button size="small" variant="secondary" disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</FocusModal.Close>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={handleCreate}
|
||||||
|
isLoading={saving}
|
||||||
|
disabled={!selectedProduct || !selectedVariantId}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Header>
|
||||||
|
|
||||||
|
<FocusModal.Body className="flex-1 overflow-auto">
|
||||||
|
<div className="mx-auto flex max-w-lg flex-col gap-y-6 p-8">
|
||||||
|
<div>
|
||||||
|
<Heading level="h2">Add Download Grant</Heading>
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
Grant download access for{" "}
|
||||||
|
{selectedCustomer
|
||||||
|
? customerName(selectedCustomer)
|
||||||
|
: "customer"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
{/* Product search */}
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<Label htmlFor="product-search">Product</Label>
|
||||||
|
<Input
|
||||||
|
id="product-search"
|
||||||
|
placeholder="Search for a product..."
|
||||||
|
value={productSearch}
|
||||||
|
onChange={(e) => handleProductSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{productSearchLoading && (
|
||||||
|
<Text size="small" className="text-ui-fg-muted">
|
||||||
|
Searching...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{productResults.length > 0 && !selectedProduct && (
|
||||||
|
<div className="flex flex-col rounded-md border border-ui-border-base">
|
||||||
|
{productResults.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectProduct(p)}
|
||||||
|
className="px-4 py-2.5 text-left transition-colors hover:bg-ui-bg-subtle-hover first:rounded-t-md last:rounded-b-md"
|
||||||
|
>
|
||||||
|
<Text size="small" weight="plus">
|
||||||
|
{p.title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
>
|
||||||
|
{p.variants.filter(
|
||||||
|
(v) =>
|
||||||
|
v.sku && v.metadata?.type === "digital"
|
||||||
|
).length}{" "}
|
||||||
|
digital variant(s)
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variant selector */}
|
||||||
|
{selectedProduct && digitalVariants.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<Label>Variant</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedVariantId}
|
||||||
|
onValueChange={setSelectedVariantId}
|
||||||
|
>
|
||||||
|
<Select.Trigger>
|
||||||
|
<Select.Value placeholder="Select a variant..." />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{digitalVariants.map((v) => (
|
||||||
|
<Select.Item key={v.id} value={v.id}>
|
||||||
|
{v.title} ({v.sku})
|
||||||
|
</Select.Item>
|
||||||
|
))}
|
||||||
|
</Select.Content>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{selectedProduct && selectedVariantId && (
|
||||||
|
<div className="rounded-md border border-ui-border-base bg-ui-bg-subtle p-4">
|
||||||
|
<Text
|
||||||
|
size="small"
|
||||||
|
weight="plus"
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
Grant summary
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">
|
||||||
|
Product:
|
||||||
|
</span>{" "}
|
||||||
|
{selectedProduct.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">
|
||||||
|
Variant:
|
||||||
|
</span>{" "}
|
||||||
|
{
|
||||||
|
digitalVariants.find(
|
||||||
|
(v) => v.id === selectedVariantId
|
||||||
|
)?.title
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">SKU:</span>{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{
|
||||||
|
digitalVariants.find(
|
||||||
|
(v) => v.id === selectedVariantId
|
||||||
|
)?.sku
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Body>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Content>
|
||||||
|
</FocusModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Download Grants",
|
||||||
|
icon: ArrowDownTray,
|
||||||
|
rank: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default DownloadGrantsPage
|
||||||
158
src/admin/routes/orders-to-ship/page.tsx
Normal file
158
src/admin/routes/orders-to-ship/page.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||||
|
import { FlyingBox } from "@medusajs/icons"
|
||||||
|
import { Container, Heading, Text, Badge } from "@medusajs/ui"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
interface ItemToShip {
|
||||||
|
title: string
|
||||||
|
catalogue_number: string | null
|
||||||
|
format: string | null
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderToShip {
|
||||||
|
id: string
|
||||||
|
display_id: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
email: string
|
||||||
|
customer: { name: string; email: string } | null
|
||||||
|
shipping_address: { city: string; country_code: string } | null
|
||||||
|
items_to_ship: ItemToShip[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrdersToShipPage = () => {
|
||||||
|
const [orders, setOrders] = useState<OrderToShip[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/admin/orders-to-ship", { credentials: "include" })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed to load orders")
|
||||||
|
const data = await res.json()
|
||||||
|
setOrders(data.orders)
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<Heading level="h1">Orders to Ship</Heading>
|
||||||
|
<Text className="text-ui-fg-subtle" size="small">
|
||||||
|
Orders with physical items awaiting fulfillment
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{!loading && !error && (
|
||||||
|
<Badge color="orange" size="small">
|
||||||
|
{orders.length} order{orders.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center px-6 py-12">
|
||||||
|
<Text className="text-ui-fg-muted">Loading orders...</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center justify-center px-6 py-12">
|
||||||
|
<Text className="text-ui-fg-error">{error}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && orders.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center px-6 py-12">
|
||||||
|
<Text className="text-ui-fg-muted">
|
||||||
|
No orders to ship — all caught up!
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && orders.length > 0 && (
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ui-border-base bg-ui-bg-subtle">
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Order
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Customer
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Items to Ship
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">
|
||||||
|
Destination
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 font-medium text-ui-fg-subtle">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<tr
|
||||||
|
key={order.id}
|
||||||
|
className="border-b border-ui-border-base hover:bg-ui-bg-subtle-hover"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href={`/app/orders/${order.id}`}
|
||||||
|
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover font-medium"
|
||||||
|
>
|
||||||
|
#{order.display_id}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Text size="small">
|
||||||
|
{order.customer?.name || order.email}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<div className="flex flex-col gap-y-0.5">
|
||||||
|
{order.items_to_ship.map((item, i) => (
|
||||||
|
<Text size="small" key={i} className="font-mono">
|
||||||
|
{item.quantity}x{" "}
|
||||||
|
{item.catalogue_number && item.format
|
||||||
|
? `${item.catalogue_number}_${item.format}`
|
||||||
|
: item.catalogue_number
|
||||||
|
? item.catalogue_number
|
||||||
|
: item.title}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Text size="small">
|
||||||
|
{[
|
||||||
|
order.shipping_address?.city,
|
||||||
|
order.shipping_address?.country_code?.toUpperCase(),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || "—"}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
|
{new Date(order.created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Orders to Ship",
|
||||||
|
icon: FlyingBox,
|
||||||
|
rank: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default OrdersToShipPage
|
||||||
24
src/admin/tsconfig.json
Normal file
24
src/admin/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"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": ["."]
|
||||||
|
}
|
||||||
1
src/admin/vite-env.d.ts
vendored
Normal file
1
src/admin/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
253
src/admin/widgets/trptk-formats.tsx
Normal file
253
src/admin/widgets/trptk-formats.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||||
|
import type { AdminProduct } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
toast,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
interface SanityData {
|
||||||
|
title: string | null
|
||||||
|
subtitle: string | null
|
||||||
|
handle: string | null
|
||||||
|
description: string | null
|
||||||
|
catalogue_number: string | null
|
||||||
|
ean: string | null
|
||||||
|
image_url: string | null
|
||||||
|
format: string | null
|
||||||
|
available_variants: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELEASE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
single: "Single",
|
||||||
|
ep: "EP",
|
||||||
|
album: "Album",
|
||||||
|
boxset: "Box Set",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRPTKFormatsWidget = ({ data }: { data: AdminProduct }) => {
|
||||||
|
const existingCatalogueNumber =
|
||||||
|
(data.metadata?.catalogue_number as string) ?? ""
|
||||||
|
const existingEan = (data.metadata?.ean as string) ?? ""
|
||||||
|
|
||||||
|
const [catalogueNumber, setCatalogueNumber] = useState(
|
||||||
|
existingCatalogueNumber
|
||||||
|
)
|
||||||
|
const [ean, setEan] = useState(existingEan)
|
||||||
|
const [syncLoading, setSyncLoading] = useState(false)
|
||||||
|
const [sanityData, setSanityData] = useState<SanityData | null>(null)
|
||||||
|
const [sanityLoading, setSanityLoading] = useState(false)
|
||||||
|
const [sanityChecked, setSanityChecked] = useState(false)
|
||||||
|
|
||||||
|
const bothFieldsFilled = Boolean(catalogueNumber) && Boolean(ean)
|
||||||
|
|
||||||
|
const handleSanityLookup = async () => {
|
||||||
|
if (!ean) {
|
||||||
|
toast.error("Enter an EAN first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSanityLoading(true)
|
||||||
|
setSanityChecked(false)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ ean })
|
||||||
|
if (catalogueNumber) {
|
||||||
|
params.set("catalogue_number", catalogueNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/admin/sanity-lookup?${params.toString()}`,
|
||||||
|
{ credentials: "include" }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
setSanityData(null)
|
||||||
|
setSanityChecked(true)
|
||||||
|
toast.error("No release found in Sanity for this EAN")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json()
|
||||||
|
toast.error(err.message || "Sanity lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SanityData = await response.json()
|
||||||
|
setSanityData(result)
|
||||||
|
setSanityChecked(true)
|
||||||
|
|
||||||
|
// Auto-fill catalogue number from Sanity if empty
|
||||||
|
if (result.catalogue_number && !catalogueNumber) {
|
||||||
|
setCatalogueNumber(result.catalogue_number)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Found: ${result.title || "Untitled"}${result.subtitle ? ` — ${result.subtitle}` : ""}`
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
toast.error("Sanity lookup failed")
|
||||||
|
} finally {
|
||||||
|
setSanityLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (!catalogueNumber || !ean) {
|
||||||
|
toast.error("Catalogue Number and EAN are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/admin/products/${data.id}/trptk-sync`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
catalogue_number: catalogueNumber,
|
||||||
|
ean,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(result.message || "Sync failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(result.message)
|
||||||
|
setTimeout(() => window.location.reload(), 1000)
|
||||||
|
} catch {
|
||||||
|
toast.error("Sync failed")
|
||||||
|
} finally {
|
||||||
|
setSyncLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div className="flex flex-col gap-y-4 px-6 py-4">
|
||||||
|
<Heading level="h2">TRPTK Formats</Heading>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ean">EAN</Label>
|
||||||
|
<Input
|
||||||
|
id="ean"
|
||||||
|
placeholder="0608917722017"
|
||||||
|
value={ean}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEan(e.target.value)
|
||||||
|
setSanityChecked(false)
|
||||||
|
setSanityData(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="catalogue_number">Catalogue Number</Label>
|
||||||
|
<Input
|
||||||
|
id="catalogue_number"
|
||||||
|
placeholder="TTK0001"
|
||||||
|
value={catalogueNumber}
|
||||||
|
onChange={(e) => setCatalogueNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleSanityLookup}
|
||||||
|
isLoading={sanityLoading}
|
||||||
|
disabled={!ean}
|
||||||
|
>
|
||||||
|
Look up in Sanity
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{sanityChecked && sanityData && (
|
||||||
|
<div className="rounded-lg border border-ui-border-base bg-ui-bg-subtle p-3">
|
||||||
|
<Text size="small" weight="plus" className="mb-2">
|
||||||
|
Sanity data found:
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
{sanityData.title && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Title:</span>{" "}
|
||||||
|
{sanityData.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sanityData.subtitle && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Artist:</span>{" "}
|
||||||
|
{sanityData.subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sanityData.format && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Release type:</span>{" "}
|
||||||
|
{RELEASE_TYPE_LABELS[sanityData.format] || sanityData.format}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sanityData.available_variants && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Variants:</span>{" "}
|
||||||
|
{sanityData.available_variants.length} format(s) defined
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sanityData.handle && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Slug:</span>{" "}
|
||||||
|
{sanityData.handle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sanityData.description && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Description:</span>{" "}
|
||||||
|
{sanityData.description.length > 80
|
||||||
|
? `${sanityData.description.slice(0, 80)}...`
|
||||||
|
: sanityData.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{sanityData.image_url && (
|
||||||
|
<Text size="small">
|
||||||
|
<span className="text-ui-fg-subtle">Album cover:</span> Yes
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sanityChecked && !sanityData && (
|
||||||
|
<Text size="small" className="text-ui-fg-muted">
|
||||||
|
No Sanity data found for this EAN.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleSync}
|
||||||
|
isLoading={syncLoading}
|
||||||
|
disabled={!bothFieldsFilled}
|
||||||
|
>
|
||||||
|
Sync formats from Sanity
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineWidgetConfig({
|
||||||
|
zone: "product.details.side.after",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TRPTKFormatsWidget
|
||||||
135
src/api/README.md
Normal file
135
src/api/README.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# Custom API Routes
|
||||||
|
|
||||||
|
An API Route is a REST API endpoint.
|
||||||
|
|
||||||
|
An API Route is created in a TypeScript or JavaScript file under the `/src/api` directory of your Medusa application. The file’s name must be `route.ts` or `route.js`.
|
||||||
|
|
||||||
|
> Learn more about API Routes in [this documentation](https://docs.medusajs.com/learn/fundamentals/api-routes)
|
||||||
|
|
||||||
|
For example, to create a `GET` API Route at `/store/hello-world`, create the file `src/api/store/hello-world/route.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
res.json({
|
||||||
|
message: "Hello world!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported HTTP methods
|
||||||
|
|
||||||
|
The file based routing supports the following HTTP methods:
|
||||||
|
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PUT
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
- OPTIONS
|
||||||
|
- HEAD
|
||||||
|
|
||||||
|
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Handle GET requests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Handle POST requests
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
// Handle PUT requests
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
To create an API route that accepts a path parameter, create a directory within the route's path whose name is of the format `[param]`.
|
||||||
|
|
||||||
|
For example, if you want to define a route that takes a `productId` parameter, you can do so by creating a file called `/api/products/[productId]/route.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
|
||||||
|
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||||
|
const { productId } = req.params;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `You're looking for product ${productId}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To create an API route that accepts multiple path parameters, create within the file's path multiple directories whose names are of the format `[param]`.
|
||||||
|
|
||||||
|
For example, if you want to define a route that takes both a `productId` and a `variantId` parameter, you can do so by creating a file called `/api/products/[productId]/variants/[variantId]/route.ts`.
|
||||||
|
|
||||||
|
## Using the container
|
||||||
|
|
||||||
|
The Medusa container is available on `req.scope`. Use it to access modules' main services and other registered resources:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) => {
|
||||||
|
const productModuleService = req.scope.resolve("product")
|
||||||
|
|
||||||
|
const [, count] = await productModuleService.listAndCount()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Middleware
|
||||||
|
|
||||||
|
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file must export a configuration object with what middleware you want to apply to which routes.
|
||||||
|
|
||||||
|
For example, if you want to apply a custom middleware function to the `/store/custom` route, you can do so by adding the following to your `/api/middlewares.ts` file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineMiddlewares } from "@medusajs/framework/http"
|
||||||
|
import type {
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
MedusaNextFunction,
|
||||||
|
} from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
async function logger(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse,
|
||||||
|
next: MedusaNextFunction
|
||||||
|
) {
|
||||||
|
console.log("Request received");
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineMiddlewares({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
matcher: "/store/custom",
|
||||||
|
middlewares: [logger],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The `matcher` property can be either a string or a regular expression. The `middlewares` property accepts an array of middleware functions.
|
||||||
8
src/api/admin/custom/route.ts
Normal file
8
src/api/admin/custom/route.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
res.sendStatus(200);
|
||||||
|
}
|
||||||
92
src/api/admin/customers/[id]/all-downloads/route.ts
Normal file
92
src/api/admin/customers/[id]/all-downloads/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
interface PurchasedDownload {
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
order_id: string
|
||||||
|
order_display_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrantRecord {
|
||||||
|
id: string
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
type: "grant" | "block"
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { id } = req.params
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
// Fetch customer's orders to find purchased digital downloads
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
filters: { customer_id: id },
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"display_id",
|
||||||
|
"items.*",
|
||||||
|
"items.variant.*",
|
||||||
|
"items.variant.product.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const purchased: PurchasedDownload[] = []
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
for (const item of order.items ?? []) {
|
||||||
|
if (!item) continue
|
||||||
|
|
||||||
|
const variant = (item as any).variant
|
||||||
|
if (!variant) continue
|
||||||
|
|
||||||
|
const isDigital = variant.metadata?.type === "digital"
|
||||||
|
if (!isDigital) continue
|
||||||
|
|
||||||
|
const sku = variant.sku as string | undefined
|
||||||
|
if (!sku) continue
|
||||||
|
|
||||||
|
const product = variant.product
|
||||||
|
const productTitle = product?.title ?? (item as any).title ?? ""
|
||||||
|
const variantTitle = variant.title ?? (item as any).variant_title ?? ""
|
||||||
|
|
||||||
|
purchased.push({
|
||||||
|
product_title: productTitle,
|
||||||
|
variant_title: variantTitle,
|
||||||
|
sku,
|
||||||
|
order_id: order.id,
|
||||||
|
order_display_id: order.display_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch grant + block records
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
filters: { id },
|
||||||
|
fields: ["id", "download_grants.*"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const grants: GrantRecord[] = ((customers[0] as any)?.download_grants ?? []).map(
|
||||||
|
(g: any) => ({
|
||||||
|
id: g.id,
|
||||||
|
product_title: g.product_title,
|
||||||
|
variant_title: g.variant_title,
|
||||||
|
sku: g.sku,
|
||||||
|
type: g.type ?? "grant",
|
||||||
|
created_at: g.created_at,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
res.status(200).json({ purchased, grants })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import deleteDownloadGrantWorkflow from "../../../../../../workflows/delete-download-grant"
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { id, grantId } = req.params
|
||||||
|
|
||||||
|
const { result } = await deleteDownloadGrantWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
customer_id: id,
|
||||||
|
grant_id: grantId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json(result)
|
||||||
|
}
|
||||||
46
src/api/admin/customers/[id]/download-grants/route.ts
Normal file
46
src/api/admin/customers/[id]/download-grants/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
import createDownloadGrantWorkflow from "../../../../../workflows/create-download-grant"
|
||||||
|
import type { PostAdminDownloadGrant } from "./validators"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { id } = req.params
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
filters: { id },
|
||||||
|
fields: ["id", "download_grants.*"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const grants = customers[0]?.download_grants ?? []
|
||||||
|
|
||||||
|
res.status(200).json({ download_grants: grants })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: AuthenticatedMedusaRequest<PostAdminDownloadGrant>,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { id } = req.params
|
||||||
|
const { sku, product_title, variant_title, note, type } = req.validatedBody
|
||||||
|
|
||||||
|
const { result } = await createDownloadGrantWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
customer_id: id,
|
||||||
|
sku,
|
||||||
|
product_title,
|
||||||
|
variant_title,
|
||||||
|
note,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json({ download_grant: result })
|
||||||
|
}
|
||||||
11
src/api/admin/customers/[id]/download-grants/validators.ts
Normal file
11
src/api/admin/customers/[id]/download-grants/validators.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const PostAdminDownloadGrant = z.object({
|
||||||
|
sku: z.string().min(1),
|
||||||
|
product_title: z.string().min(1),
|
||||||
|
variant_title: z.string().min(1),
|
||||||
|
note: z.string().optional(),
|
||||||
|
type: z.enum(["grant", "block"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PostAdminDownloadGrant = z.infer<typeof PostAdminDownloadGrant>
|
||||||
87
src/api/admin/orders-to-ship/route.ts
Normal file
87
src/api/admin/orders-to-ship/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) => {
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
filters: {
|
||||||
|
status: { $in: ["pending", "completed", "requires_action"] },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"display_id",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"email",
|
||||||
|
"currency_code",
|
||||||
|
"customer.*",
|
||||||
|
"shipping_address.*",
|
||||||
|
"items.*",
|
||||||
|
"items.detail.*",
|
||||||
|
"items.variant.*",
|
||||||
|
"items.variant.product.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter to orders that have at least one physical item not yet fulfilled
|
||||||
|
const ordersToShip = orders.filter((order: any) => {
|
||||||
|
const items = order.items ?? []
|
||||||
|
return items.some((item: any) => {
|
||||||
|
const isDigital = item.variant?.metadata?.type === "digital"
|
||||||
|
if (isDigital) return false
|
||||||
|
|
||||||
|
const quantity = item.quantity ?? 0
|
||||||
|
const fulfilled = item.detail?.fulfilled_quantity ?? 0
|
||||||
|
return fulfilled < quantity
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map to a clean response shape
|
||||||
|
const result = ordersToShip.map((order: any) => {
|
||||||
|
const physicalItems = (order.items ?? []).filter((item: any) => {
|
||||||
|
const isDigital = item.variant?.metadata?.type === "digital"
|
||||||
|
if (isDigital) return false
|
||||||
|
const fulfilled = item.detail?.fulfilled_quantity ?? 0
|
||||||
|
return fulfilled < (item.quantity ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: order.id,
|
||||||
|
display_id: order.display_id,
|
||||||
|
status: order.status,
|
||||||
|
created_at: order.created_at,
|
||||||
|
email: order.email,
|
||||||
|
customer: order.customer
|
||||||
|
? {
|
||||||
|
name: [order.customer.first_name, order.customer.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
email: order.customer.email,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
shipping_address: order.shipping_address
|
||||||
|
? {
|
||||||
|
city: order.shipping_address.city,
|
||||||
|
country_code: order.shipping_address.country_code,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
items_to_ship: physicalItems.map((item: any) => ({
|
||||||
|
title: item.variant_title || item.title,
|
||||||
|
catalogue_number:
|
||||||
|
(item.variant?.product?.metadata?.catalogue_number as string) || null,
|
||||||
|
format: (item.variant_title as string) || null,
|
||||||
|
quantity: (item.quantity ?? 0) - (item.detail?.fulfilled_quantity ?? 0),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ orders: result })
|
||||||
|
}
|
||||||
45
src/api/admin/products/[id]/trptk-formats/route.ts
Normal file
45
src/api/admin/products/[id]/trptk-formats/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { createTrptkFormatVariantsWorkflow } from "../../../../../workflows/create-trptk-format-variants"
|
||||||
|
import type { PostAdminTrptkFormatsType } from "./validators"
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: AuthenticatedMedusaRequest<PostAdminTrptkFormatsType>,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { id } = req.params
|
||||||
|
const {
|
||||||
|
catalogue_number,
|
||||||
|
ean,
|
||||||
|
exclude_formats,
|
||||||
|
price_overrides,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
handle,
|
||||||
|
description,
|
||||||
|
image_url,
|
||||||
|
release_type,
|
||||||
|
available_variants,
|
||||||
|
} = req.validatedBody
|
||||||
|
|
||||||
|
const { result } = await createTrptkFormatVariantsWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
product_id: id,
|
||||||
|
catalogue_number,
|
||||||
|
ean,
|
||||||
|
exclude_formats,
|
||||||
|
price_overrides,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
handle,
|
||||||
|
description,
|
||||||
|
image_url,
|
||||||
|
release_type,
|
||||||
|
available_variants,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json(result)
|
||||||
|
}
|
||||||
18
src/api/admin/products/[id]/trptk-formats/validators.ts
Normal file
18
src/api/admin/products/[id]/trptk-formats/validators.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { z } from "@medusajs/framework/zod"
|
||||||
|
|
||||||
|
export const PostAdminTrptkFormats = z.object({
|
||||||
|
catalogue_number: z.string(),
|
||||||
|
ean: z.string(),
|
||||||
|
exclude_formats: z.array(z.string()).optional(),
|
||||||
|
price_overrides: z.record(z.string(), z.number()).optional(),
|
||||||
|
// Sanity-sourced fields
|
||||||
|
title: z.string().optional(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
handle: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
image_url: z.string().optional(),
|
||||||
|
release_type: z.string().optional(),
|
||||||
|
available_variants: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PostAdminTrptkFormatsType = z.infer<typeof PostAdminTrptkFormats>
|
||||||
40
src/api/admin/products/[id]/trptk-sync/route.ts
Normal file
40
src/api/admin/products/[id]/trptk-sync/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { syncProductFromSanityWorkflow } from "../../../../../workflows/sync-product-from-sanity"
|
||||||
|
import type { PostAdminTrptkSyncType } from "./validators"
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: AuthenticatedMedusaRequest<PostAdminTrptkSyncType>,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const { id } = req.params
|
||||||
|
const { catalogue_number, ean } = req.validatedBody
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = await syncProductFromSanityWorkflow(req.scope).run({
|
||||||
|
input: {
|
||||||
|
product_id: id,
|
||||||
|
catalogue_number,
|
||||||
|
ean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json(result)
|
||||||
|
} catch (err: any) {
|
||||||
|
// Map known error messages to appropriate HTTP status codes
|
||||||
|
if (err.message === "Release not found in Sanity") {
|
||||||
|
res.status(404).json({ message: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
err.message?.includes("SANITY_PROJECT_ID is not configured") ||
|
||||||
|
err.message?.includes("No availableVariants defined")
|
||||||
|
) {
|
||||||
|
res.status(400).json({ message: err.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/api/admin/products/[id]/trptk-sync/validators.ts
Normal file
8
src/api/admin/products/[id]/trptk-sync/validators.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { z } from "@medusajs/framework/zod"
|
||||||
|
|
||||||
|
export const PostAdminTrptkSync = z.object({
|
||||||
|
catalogue_number: z.string(),
|
||||||
|
ean: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PostAdminTrptkSyncType = z.infer<typeof PostAdminTrptkSync>
|
||||||
103
src/api/admin/sanity-lookup/route.ts
Normal file
103
src/api/admin/sanity-lookup/route.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { getSanityClient } from "../../../scripts/sanity-client"
|
||||||
|
|
||||||
|
const sanity = getSanityClient()
|
||||||
|
|
||||||
|
interface SanityRelease {
|
||||||
|
name?: string
|
||||||
|
albumArtist?: string
|
||||||
|
slug?: { current?: string }
|
||||||
|
shortDescription?: string
|
||||||
|
catalogNo?: string
|
||||||
|
upc?: string
|
||||||
|
format?: string
|
||||||
|
availableVariants?: string[]
|
||||||
|
albumCover?: {
|
||||||
|
asset?: {
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const ean = req.query.ean as string | undefined
|
||||||
|
const catalogueNumber = req.query.catalogue_number as string | undefined
|
||||||
|
|
||||||
|
if (!ean && !catalogueNumber) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "Provide ean or catalogue_number as a query parameter",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.SANITY_PROJECT_ID) {
|
||||||
|
res.status(500).json({
|
||||||
|
message:
|
||||||
|
"SANITY_PROJECT_ID is not configured. Add it to your .env file.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build GROQ filter: match by EAN (upc) or catalogue number (catalogNo)
|
||||||
|
const conditions: string[] = []
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (ean) {
|
||||||
|
conditions.push("upc == $ean")
|
||||||
|
params.ean = ean
|
||||||
|
}
|
||||||
|
if (catalogueNumber) {
|
||||||
|
conditions.push("catalogNo == $catalogueNumber")
|
||||||
|
params.catalogueNumber = catalogueNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = conditions.join(" || ")
|
||||||
|
const query = `*[_type == "release" && (${filter})][0]{
|
||||||
|
name,
|
||||||
|
albumArtist,
|
||||||
|
slug,
|
||||||
|
shortDescription,
|
||||||
|
catalogNo,
|
||||||
|
upc,
|
||||||
|
format,
|
||||||
|
availableVariants,
|
||||||
|
"albumCover": albumCover{
|
||||||
|
"asset": asset->{
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const release = await sanity.fetch<SanityRelease | null>(query, params)
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
res.status(404).json({
|
||||||
|
message: "No release found in Sanity",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
title: release.name || null,
|
||||||
|
subtitle: release.albumArtist || null,
|
||||||
|
handle: release.slug?.current || null,
|
||||||
|
description: release.shortDescription || null,
|
||||||
|
catalogue_number: release.catalogNo || null,
|
||||||
|
ean: release.upc || null,
|
||||||
|
image_url: release.albumCover?.asset?.url || null,
|
||||||
|
format: release.format || null,
|
||||||
|
available_variants: release.availableVariants || null,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({
|
||||||
|
message: `Sanity query failed: ${err.message}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/api/middlewares.ts
Normal file
33
src/api/middlewares.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {
|
||||||
|
defineMiddlewares,
|
||||||
|
validateAndTransformBody,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { filterShippingByCart } from "./store/shipping-options/filter-shipping"
|
||||||
|
import { PostAdminTrptkFormats } from "./admin/products/[id]/trptk-formats/validators"
|
||||||
|
import { PostAdminTrptkSync } from "./admin/products/[id]/trptk-sync/validators"
|
||||||
|
import { PostAdminDownloadGrant } from "./admin/customers/[id]/download-grants/validators"
|
||||||
|
|
||||||
|
export default defineMiddlewares({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
matcher: "/store/shipping-options",
|
||||||
|
method: "GET",
|
||||||
|
middlewares: [filterShippingByCart],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: "/admin/products/:id/trptk-formats",
|
||||||
|
method: "POST",
|
||||||
|
middlewares: [validateAndTransformBody(PostAdminTrptkFormats)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: "/admin/products/:id/trptk-sync",
|
||||||
|
method: "POST",
|
||||||
|
middlewares: [validateAndTransformBody(PostAdminTrptkSync)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matcher: "/admin/customers/:id/download-grants",
|
||||||
|
method: "POST",
|
||||||
|
middlewares: [validateAndTransformBody(PostAdminDownloadGrant)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
8
src/api/store/custom/route.ts
Normal file
8
src/api/store/custom/route.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
res.sendStatus(200);
|
||||||
|
}
|
||||||
55
src/api/store/customers/me/download-grants/route.ts
Normal file
55
src/api/store/customers/me/download-grants/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
MedusaError,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import { getPresignedDownloadUrl } from "../../../../../lib/s3-download"
|
||||||
|
|
||||||
|
interface GrantDownload {
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
download_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const customerId = req.auth_context.actor_id
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_ALLOWED,
|
||||||
|
"Customer authentication required"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
filters: { id: customerId },
|
||||||
|
fields: ["id", "download_grants.*"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const allGrants = (customers[0] as any)?.download_grants ?? []
|
||||||
|
const downloads: GrantDownload[] = []
|
||||||
|
|
||||||
|
// Only include type=grant records, skip blocks
|
||||||
|
for (const grant of allGrants) {
|
||||||
|
if (grant.type === "block") continue
|
||||||
|
const downloadUrl = await getPresignedDownloadUrl(grant.sku)
|
||||||
|
downloads.push({
|
||||||
|
product_title: grant.product_title,
|
||||||
|
variant_title: grant.variant_title,
|
||||||
|
sku: grant.sku,
|
||||||
|
download_url: downloadUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ downloads })
|
||||||
|
}
|
||||||
138
src/api/store/customers/me/downloads/route.ts
Normal file
138
src/api/store/customers/me/downloads/route.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
MedusaError,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import { getPresignedDownloadUrl } from "../../../../../lib/s3-download"
|
||||||
|
import { isReleased, getReleaseDate } from "../../../../../lib/release-date"
|
||||||
|
|
||||||
|
interface DownloadItem {
|
||||||
|
order_id: string | null
|
||||||
|
order_display_id: string | null
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
download_url: string
|
||||||
|
source: "order" | "grant"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpcomingItem {
|
||||||
|
order_id: string
|
||||||
|
order_display_id: string | null
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
release_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) {
|
||||||
|
const customerId = req.auth_context.actor_id
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.NOT_ALLOWED,
|
||||||
|
"Customer authentication required"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
filters: {
|
||||||
|
customer_id: customerId,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"display_id",
|
||||||
|
"status",
|
||||||
|
"items.*",
|
||||||
|
"items.variant.*",
|
||||||
|
"items.variant.product.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloads: DownloadItem[] = []
|
||||||
|
const upcoming: UpcomingItem[] = []
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
for (const item of order.items ?? []) {
|
||||||
|
if (!item) continue
|
||||||
|
|
||||||
|
const variant = (item as any).variant
|
||||||
|
if (!variant) continue
|
||||||
|
|
||||||
|
const isDigital = variant.metadata?.type === "digital"
|
||||||
|
if (!isDigital) continue
|
||||||
|
|
||||||
|
const sku = variant.sku as string | undefined
|
||||||
|
if (!sku) continue
|
||||||
|
|
||||||
|
const product = variant.product
|
||||||
|
const productTitle = product?.title ?? (item as any).title ?? ""
|
||||||
|
const variantTitle = variant.title ?? (item as any).variant_title ?? ""
|
||||||
|
|
||||||
|
if (!isReleased(product?.metadata)) {
|
||||||
|
upcoming.push({
|
||||||
|
order_id: order.id,
|
||||||
|
order_display_id: order.display_id,
|
||||||
|
product_title: productTitle,
|
||||||
|
variant_title: variantTitle,
|
||||||
|
sku,
|
||||||
|
release_date: getReleaseDate(product?.metadata)!,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = await getPresignedDownloadUrl(sku)
|
||||||
|
|
||||||
|
downloads.push({
|
||||||
|
order_id: order.id,
|
||||||
|
order_display_id: order.display_id,
|
||||||
|
product_title: productTitle,
|
||||||
|
variant_title: variantTitle,
|
||||||
|
sku,
|
||||||
|
download_url: downloadUrl,
|
||||||
|
source: "order",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch grant-based downloads (only type=grant, not blocks)
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
filters: { id: customerId },
|
||||||
|
fields: ["id", "download_grants.*"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const allGrants = (customers[0] as any)?.download_grants ?? []
|
||||||
|
|
||||||
|
for (const grant of allGrants) {
|
||||||
|
if (grant.type === "block") continue
|
||||||
|
const downloadUrl = await getPresignedDownloadUrl(grant.sku)
|
||||||
|
downloads.push({
|
||||||
|
order_id: null,
|
||||||
|
order_display_id: null,
|
||||||
|
product_title: grant.product_title,
|
||||||
|
variant_title: grant.variant_title,
|
||||||
|
sku: grant.sku,
|
||||||
|
download_url: downloadUrl,
|
||||||
|
source: "grant",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out blocked SKUs from both arrays
|
||||||
|
const blockedSkus = new Set(
|
||||||
|
allGrants.filter((g: any) => g.type === "block").map((g: any) => g.sku)
|
||||||
|
)
|
||||||
|
const filteredDownloads = downloads.filter((d) => !blockedSkus.has(d.sku))
|
||||||
|
const filteredUpcoming = upcoming.filter((u) => !blockedSkus.has(u.sku))
|
||||||
|
|
||||||
|
res.status(200).json({ downloads: filteredDownloads, upcoming: filteredUpcoming })
|
||||||
|
}
|
||||||
106
src/api/store/order-downloads/route.ts
Normal file
106
src/api/store/order-downloads/route.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import type { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
import { getPresignedDownloadUrl } from "../../../lib/s3-download"
|
||||||
|
import { isReleased, getReleaseDate } from "../../../lib/release-date"
|
||||||
|
import { getBlockedSkus } from "../../../lib/blocked-skus"
|
||||||
|
|
||||||
|
interface DownloadItem {
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
download_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpcomingItem {
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
sku: string
|
||||||
|
release_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /store/order-downloads?order_id=xxx
|
||||||
|
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
|
||||||
|
const customerId = req.auth_context?.actor_id
|
||||||
|
if (!customerId) {
|
||||||
|
res.status(401).json({ message: "Authentication required" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = req.query.order_id as string | undefined
|
||||||
|
|
||||||
|
if (!orderId) {
|
||||||
|
res.status(400).json({ message: "Missing order_id query parameter" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
filters: { id: orderId },
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"customer_id",
|
||||||
|
"items.*",
|
||||||
|
"items.variant.*",
|
||||||
|
"items.variant.product.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const order = orders[0]
|
||||||
|
if (!order) {
|
||||||
|
res.status(404).json({ message: "Order not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((order as any).customer_id !== customerId) {
|
||||||
|
res.status(404).json({ message: "Order not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloads: DownloadItem[] = []
|
||||||
|
const upcoming: UpcomingItem[] = []
|
||||||
|
|
||||||
|
for (const item of order.items ?? []) {
|
||||||
|
if (!item) continue
|
||||||
|
|
||||||
|
const variant = (item as any).variant
|
||||||
|
if (!variant) continue
|
||||||
|
|
||||||
|
const isDigital = variant.metadata?.type === "digital"
|
||||||
|
if (!isDigital) continue
|
||||||
|
|
||||||
|
const sku = variant.sku as string | undefined
|
||||||
|
if (!sku) continue
|
||||||
|
|
||||||
|
const product = variant.product
|
||||||
|
const productTitle = product?.title ?? (item as any).title ?? ""
|
||||||
|
const variantTitle = variant.title ?? (item as any).variant_title ?? ""
|
||||||
|
|
||||||
|
if (!isReleased(product?.metadata)) {
|
||||||
|
upcoming.push({
|
||||||
|
product_title: productTitle,
|
||||||
|
variant_title: variantTitle,
|
||||||
|
sku,
|
||||||
|
release_date: getReleaseDate(product?.metadata)!,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = await getPresignedDownloadUrl(sku)
|
||||||
|
|
||||||
|
downloads.push({
|
||||||
|
product_title: productTitle,
|
||||||
|
variant_title: variantTitle,
|
||||||
|
sku,
|
||||||
|
download_url: downloadUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out blocked SKUs
|
||||||
|
const blockedSkus = await getBlockedSkus(query, customerId)
|
||||||
|
const filteredDownloads = downloads.filter((d) => !blockedSkus.has(d.sku))
|
||||||
|
const filteredUpcoming = upcoming.filter((u) => !blockedSkus.has(u.sku))
|
||||||
|
|
||||||
|
res.status(200).json({ downloads: filteredDownloads, upcoming: filteredUpcoming })
|
||||||
|
}
|
||||||
81
src/api/store/shipping-options/filter-shipping.ts
Normal file
81
src/api/store/shipping-options/filter-shipping.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type {
|
||||||
|
MedusaNextFunction,
|
||||||
|
MedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "@medusajs/framework/http"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
const MAILBOX_FORMATS = new Set(["CD", "SACD"])
|
||||||
|
const MAX_MAILBOX_ITEMS = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that filters shipping options based on cart contents.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - Mailbox Package: only CDs/SACDs, up to 4 physical items
|
||||||
|
* - Package: LPs, 5+ CDs/SACDs, or mixed physical formats
|
||||||
|
*
|
||||||
|
* Runs before the route handler, wraps res.json to filter the response.
|
||||||
|
*/
|
||||||
|
export async function filterShippingByCart(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse,
|
||||||
|
next: MedusaNextFunction
|
||||||
|
) {
|
||||||
|
const cartId = req.query.cart_id as string
|
||||||
|
|
||||||
|
if (!cartId) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const { data: carts } = await query.graph({
|
||||||
|
entity: "cart",
|
||||||
|
fields: ["id", "items.variant_title", "items.quantity"],
|
||||||
|
filters: { id: cartId },
|
||||||
|
})
|
||||||
|
|
||||||
|
const cart = carts[0]
|
||||||
|
if (!cart?.items?.length) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify physical items by variant title (CD, SACD, LP)
|
||||||
|
const physicalItems = cart.items.filter(
|
||||||
|
(item: any) =>
|
||||||
|
MAILBOX_FORMATS.has(item.variant_title) ||
|
||||||
|
item.variant_title === "LP"
|
||||||
|
)
|
||||||
|
|
||||||
|
// If no physical items (digital-only order), don't filter
|
||||||
|
if (physicalItems.length === 0) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const physicalQty = physicalItems.reduce(
|
||||||
|
(sum: number, item: any) => sum + item.quantity,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
const allMailboxFormats = physicalItems.every((item: any) =>
|
||||||
|
MAILBOX_FORMATS.has(item.variant_title)
|
||||||
|
)
|
||||||
|
|
||||||
|
const isMailboxEligible = allMailboxFormats && physicalQty <= MAX_MAILBOX_ITEMS
|
||||||
|
|
||||||
|
// Intercept the response to filter shipping options
|
||||||
|
const originalJson = res.json.bind(res)
|
||||||
|
res.json = ((body: any) => {
|
||||||
|
if (body?.shipping_options) {
|
||||||
|
body.shipping_options = body.shipping_options.filter((opt: any) => {
|
||||||
|
const code = opt.type?.code
|
||||||
|
if (code === "mailbox-package") return isMailboxEligible
|
||||||
|
if (code === "package") return !isMailboxEligible
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return originalJson(body)
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
38
src/jobs/README.md
Normal file
38
src/jobs/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Custom scheduled jobs
|
||||||
|
|
||||||
|
A scheduled job is a function executed at a specified interval of time in the background of your Medusa application.
|
||||||
|
|
||||||
|
> Learn more about scheduled jobs in [this documentation](https://docs.medusajs.com/learn/fundamentals/scheduled-jobs).
|
||||||
|
|
||||||
|
A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory.
|
||||||
|
|
||||||
|
For example, create the file `src/jobs/hello-world.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
MedusaContainer
|
||||||
|
} from "@medusajs/framework/types";
|
||||||
|
|
||||||
|
export default async function myCustomJob(container: MedusaContainer) {
|
||||||
|
const productService = container.resolve("product")
|
||||||
|
|
||||||
|
const products = await productService.listAndCountProducts();
|
||||||
|
|
||||||
|
// Do something with the products
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
name: "daily-product-report",
|
||||||
|
schedule: "0 0 * * *", // Every day at midnight
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
A scheduled job file must export:
|
||||||
|
|
||||||
|
- The function to be executed whenever it’s time to run the scheduled job.
|
||||||
|
- A configuration object defining the job. It has three properties:
|
||||||
|
- `name`: a unique name for the job.
|
||||||
|
- `schedule`: a [cron expression](https://crontab.guru/).
|
||||||
|
- `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed
|
||||||
|
|
||||||
|
The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services.
|
||||||
67
src/jobs/fulfill-preorders.ts
Normal file
67
src/jobs/fulfill-preorders.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { MedusaContainer } from "@medusajs/framework/types"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
import { fulfillDigitalItemsWorkflow } from "../workflows/fulfill-digital-items"
|
||||||
|
import { getDigitalItemsReadyForFulfillment } from "../lib/digital-fulfillment-utils"
|
||||||
|
|
||||||
|
export default async function fulfillPreordersJob(container: MedusaContainer) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
|
||||||
|
logger.info("[fulfill-preorders] Checking for pre-orders ready to fulfill...")
|
||||||
|
|
||||||
|
// Fetch orders that are not fully fulfilled yet
|
||||||
|
const { data: orders } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
filters: {
|
||||||
|
status: { $in: ["pending", "requires_action"] },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"display_id",
|
||||||
|
"items.*",
|
||||||
|
"items.detail.*",
|
||||||
|
"items.variant.*",
|
||||||
|
"items.variant.product.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
let totalFulfilled = 0
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const digitalItemsToFulfill = getDigitalItemsReadyForFulfillment(
|
||||||
|
(order.items ?? []) as any[],
|
||||||
|
{ checkFulfilledQty: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (digitalItemsToFulfill.length === 0) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fulfillDigitalItemsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
order_id: order.id,
|
||||||
|
items: digitalItemsToFulfill.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
totalFulfilled += digitalItemsToFulfill.length
|
||||||
|
logger.info(
|
||||||
|
`[fulfill-preorders] Fulfilled ${digitalItemsToFulfill.length} digital item(s) ` +
|
||||||
|
`for order #${order.display_id} (${order.id})`
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(
|
||||||
|
`[fulfill-preorders] Failed to fulfill order #${order.display_id}: ${err.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[fulfill-preorders] Done. Fulfilled ${totalFulfilled} item(s) total.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
name: "fulfill-preorders",
|
||||||
|
schedule: "0 6 * * *", // Daily at 6 AM
|
||||||
|
}
|
||||||
18
src/lib/blocked-skus.ts
Normal file
18
src/lib/blocked-skus.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export async function getBlockedSkus(
|
||||||
|
query: any,
|
||||||
|
customerId: string
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const { data: customers } = await query.graph({
|
||||||
|
entity: "customer",
|
||||||
|
filters: { id: customerId },
|
||||||
|
fields: ["id", "download_grants.*"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const grants = (customers[0] as any)?.download_grants ?? []
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
grants
|
||||||
|
.filter((g: any) => g.type === "block")
|
||||||
|
.map((g: any) => g.sku)
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/lib/digital-fulfillment-utils.ts
Normal file
96
src/lib/digital-fulfillment-utils.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { isReleased } from "./release-date"
|
||||||
|
|
||||||
|
export interface OrderItemForFulfillment {
|
||||||
|
id: string
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
id?: string
|
||||||
|
quantity?: number
|
||||||
|
variant?: {
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
title?: string
|
||||||
|
product?: {
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detail?: {
|
||||||
|
fulfilled_quantity?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a variant represents a digital item.
|
||||||
|
*/
|
||||||
|
export function isDigitalItem(variant: { metadata?: Record<string, unknown> } | null | undefined): boolean {
|
||||||
|
return variant?.metadata?.type === "digital"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts digital line items from an order that are released and ready for
|
||||||
|
* fulfillment. Used by both the order.placed subscriber and the
|
||||||
|
* fulfill-preorders scheduled job.
|
||||||
|
*
|
||||||
|
* @param items - The order items (with variant and product relations)
|
||||||
|
* @param opts.checkFulfilledQty - When true, items already fulfilled are
|
||||||
|
* excluded and only the remaining quantity is returned. This is used by
|
||||||
|
* the preorder job where items may have been partially fulfilled.
|
||||||
|
* @param opts.logger - Optional logger for skip messages
|
||||||
|
* @param opts.logPrefix - Prefix for log messages (e.g. "[digital-fulfillment]")
|
||||||
|
*/
|
||||||
|
export function getDigitalItemsReadyForFulfillment(
|
||||||
|
items: OrderItem[],
|
||||||
|
opts: {
|
||||||
|
checkFulfilledQty?: boolean
|
||||||
|
logger?: { info: (msg: string) => void }
|
||||||
|
logPrefix?: string
|
||||||
|
} = {}
|
||||||
|
): OrderItemForFulfillment[] {
|
||||||
|
const { checkFulfilledQty = false, logger, logPrefix = "" } = opts
|
||||||
|
const result: OrderItemForFulfillment[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item) continue
|
||||||
|
|
||||||
|
const variant = item.variant
|
||||||
|
if (!variant) continue
|
||||||
|
|
||||||
|
if (!isDigitalItem(variant)) continue
|
||||||
|
|
||||||
|
// Check if item is already fully fulfilled (preorder job mode)
|
||||||
|
if (checkFulfilledQty) {
|
||||||
|
const fulfilledQty = item.detail?.fulfilled_quantity ?? 0
|
||||||
|
const totalQty = item.quantity ?? 1
|
||||||
|
if (fulfilledQty >= totalQty) continue
|
||||||
|
|
||||||
|
// Check if the product is now released
|
||||||
|
const product = variant.product
|
||||||
|
if (!isReleased(product?.metadata)) continue
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: item.id!,
|
||||||
|
quantity: totalQty - fulfilledQty,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Subscriber mode: check release date, skip pre-orders
|
||||||
|
const product = variant.product
|
||||||
|
if (!isReleased(product?.metadata)) {
|
||||||
|
if (logger) {
|
||||||
|
logger.info(
|
||||||
|
`${logPrefix} Skipping pre-order item "${variant.title}" ` +
|
||||||
|
`(release date: ${product?.metadata?.release_date})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: item.id!,
|
||||||
|
quantity: item.quantity ?? 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
24
src/lib/release-date.ts
Normal file
24
src/lib/release-date.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Checks whether a product's release date has passed (or is today).
|
||||||
|
* If no release_date is set in metadata, the product is treated as released.
|
||||||
|
*/
|
||||||
|
export function isReleased(metadata: Record<string, unknown> | null | undefined): boolean {
|
||||||
|
const releaseDate = metadata?.release_date as string | undefined
|
||||||
|
if (!releaseDate) return true
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
const release = new Date(releaseDate)
|
||||||
|
release.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
return release <= today
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the release date string from product metadata, or null if not set.
|
||||||
|
*/
|
||||||
|
export function getReleaseDate(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||||
|
const releaseDate = metadata?.release_date as string | undefined
|
||||||
|
return releaseDate ?? null
|
||||||
|
}
|
||||||
51
src/lib/s3-download.ts
Normal file
51
src/lib/s3-download.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
|
|
||||||
|
const PRESIGNED_URL_EXPIRY_SECONDS = 300 // 5 minutes
|
||||||
|
|
||||||
|
let s3Client: S3Client | null = null
|
||||||
|
|
||||||
|
function getS3Client(): S3Client {
|
||||||
|
if (!s3Client) {
|
||||||
|
s3Client = new S3Client({
|
||||||
|
endpoint: process.env.HETZNER_S3_ENDPOINT,
|
||||||
|
region: process.env.HETZNER_S3_REGION || "fsn1",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.HETZNER_S3_ACCESS_KEY || "",
|
||||||
|
secretAccessKey: process.env.HETZNER_S3_SECRET_KEY || "",
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return s3Client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the S3 object key from a variant SKU.
|
||||||
|
*
|
||||||
|
* Convention: SKU "TTK0001_352K24B2CH" → key "ttk0001/ttk0001_352k24b2ch.zip"
|
||||||
|
*/
|
||||||
|
export function skuToS3Key(sku: string): string {
|
||||||
|
const lower = sku.toLowerCase()
|
||||||
|
const catalogueNumber = lower.split("_")[0]
|
||||||
|
return `${catalogueNumber}/${lower}.zip`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a presigned download URL for a digital variant file
|
||||||
|
* stored in Hetzner Object Storage.
|
||||||
|
*/
|
||||||
|
export async function getPresignedDownloadUrl(sku: string): Promise<string> {
|
||||||
|
const client = getS3Client()
|
||||||
|
const bucket = process.env.HETZNER_S3_BUCKET || "trptk-downloads"
|
||||||
|
const key = skuToS3Key(sku)
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
|
||||||
|
return getSignedUrl(client, command, {
|
||||||
|
expiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
})
|
||||||
|
}
|
||||||
26
src/links/README.md
Normal file
26
src/links/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Module Links
|
||||||
|
|
||||||
|
A module link forms an association between two data models of different modules, while maintaining module isolation.
|
||||||
|
|
||||||
|
> Learn more about links in [this documentation](https://docs.medusajs.com/learn/fundamentals/module-links)
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import BlogModule from "../modules/blog"
|
||||||
|
import ProductModule from "@medusajs/medusa/product"
|
||||||
|
import { defineLink } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export default defineLink(
|
||||||
|
ProductModule.linkable.product,
|
||||||
|
BlogModule.linkable.post
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This defines a link between the Product Module's `product` data model and the Blog Module (custom module)'s `post` data model.
|
||||||
|
|
||||||
|
Then, in the Medusa application, run the following command to sync the links to the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa db:migrate
|
||||||
|
```
|
||||||
12
src/links/customer-download-grant.ts
Normal file
12
src/links/customer-download-grant.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineLink } from "@medusajs/framework/utils"
|
||||||
|
import CustomerModule from "@medusajs/medusa/customer"
|
||||||
|
import DownloadGrantModule from "../modules/downloadGrant"
|
||||||
|
|
||||||
|
export default defineLink(
|
||||||
|
CustomerModule.linkable.customer,
|
||||||
|
{
|
||||||
|
linkable: DownloadGrantModule.linkable.downloadGrant,
|
||||||
|
isList: true,
|
||||||
|
deleteCascade: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
117
src/modules/README.md
Normal file
117
src/modules/README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Custom Module
|
||||||
|
|
||||||
|
A module is a package of reusable functionalities. It can be integrated into your Medusa application without affecting the overall system. You can create a module as part of a plugin.
|
||||||
|
|
||||||
|
> Learn more about modules in [this documentation](https://docs.medusajs.com/learn/fundamentals/modules).
|
||||||
|
|
||||||
|
To create a module:
|
||||||
|
|
||||||
|
## 1. Create a Data Model
|
||||||
|
|
||||||
|
A data model represents a table in the database. You create a data model in a TypeScript or JavaScript file under the `models` directory of a module.
|
||||||
|
|
||||||
|
For example, create the file `src/modules/blog/models/post.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { model } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
const Post = model.define("post", {
|
||||||
|
id: model.id().primaryKey(),
|
||||||
|
title: model.text(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Post
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Create a Service
|
||||||
|
|
||||||
|
A module must define a service. A service is a TypeScript or JavaScript class holding methods related to a business logic or commerce functionality.
|
||||||
|
|
||||||
|
For example, create the file `src/modules/blog/service.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MedusaService } from "@medusajs/framework/utils"
|
||||||
|
import Post from "./models/post"
|
||||||
|
|
||||||
|
class BlogModuleService extends MedusaService({
|
||||||
|
Post,
|
||||||
|
}){
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogModuleService
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Export Module Definition
|
||||||
|
|
||||||
|
A module must have an `index.ts` file in its root directory that exports its definition. The definition specifies the main service of the module.
|
||||||
|
|
||||||
|
For example, create the file `src/modules/blog/index.ts` with the following content:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import BlogModuleService from "./service"
|
||||||
|
import { Module } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export const BLOG_MODULE = "blog"
|
||||||
|
|
||||||
|
export default Module(BLOG_MODULE, {
|
||||||
|
service: BlogModuleService,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Add Module to Medusa's Configurations
|
||||||
|
|
||||||
|
To start using the module, add it to `medusa-config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
module.exports = defineConfig({
|
||||||
|
projectConfig: {
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
resolve: "./src/modules/blog",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Generate and Run Migrations
|
||||||
|
|
||||||
|
To generate migrations for your module, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa db:generate blog
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, to run migrations, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Module
|
||||||
|
|
||||||
|
You can use the module in customizations within the Medusa application, such as workflows and API routes.
|
||||||
|
|
||||||
|
For example, to use the module in an API route:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
|
||||||
|
import BlogModuleService from "../../../modules/blog/service"
|
||||||
|
import { BLOG_MODULE } from "../../../modules/blog"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: MedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
): Promise<void> {
|
||||||
|
const blogModuleService: BlogModuleService = req.scope.resolve(
|
||||||
|
BLOG_MODULE
|
||||||
|
)
|
||||||
|
|
||||||
|
const posts = await blogModuleService.listPosts()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
posts
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
8
src/modules/downloadGrant/index.ts
Normal file
8
src/modules/downloadGrant/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import DownloadGrantModuleService from "./service"
|
||||||
|
import { Module } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
export const DOWNLOAD_GRANT_MODULE = "downloadGrant"
|
||||||
|
|
||||||
|
export default Module(DOWNLOAD_GRANT_MODULE, {
|
||||||
|
service: DownloadGrantModuleService,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
{
|
||||||
|
"namespaces": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"name": "public",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"sku": {
|
||||||
|
"name": "sku",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"product_title": {
|
||||||
|
"name": "product_title",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"variant_title": {
|
||||||
|
"name": "variant_title",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"name": "note",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"mappedType": "text"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"default": "'grant'",
|
||||||
|
"enumItems": [
|
||||||
|
"grant",
|
||||||
|
"block"
|
||||||
|
],
|
||||||
|
"mappedType": "enum"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 6,
|
||||||
|
"default": "now()",
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": false,
|
||||||
|
"length": 6,
|
||||||
|
"default": "now()",
|
||||||
|
"mappedType": "datetime"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamptz",
|
||||||
|
"unsigned": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"primary": false,
|
||||||
|
"nullable": true,
|
||||||
|
"length": 6,
|
||||||
|
"mappedType": "datetime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "download_grant",
|
||||||
|
"schema": "public",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "IDX_download_grant_deleted_at",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_download_grant_deleted_at\" ON \"download_grant\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyName": "download_grant_pkey",
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"composite": false,
|
||||||
|
"constraint": true,
|
||||||
|
"primary": true,
|
||||||
|
"unique": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": [],
|
||||||
|
"foreignKeys": {},
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nativeEnums": {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
||||||
|
|
||||||
|
export class Migration20260223180641 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`create table if not exists "download_grant" ("id" text not null, "sku" text not null, "product_title" text not null, "variant_title" text not null, "note" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "download_grant_pkey" primary key ("id"));`);
|
||||||
|
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_download_grant_deleted_at" ON "download_grant" ("deleted_at") WHERE deleted_at IS NULL;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop table if exists "download_grant" cascade;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
||||||
|
|
||||||
|
export class Migration20260223184017 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table if exists "download_grant" add column if not exists "type" text check ("type" in ('grant', 'block')) not null default 'grant';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table if exists "download_grant" drop column if exists "type";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
12
src/modules/downloadGrant/models/download-grant.ts
Normal file
12
src/modules/downloadGrant/models/download-grant.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { model } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
const DownloadGrant = model.define("download_grant", {
|
||||||
|
id: model.id().primaryKey(),
|
||||||
|
sku: model.text(),
|
||||||
|
product_title: model.text(),
|
||||||
|
variant_title: model.text(),
|
||||||
|
note: model.text().nullable(),
|
||||||
|
type: model.enum(["grant", "block"]).default("grant"),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default DownloadGrant
|
||||||
8
src/modules/downloadGrant/service.ts
Normal file
8
src/modules/downloadGrant/service.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { MedusaService } from "@medusajs/framework/utils"
|
||||||
|
import DownloadGrant from "./models/download-grant"
|
||||||
|
|
||||||
|
class DownloadGrantModuleService extends MedusaService({
|
||||||
|
DownloadGrant,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export default DownloadGrantModuleService
|
||||||
63
src/scripts/README.md
Normal file
63
src/scripts/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Custom CLI Script
|
||||||
|
|
||||||
|
A custom CLI script is a function to execute through Medusa's CLI tool. This is useful when creating custom Medusa tooling to run as a CLI tool.
|
||||||
|
|
||||||
|
> Learn more about custom CLI scripts in [this documentation](https://docs.medusajs.com/learn/fundamentals/custom-cli-scripts).
|
||||||
|
|
||||||
|
## How to Create a Custom CLI Script?
|
||||||
|
|
||||||
|
To create a custom CLI script, create a TypeScript or JavaScript file under the `src/scripts` directory. The file must default export a function.
|
||||||
|
|
||||||
|
For example, create the file `src/scripts/my-script.ts` with the following content:
|
||||||
|
|
||||||
|
```ts title="src/scripts/my-script.ts"
|
||||||
|
import {
|
||||||
|
ExecArgs,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
|
|
||||||
|
export default async function myScript ({
|
||||||
|
container
|
||||||
|
}: ExecArgs) {
|
||||||
|
const productModuleService = container.resolve("product")
|
||||||
|
|
||||||
|
const [, count] = await productModuleService.listAndCountProducts()
|
||||||
|
|
||||||
|
console.log(`You have ${count} product(s)`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The function receives as a parameter an object having a `container` property, which is an instance of the Medusa Container. Use it to resolve resources in your Medusa application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Run Custom CLI Script?
|
||||||
|
|
||||||
|
To run the custom CLI script, run the `exec` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa exec ./src/scripts/my-script.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom CLI Script Arguments
|
||||||
|
|
||||||
|
Your script can accept arguments from the command line. Arguments are passed to the function's object parameter in the `args` property.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ExecArgs } from "@medusajs/framework/types"
|
||||||
|
|
||||||
|
export default async function myScript ({
|
||||||
|
args
|
||||||
|
}: ExecArgs) {
|
||||||
|
console.log(`The arguments you passed: ${args}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, pass the arguments in the `exec` command after the file path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx medusa exec ./src/scripts/my-script.ts arg1 arg2
|
||||||
|
```
|
||||||
175
src/scripts/create-products.ts
Normal file
175
src/scripts/create-products.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { CreateInventoryLevelInput, ExecArgs } from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
Modules,
|
||||||
|
ProductStatus,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createInventoryLevelsWorkflow,
|
||||||
|
createProductsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
import {
|
||||||
|
ALL_FORMATS,
|
||||||
|
buildVariant,
|
||||||
|
TRPTKRelease,
|
||||||
|
} from "./trptk-formats"
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// ADD YOUR RELEASES HERE
|
||||||
|
// ===========================================================================
|
||||||
|
// Each release will automatically get all format variants with default
|
||||||
|
// pricing and auto-generated SKUs (e.g. TTK0001_CD, TTK0001_88K24B2CH).
|
||||||
|
//
|
||||||
|
// Options:
|
||||||
|
// formats — provide a specific list of formats (overrides default)
|
||||||
|
// excludeFormats — exclude specific formats from the default set
|
||||||
|
// priceOverrides — override default price for specific formats (in EUR)
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// Standard release (all formats, default pricing):
|
||||||
|
// { catalogueNumber: "TTK0001", title: "Album Title", ean: "0608917722017" }
|
||||||
|
//
|
||||||
|
// Release with only physical + stereo formats:
|
||||||
|
// {
|
||||||
|
// catalogueNumber: "TTK0002",
|
||||||
|
// title: "Album Title",
|
||||||
|
// ean: "0608917722024",
|
||||||
|
// excludeFormats: ["FLAC Surround 88.2/24", "FLAC Surround 176.4/24", ...],
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 2-CD box set (custom price for CD format):
|
||||||
|
// {
|
||||||
|
// catalogueNumber: "TTK0003",
|
||||||
|
// title: "Box Set Album (2-CD)",
|
||||||
|
// ean: "0608917722031",
|
||||||
|
// priceOverrides: { "CD": 3500 }, // €35 for the 2-CD set
|
||||||
|
// }
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
const RELEASES: TRPTKRelease[] = [
|
||||||
|
// Add your releases below. Example:
|
||||||
|
// {
|
||||||
|
// catalogueNumber: "TTK0001",
|
||||||
|
// title: "Bach: Goldberg Variations",
|
||||||
|
// ean: "0608917722017",
|
||||||
|
// },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Script logic — no need to edit below this line
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
export default async function createProducts({ container }: ExecArgs) {
|
||||||
|
if (RELEASES.length === 0) {
|
||||||
|
console.log("No releases defined. Add entries to the RELEASES array in create-products.ts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const salesChannelModuleService = container.resolve(Modules.SALES_CHANNEL)
|
||||||
|
const fulfillmentModuleService = container.resolve(Modules.FULFILLMENT)
|
||||||
|
const storeModuleService = container.resolve(Modules.STORE)
|
||||||
|
|
||||||
|
// Get existing sales channel
|
||||||
|
const [salesChannel] = await salesChannelModuleService.listSalesChannels({
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
})
|
||||||
|
if (!salesChannel) {
|
||||||
|
throw new Error("Default Sales Channel not found. Run the seed script first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shipping profile
|
||||||
|
const shippingProfiles =
|
||||||
|
await fulfillmentModuleService.listShippingProfiles({ type: "default" })
|
||||||
|
const shippingProfile = shippingProfiles[0]
|
||||||
|
if (!shippingProfile) {
|
||||||
|
throw new Error("Default shipping profile not found. Run the seed script first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stock location
|
||||||
|
const [store] = await storeModuleService.listStores()
|
||||||
|
const stockLocationId = store.default_location_id
|
||||||
|
if (!stockLocationId) {
|
||||||
|
throw new Error("No default stock location. Run the seed script first.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build products
|
||||||
|
const products = RELEASES.map((release) => {
|
||||||
|
// Determine which formats to use
|
||||||
|
let formats: string[]
|
||||||
|
if (release.formats) {
|
||||||
|
formats = release.formats
|
||||||
|
} else if (release.excludeFormats) {
|
||||||
|
const excluded = new Set(release.excludeFormats)
|
||||||
|
formats = ALL_FORMATS.filter((f) => !excluded.has(f))
|
||||||
|
} else {
|
||||||
|
formats = ALL_FORMATS
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = formats.map((format) =>
|
||||||
|
buildVariant(release.catalogueNumber, format, release.priceOverrides?.[format])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: release.title,
|
||||||
|
description: release.description ?? "",
|
||||||
|
handle: release.catalogueNumber.toLowerCase(),
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
external_id: release.catalogueNumber,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
metadata: {
|
||||||
|
ean: release.ean,
|
||||||
|
catalogue_number: release.catalogueNumber,
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
title: "Format",
|
||||||
|
values: formats,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants,
|
||||||
|
sales_channels: [{ id: salesChannel.id }],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Creating ${products.length} product(s)...`)
|
||||||
|
await createProductsWorkflow(container).run({
|
||||||
|
input: { products },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set inventory levels for physical variants
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get existing inventory levels to avoid duplicates
|
||||||
|
const { data: existingLevels } = await query.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["inventory_item_id"],
|
||||||
|
})
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
existingLevels.map((l: { inventory_item_id: string }) => l.inventory_item_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newLevels: CreateInventoryLevelInput[] = inventoryItems
|
||||||
|
.filter((item: { id: string }) => !existingItemIds.has(item.id))
|
||||||
|
.map((item: { id: string }) => ({
|
||||||
|
location_id: stockLocationId,
|
||||||
|
stocked_quantity: 100,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newLevels.length) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: { inventory_levels: newLevels },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const release of RELEASES) {
|
||||||
|
logger.info(`Created: ${release.catalogueNumber} — ${release.title}`)
|
||||||
|
}
|
||||||
|
logger.info("Done!")
|
||||||
|
}
|
||||||
136
src/scripts/link-products.ts
Normal file
136
src/scripts/link-products.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import {
|
||||||
|
CreateInventoryLevelInput,
|
||||||
|
ExecArgs,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import { createInventoryLevelsWorkflow } from "@medusajs/medusa/core-flows"
|
||||||
|
|
||||||
|
export default async function linkProducts({ container }: ExecArgs) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const link = container.resolve(ContainerRegistrationKeys.LINK)
|
||||||
|
const salesChannelModule = container.resolve(Modules.SALES_CHANNEL)
|
||||||
|
const stockLocationModule = container.resolve(Modules.STOCK_LOCATION)
|
||||||
|
|
||||||
|
// 1. Get the single sales channel and stock location
|
||||||
|
const [salesChannel] = await salesChannelModule.listSalesChannels({})
|
||||||
|
const [stockLocation] = await stockLocationModule.listStockLocations({})
|
||||||
|
|
||||||
|
if (!salesChannel) {
|
||||||
|
logger.error("No sales channel found!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!stockLocation) {
|
||||||
|
logger.error("No stock location found!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Sales channel: "${salesChannel.name}" (${salesChannel.id})`
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
`Stock location: "${stockLocation.name}" (${stockLocation.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Get all products and link to sales channel
|
||||||
|
const { data: products } = await query.graph({
|
||||||
|
entity: "product",
|
||||||
|
fields: ["id", "title", "sales_channels.id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Found ${products.length} products.`)
|
||||||
|
|
||||||
|
let linkedCount = 0
|
||||||
|
for (const product of products) {
|
||||||
|
const alreadyLinked = product.sales_channels?.some(
|
||||||
|
(sc) => sc && sc.id === salesChannel.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyLinked) {
|
||||||
|
await link.create({
|
||||||
|
[Modules.PRODUCT]: { product_id: product.id },
|
||||||
|
[Modules.SALES_CHANNEL]: { sales_channel_id: salesChannel.id },
|
||||||
|
})
|
||||||
|
linkedCount++
|
||||||
|
logger.info(`Linked product "${product.title}" to sales channel.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`Linked ${linkedCount} products to sales channel (${products.length - linkedCount} already linked).`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Get all inventory items and create inventory levels at the stock location
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check which already have levels at this location
|
||||||
|
const { data: existingLevels } = await query.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["id", "inventory_item_id"],
|
||||||
|
filters: {
|
||||||
|
location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
existingLevels.map((l: { inventory_item_id: string }) => l.inventory_item_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newLevels: CreateInventoryLevelInput[] = inventoryItems
|
||||||
|
.filter((item: { id: string }) => !existingItemIds.has(item.id))
|
||||||
|
.map((item: { id: string }) => ({
|
||||||
|
location_id: stockLocation.id,
|
||||||
|
stocked_quantity: 0,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newLevels.length) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: { inventory_levels: newLevels },
|
||||||
|
})
|
||||||
|
logger.info(
|
||||||
|
`Created ${newLevels.length} inventory levels at "${stockLocation.name}".`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info("All inventory items already have levels at this location.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update all inventory levels at this location to the desired stocked quantity
|
||||||
|
const STOCKED_QUANTITY = 500
|
||||||
|
|
||||||
|
const { data: allLevels } = await query.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["id", "inventory_item_id", "stocked_quantity"],
|
||||||
|
filters: {
|
||||||
|
location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const inventoryModule = container.resolve(Modules.INVENTORY)
|
||||||
|
const levelsToUpdate = allLevels
|
||||||
|
.filter(
|
||||||
|
(l: { stocked_quantity: number }) =>
|
||||||
|
l.stocked_quantity !== STOCKED_QUANTITY
|
||||||
|
)
|
||||||
|
.map((l: { inventory_item_id: string }) => ({
|
||||||
|
inventory_item_id: l.inventory_item_id,
|
||||||
|
location_id: stockLocation.id,
|
||||||
|
stocked_quantity: STOCKED_QUANTITY,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (levelsToUpdate.length) {
|
||||||
|
await inventoryModule.updateInventoryLevels(levelsToUpdate)
|
||||||
|
logger.info(
|
||||||
|
`Updated ${levelsToUpdate.length} inventory levels to stocked_quantity: ${STOCKED_QUANTITY}.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info("All inventory levels already at target quantity.")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Done! All products linked and inventory levels updated.")
|
||||||
|
}
|
||||||
10
src/scripts/sanity-client.ts
Normal file
10
src/scripts/sanity-client.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createClient } from "@sanity/client"
|
||||||
|
|
||||||
|
export function getSanityClient(useCdn = true) {
|
||||||
|
return createClient({
|
||||||
|
projectId: process.env.SANITY_PROJECT_ID,
|
||||||
|
dataset: process.env.SANITY_DATASET || "production",
|
||||||
|
apiVersion: "2024-01-01",
|
||||||
|
useCdn,
|
||||||
|
})
|
||||||
|
}
|
||||||
519
src/scripts/seed.ts
Normal file
519
src/scripts/seed.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
import { CreateInventoryLevelInput, ExecArgs } from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
Modules,
|
||||||
|
ProductStatus,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createWorkflow,
|
||||||
|
transform,
|
||||||
|
WorkflowResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import {
|
||||||
|
createApiKeysWorkflow,
|
||||||
|
createInventoryLevelsWorkflow,
|
||||||
|
createProductsWorkflow,
|
||||||
|
createRegionsWorkflow,
|
||||||
|
createSalesChannelsWorkflow,
|
||||||
|
createShippingOptionsWorkflow,
|
||||||
|
createShippingProfilesWorkflow,
|
||||||
|
createStockLocationsWorkflow,
|
||||||
|
createTaxRegionsWorkflow,
|
||||||
|
linkSalesChannelsToApiKeyWorkflow,
|
||||||
|
linkSalesChannelsToStockLocationWorkflow,
|
||||||
|
updateStoresStep,
|
||||||
|
updateStoresWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
import { ApiKey } from "../../.medusa/types/query-entry-points"
|
||||||
|
|
||||||
|
import { ALL_FORMATS, buildVariant } from "./trptk-formats"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workflow: update store currencies
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const updateStoreCurrencies = createWorkflow(
|
||||||
|
"update-store-currencies",
|
||||||
|
(input: {
|
||||||
|
supported_currencies: {
|
||||||
|
currency_code: string
|
||||||
|
is_default?: boolean
|
||||||
|
}[]
|
||||||
|
store_id: string
|
||||||
|
}) => {
|
||||||
|
const normalizedInput = transform({ input }, (data) => {
|
||||||
|
return {
|
||||||
|
selector: { id: data.input.store_id },
|
||||||
|
update: {
|
||||||
|
supported_currencies: data.input.supported_currencies.map(
|
||||||
|
(currency) => ({
|
||||||
|
currency_code: currency.currency_code,
|
||||||
|
is_default: currency.is_default ?? false,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stores = updateStoresStep(normalizedInput)
|
||||||
|
return new WorkflowResponse(stores)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VAT rates for countries where we collect tax (standard rates as of 2025)
|
||||||
|
// Countries not listed here get 0% tax automatically.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const VAT_RATES: Record<string, number> = {
|
||||||
|
// EU member states
|
||||||
|
nl: 21, de: 19, fr: 20, it: 22, es: 21, be: 21, at: 20,
|
||||||
|
pl: 23, cz: 21, dk: 25, se: 25, fi: 25.5, ie: 23, pt: 23,
|
||||||
|
bg: 20, hr: 21, cy: 19, ee: 22, gr: 24, hu: 27, lv: 21,
|
||||||
|
lt: 21, lu: 17, mt: 18, ro: 19, sk: 23, si: 22,
|
||||||
|
// Non-EU European
|
||||||
|
gb: 20, ch: 8.1, no: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All supported countries (worldwide except RU and IR)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// prettier-ignore
|
||||||
|
const allCountries = [
|
||||||
|
// Europe
|
||||||
|
"al", "ad", "at", "be", "ba", "bg", "hr", "cy", "cz", "dk",
|
||||||
|
"ee", "fi", "fr", "de", "gr", "hu", "is", "ie", "it", "xk",
|
||||||
|
"lv", "li", "lt", "lu", "mk", "mt", "md", "mc", "me", "nl",
|
||||||
|
"no", "pl", "pt", "ro", "sm", "rs", "sk", "si", "es", "se",
|
||||||
|
"ch", "ua", "gb", "va",
|
||||||
|
// North America
|
||||||
|
"us", "ca", "mx",
|
||||||
|
// Central America & Caribbean
|
||||||
|
"bz", "cr", "sv", "gt", "hn", "ni", "pa",
|
||||||
|
"ag", "bs", "bb", "cu", "dm", "do", "gd", "ht", "jm", "kn",
|
||||||
|
"lc", "vc", "tt",
|
||||||
|
// South America
|
||||||
|
"ar", "bo", "br", "cl", "co", "ec", "gy", "py", "pe", "sr",
|
||||||
|
"uy", "ve",
|
||||||
|
// East Asia & Pacific
|
||||||
|
"au", "bn", "kh", "cn", "fj", "hk", "id", "jp", "kr", "la",
|
||||||
|
"mo", "my", "mn", "mm", "nz", "pg", "ph", "sg", "tw", "th",
|
||||||
|
"tl", "vn",
|
||||||
|
// South Asia
|
||||||
|
"af", "bd", "bt", "in", "mv", "np", "pk", "lk",
|
||||||
|
// Central Asia
|
||||||
|
"kz", "kg", "tj", "tm", "uz",
|
||||||
|
// Middle East (excluding IR)
|
||||||
|
"bh", "iq", "il", "jo", "kw", "lb", "om", "ps", "qa", "sa",
|
||||||
|
"ae", "ye",
|
||||||
|
// Africa
|
||||||
|
"dz", "ao", "bj", "bw", "bf", "bi", "cv", "cm", "cf", "td",
|
||||||
|
"km", "cd", "cg", "ci", "dj", "eg", "gq", "er", "sz", "et",
|
||||||
|
"ga", "gm", "gh", "gn", "gw", "ke", "ls", "lr", "ly", "mg",
|
||||||
|
"mw", "ml", "mr", "mu", "ma", "mz", "na", "ne", "ng", "rw",
|
||||||
|
"st", "sn", "sc", "sl", "so", "za", "ss", "sd", "tz", "tg",
|
||||||
|
"tn", "ug", "zm", "zw",
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shipping zones — country codes grouped by shipping cost tier
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ZONE_NL = ["nl"]
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const ZONE_EU1 = [
|
||||||
|
"be", "dk", "de", "fr", "it", "lu", "at", "es", "se",
|
||||||
|
]
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const ZONE_EU2 = [
|
||||||
|
"bg", "ee", "fi", "hu", "ie", "hr", "lv", "lt",
|
||||||
|
"pl", "pt", "ro", "si", "sk", "cz", "gb", "ch",
|
||||||
|
]
|
||||||
|
|
||||||
|
const ZONE_ROW = allCountries.filter(
|
||||||
|
(cc) =>
|
||||||
|
!ZONE_NL.includes(cc) &&
|
||||||
|
!ZONE_EU1.includes(cc) &&
|
||||||
|
!ZONE_EU2.includes(cc)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Main seed function
|
||||||
|
// ===========================================================================
|
||||||
|
export default async function seedTRPTKData({ container }: ExecArgs) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const link = container.resolve(ContainerRegistrationKeys.LINK)
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const fulfillmentModuleService = container.resolve(Modules.FULFILLMENT)
|
||||||
|
const salesChannelModuleService = container.resolve(Modules.SALES_CHANNEL)
|
||||||
|
const storeModuleService = container.resolve(Modules.STORE)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. Store settings
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Setting up TRPTK store...")
|
||||||
|
const [store] = await storeModuleService.listStores()
|
||||||
|
|
||||||
|
let defaultSalesChannel = await salesChannelModuleService.listSalesChannels({
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!defaultSalesChannel.length) {
|
||||||
|
const { result: salesChannelResult } = await createSalesChannelsWorkflow(
|
||||||
|
container
|
||||||
|
).run({
|
||||||
|
input: {
|
||||||
|
salesChannelsData: [
|
||||||
|
{
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defaultSalesChannel = salesChannelResult
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateStoreCurrencies(container).run({
|
||||||
|
input: {
|
||||||
|
store_id: store.id,
|
||||||
|
supported_currencies: [
|
||||||
|
{
|
||||||
|
currency_code: "eur",
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateStoresWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
selector: { id: store.id },
|
||||||
|
update: {
|
||||||
|
name: "TRPTK",
|
||||||
|
default_sales_channel_id: defaultSalesChannel[0].id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.info("Store configured: TRPTK, EUR (tax-inclusive).")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. Region (single Worldwide region, EUR, tax-inclusive)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding region...")
|
||||||
|
const { result: regionResult } = await createRegionsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
regions: [
|
||||||
|
{
|
||||||
|
name: "Worldwide",
|
||||||
|
currency_code: "eur",
|
||||||
|
countries: allCountries,
|
||||||
|
is_tax_inclusive: true,
|
||||||
|
payment_providers: ["pp_system_default"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const worldwideRegion = regionResult[0]
|
||||||
|
logger.info("Region created: Worldwide.")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 3. Tax regions with VAT rates (only for countries that collect tax)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding tax regions...")
|
||||||
|
const vatCountries = Object.keys(VAT_RATES)
|
||||||
|
await createTaxRegionsWorkflow(container).run({
|
||||||
|
input: vatCountries.map((country_code) => ({
|
||||||
|
country_code,
|
||||||
|
provider_id: "tp_system",
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set default tax rates per country
|
||||||
|
const taxModule = container.resolve(Modules.TAX)
|
||||||
|
for (const [countryCode, rate] of Object.entries(VAT_RATES)) {
|
||||||
|
// Find the tax region for this country
|
||||||
|
const taxRegions = await taxModule.listTaxRegions({
|
||||||
|
country_code: countryCode,
|
||||||
|
})
|
||||||
|
if (taxRegions.length > 0) {
|
||||||
|
await taxModule.createTaxRates({
|
||||||
|
tax_region_id: taxRegions[0].id,
|
||||||
|
name: `VAT ${countryCode.toUpperCase()} ${rate}%`,
|
||||||
|
code: `vat-${countryCode}`,
|
||||||
|
rate,
|
||||||
|
is_default: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("Tax regions and rates created.")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 4. Stock location
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding stock location...")
|
||||||
|
const { result: stockLocationResult } = await createStockLocationsWorkflow(
|
||||||
|
container
|
||||||
|
).run({
|
||||||
|
input: {
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
name: "TRPTK Warehouse",
|
||||||
|
address: {
|
||||||
|
city: "Amsterdam",
|
||||||
|
country_code: "NL",
|
||||||
|
address_1: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const stockLocation = stockLocationResult[0]
|
||||||
|
|
||||||
|
await updateStoresWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
selector: { id: store.id },
|
||||||
|
update: {
|
||||||
|
default_location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await link.create({
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
[Modules.FULFILLMENT]: {
|
||||||
|
fulfillment_provider_id: "manual_manual",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.info("Stock location created: TRPTK Warehouse.")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 5. Fulfillment & shipping
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding fulfillment...")
|
||||||
|
const shippingProfiles =
|
||||||
|
await fulfillmentModuleService.listShippingProfiles({ type: "default" })
|
||||||
|
let shippingProfile = shippingProfiles.length ? shippingProfiles[0] : null
|
||||||
|
|
||||||
|
if (!shippingProfile) {
|
||||||
|
const { result: shippingProfileResult } =
|
||||||
|
await createShippingProfilesWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: "Default Shipping Profile",
|
||||||
|
type: "default",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
shippingProfile = shippingProfileResult[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toGeoZones = (countries: string[]) =>
|
||||||
|
countries.map((country_code) => ({
|
||||||
|
country_code,
|
||||||
|
type: "country" as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fulfillmentSet = await fulfillmentModuleService.createFulfillmentSets({
|
||||||
|
name: "TRPTK Shipping",
|
||||||
|
type: "shipping",
|
||||||
|
service_zones: [
|
||||||
|
{ name: "Netherlands", geo_zones: toGeoZones(ZONE_NL) },
|
||||||
|
{ name: "EU-1", geo_zones: toGeoZones(ZONE_EU1) },
|
||||||
|
{ name: "EU-2", geo_zones: toGeoZones(ZONE_EU2) },
|
||||||
|
{ name: "Rest of the World", geo_zones: toGeoZones(ZONE_ROW) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await link.create({
|
||||||
|
[Modules.STOCK_LOCATION]: {
|
||||||
|
stock_location_id: stockLocation.id,
|
||||||
|
},
|
||||||
|
[Modules.FULFILLMENT]: {
|
||||||
|
fulfillment_set_id: fulfillmentSet.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Shipping options: 2 per zone (Mailbox Package + Package)
|
||||||
|
const findZone = (name: string) => {
|
||||||
|
const zone = fulfillmentSet.service_zones.find((z) => z.name === name)
|
||||||
|
if (!zone) throw new Error(`Service zone "${name}" not found`)
|
||||||
|
return zone
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIPPING_ZONES = [
|
||||||
|
{ zone: findZone("Netherlands"), mailboxPrice: 0, packagePrice: 0 },
|
||||||
|
{ zone: findZone("EU-1"), mailboxPrice: 700, packagePrice: 900 },
|
||||||
|
{ zone: findZone("EU-2"), mailboxPrice: 800, packagePrice: 1200 },
|
||||||
|
{ zone: findZone("Rest of the World"), mailboxPrice: 1000, packagePrice: 2200 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const shippingOptionInputs = SHIPPING_ZONES.flatMap(
|
||||||
|
({ zone, mailboxPrice, packagePrice }) => [
|
||||||
|
{
|
||||||
|
name: `Mailbox Package — ${zone.name}`,
|
||||||
|
price_type: "flat" as const,
|
||||||
|
provider_id: "manual_manual",
|
||||||
|
service_zone_id: zone.id,
|
||||||
|
shipping_profile_id: shippingProfile!.id,
|
||||||
|
type: {
|
||||||
|
label: "Mailbox Package",
|
||||||
|
description:
|
||||||
|
"For orders with CDs/SACDs only (up to 4 items). Fits through the mailbox.",
|
||||||
|
code: "mailbox-package",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{ currency_code: "eur", amount: mailboxPrice },
|
||||||
|
{ region_id: worldwideRegion.id, amount: mailboxPrice },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ attribute: "enabled_in_store", value: "true", operator: "eq" as const },
|
||||||
|
{ attribute: "is_return", value: "false", operator: "eq" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Package — ${zone.name}`,
|
||||||
|
price_type: "flat" as const,
|
||||||
|
provider_id: "manual_manual",
|
||||||
|
service_zone_id: zone.id,
|
||||||
|
shipping_profile_id: shippingProfile!.id,
|
||||||
|
type: {
|
||||||
|
label: "Package",
|
||||||
|
description:
|
||||||
|
"For orders with LPs, 5+ CDs/SACDs, or mixed physical formats.",
|
||||||
|
code: "package",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{ currency_code: "eur", amount: packagePrice },
|
||||||
|
{ region_id: worldwideRegion.id, amount: packagePrice },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ attribute: "enabled_in_store", value: "true", operator: "eq" as const },
|
||||||
|
{ attribute: "is_return", value: "false", operator: "eq" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await createShippingOptionsWorkflow(container).run({
|
||||||
|
input: shippingOptionInputs,
|
||||||
|
})
|
||||||
|
logger.info(`Fulfillment configured: 4 zones, ${shippingOptionInputs.length} shipping options.`)
|
||||||
|
|
||||||
|
// Link sales channel to stock location
|
||||||
|
await linkSalesChannelsToStockLocationWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
id: stockLocation.id,
|
||||||
|
add: [defaultSalesChannel[0].id],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 6. Publishable API key
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding API key...")
|
||||||
|
let publishableApiKey: ApiKey | null = null
|
||||||
|
const { data } = await query.graph({
|
||||||
|
entity: "api_key",
|
||||||
|
fields: ["id"],
|
||||||
|
filters: {
|
||||||
|
type: "publishable",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
publishableApiKey = data?.[0]
|
||||||
|
|
||||||
|
if (!publishableApiKey) {
|
||||||
|
const {
|
||||||
|
result: [publishableApiKeyResult],
|
||||||
|
} = await createApiKeysWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
api_keys: [
|
||||||
|
{
|
||||||
|
title: "TRPTK Webshop",
|
||||||
|
type: "publishable",
|
||||||
|
created_by: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
publishableApiKey = publishableApiKeyResult as ApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
await linkSalesChannelsToApiKeyWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
id: publishableApiKey.id,
|
||||||
|
add: [defaultSalesChannel[0].id],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.info("API key configured.")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 7. Sample product: TTK0001
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding sample product...")
|
||||||
|
|
||||||
|
await createProductsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
title: "Sample Release — TTK0001",
|
||||||
|
description:
|
||||||
|
"A sample TRPTK release to demonstrate the product structure. Replace with real catalogue data.",
|
||||||
|
handle: "ttk0001",
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
external_id: "TTK0001",
|
||||||
|
shipping_profile_id: shippingProfile!.id,
|
||||||
|
metadata: {
|
||||||
|
ean: "0608917722017",
|
||||||
|
catalogue_number: "TTK0001",
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
title: "Format",
|
||||||
|
values: ALL_FORMATS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants: ALL_FORMATS.map((format) => buildVariant("TTK0001", format)),
|
||||||
|
sales_channels: [
|
||||||
|
{
|
||||||
|
id: defaultSalesChannel[0].id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.info("Sample product created: TTK0001.")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 8. Inventory levels (physical variants only)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Seeding inventory levels...")
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const inventoryLevels: CreateInventoryLevelInput[] = inventoryItems.map(
|
||||||
|
(item) => ({
|
||||||
|
location_id: stockLocation.id,
|
||||||
|
stocked_quantity: 100,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inventoryLevels.length) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
inventory_levels: inventoryLevels,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.info("Inventory levels set.")
|
||||||
|
|
||||||
|
logger.info("TRPTK seed complete!")
|
||||||
|
}
|
||||||
480
src/scripts/sync-sanity.ts
Normal file
480
src/scripts/sync-sanity.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
import {
|
||||||
|
CreateInventoryLevelInput,
|
||||||
|
ExecArgs,
|
||||||
|
} from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
Modules,
|
||||||
|
ProductStatus,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createInventoryLevelsWorkflow,
|
||||||
|
createProductsWorkflow,
|
||||||
|
createProductVariantsWorkflow,
|
||||||
|
deleteProductVariantsWorkflow,
|
||||||
|
updateProductsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
import { getSanityClient } from "./sanity-client"
|
||||||
|
import {
|
||||||
|
buildVariant,
|
||||||
|
resolveFormatsFromSanity,
|
||||||
|
} from "./trptk-formats"
|
||||||
|
import type { ReleaseType } from "./trptk-formats"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sanity release type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface SanityRelease {
|
||||||
|
name?: string
|
||||||
|
albumArtist?: string
|
||||||
|
slug?: { current?: string }
|
||||||
|
shortDescription?: string
|
||||||
|
catalogNo?: string
|
||||||
|
upc?: string
|
||||||
|
format?: string
|
||||||
|
releaseDate?: string
|
||||||
|
availableVariants?: string[]
|
||||||
|
albumCover?: { asset?: { url?: string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Bulk sync: pull all releases from Sanity → create/update in Medusa
|
||||||
|
// ===========================================================================
|
||||||
|
export default async function syncSanity({ container }: ExecArgs) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const productModule = container.resolve(Modules.PRODUCT)
|
||||||
|
const salesChannelModule = container.resolve(Modules.SALES_CHANNEL)
|
||||||
|
const fulfillmentModule = container.resolve(Modules.FULFILLMENT)
|
||||||
|
const storeModule = container.resolve(Modules.STORE)
|
||||||
|
|
||||||
|
if (!process.env.SANITY_PROJECT_ID) {
|
||||||
|
logger.error("SANITY_PROJECT_ID is not configured. Add it to your .env file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1. Fetch all releases from Sanity
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
logger.info("Fetching releases from Sanity...")
|
||||||
|
const sanity = getSanityClient(false)
|
||||||
|
|
||||||
|
const releases = await sanity.fetch<SanityRelease[]>(
|
||||||
|
`*[_type == "release" && defined(upc) && defined(availableVariants)]{
|
||||||
|
name,
|
||||||
|
albumArtist,
|
||||||
|
slug,
|
||||||
|
shortDescription,
|
||||||
|
catalogNo,
|
||||||
|
upc,
|
||||||
|
format,
|
||||||
|
releaseDate,
|
||||||
|
availableVariants,
|
||||||
|
"albumCover": albumCover{
|
||||||
|
"asset": asset->{
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!releases || releases.length === 0) {
|
||||||
|
logger.info("No releases found in Sanity.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${releases.length} release(s) in Sanity.`)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Fetch all existing Medusa products (with metadata for matching)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const { data: existingProducts } = await query.graph({
|
||||||
|
entity: "product",
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"metadata",
|
||||||
|
"options.*",
|
||||||
|
"options.values.*",
|
||||||
|
"variants.*",
|
||||||
|
"variants.options.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build lookup maps: EAN → product, catalogue_number → product
|
||||||
|
const productByEan = new Map<string, any>()
|
||||||
|
const productByCatNo = new Map<string, any>()
|
||||||
|
|
||||||
|
for (const product of existingProducts) {
|
||||||
|
const ean = (product as any).metadata?.ean
|
||||||
|
const catNo = (product as any).metadata?.catalogue_number
|
||||||
|
if (ean) productByEan.set(ean, product)
|
||||||
|
if (catNo) productByCatNo.set(catNo, product)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${existingProducts.length} existing product(s) in Medusa.`)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Get shared resources
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const [salesChannel] = await salesChannelModule.listSalesChannels({
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
})
|
||||||
|
if (!salesChannel) {
|
||||||
|
logger.error("Default Sales Channel not found. Run the seed script first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingProfiles =
|
||||||
|
await fulfillmentModule.listShippingProfiles({ type: "default" })
|
||||||
|
const shippingProfile = shippingProfiles[0]
|
||||||
|
if (!shippingProfile) {
|
||||||
|
logger.error("Default shipping profile not found. Run the seed script first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [store] = await storeModule.listStores()
|
||||||
|
const stockLocationId = store.default_location_id
|
||||||
|
if (!stockLocationId) {
|
||||||
|
logger.error("No default stock location. Run the seed script first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Process each Sanity release
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const release of releases) {
|
||||||
|
const ean = release.upc
|
||||||
|
const catNo = release.catalogNo
|
||||||
|
const label = catNo || ean || "unknown"
|
||||||
|
|
||||||
|
if (!ean && !catNo) {
|
||||||
|
logger.warn(`Skipping release "${release.name}" — no UPC or catalogue number.`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!release.availableVariants || release.availableVariants.length === 0) {
|
||||||
|
logger.warn(`Skipping "${label}" — no availableVariants defined in Sanity.`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanityFormats = resolveFormatsFromSanity(release.availableVariants)
|
||||||
|
if (sanityFormats.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping "${label}" — no recognized formats in availableVariants: ${release.availableVariants.join(", ")}`
|
||||||
|
)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseType = release.format as ReleaseType | undefined
|
||||||
|
|
||||||
|
// Check if product already exists
|
||||||
|
const existingProduct =
|
||||||
|
(ean ? productByEan.get(ean) : undefined) ||
|
||||||
|
(catNo ? productByCatNo.get(catNo) : undefined)
|
||||||
|
|
||||||
|
if (existingProduct) {
|
||||||
|
// ----- UPDATE existing product -----
|
||||||
|
await syncExistingProduct(
|
||||||
|
container,
|
||||||
|
existingProduct,
|
||||||
|
release,
|
||||||
|
sanityFormats,
|
||||||
|
releaseType,
|
||||||
|
stockLocationId,
|
||||||
|
shippingProfile.id,
|
||||||
|
logger
|
||||||
|
)
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
// ----- CREATE new product -----
|
||||||
|
await createNewProduct(
|
||||||
|
container,
|
||||||
|
release,
|
||||||
|
sanityFormats,
|
||||||
|
releaseType,
|
||||||
|
shippingProfile.id,
|
||||||
|
salesChannel.id,
|
||||||
|
stockLocationId,
|
||||||
|
logger
|
||||||
|
)
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Sanity sync complete: ${created} created, ${updated} updated, ${skipped} skipped.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create a new Medusa product from a Sanity release
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function createNewProduct(
|
||||||
|
container: any,
|
||||||
|
release: SanityRelease,
|
||||||
|
sanityFormats: string[],
|
||||||
|
releaseType: ReleaseType | undefined,
|
||||||
|
shippingProfileId: string,
|
||||||
|
salesChannelId: string,
|
||||||
|
stockLocationId: string,
|
||||||
|
logger: any
|
||||||
|
) {
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const catNo = release.catalogNo || ""
|
||||||
|
const ean = release.upc || ""
|
||||||
|
|
||||||
|
const variants = sanityFormats.map((format) =>
|
||||||
|
buildVariant(catNo, format, undefined, releaseType)
|
||||||
|
)
|
||||||
|
|
||||||
|
const productData = {
|
||||||
|
title: release.name || catNo,
|
||||||
|
description: release.shortDescription ?? "",
|
||||||
|
handle: release.slug?.current || catNo.toLowerCase(),
|
||||||
|
status: ProductStatus.PUBLISHED,
|
||||||
|
external_id: catNo,
|
||||||
|
shipping_profile_id: shippingProfileId,
|
||||||
|
metadata: {
|
||||||
|
ean,
|
||||||
|
catalogue_number: catNo,
|
||||||
|
...(release.releaseDate ? { release_date: release.releaseDate } : {}),
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
title: "Format",
|
||||||
|
values: sanityFormats,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variants,
|
||||||
|
sales_channels: [{ id: salesChannelId }],
|
||||||
|
...(release.albumArtist ? { subtitle: release.albumArtist } : {}),
|
||||||
|
...(release.albumCover?.asset?.url
|
||||||
|
? {
|
||||||
|
thumbnail: release.albumCover.asset.url,
|
||||||
|
images: [{ url: release.albumCover.asset.url }],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
await createProductsWorkflow(container).run({
|
||||||
|
input: { products: [productData as any] },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set inventory levels for new physical variants
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
const { data: existingLevels } = await query.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["inventory_item_id"],
|
||||||
|
})
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
existingLevels.map(
|
||||||
|
(l: { inventory_item_id: string }) => l.inventory_item_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newLevels: CreateInventoryLevelInput[] = inventoryItems
|
||||||
|
.filter((item: { id: string }) => !existingItemIds.has(item.id))
|
||||||
|
.map((item: { id: string }) => ({
|
||||||
|
location_id: stockLocationId,
|
||||||
|
stocked_quantity: 0,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newLevels.length > 0) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: { inventory_levels: newLevels },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Created: ${catNo} — ${release.name} (${sanityFormats.length} formats)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sync an existing Medusa product with updated Sanity data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncExistingProduct(
|
||||||
|
container: any,
|
||||||
|
product: any,
|
||||||
|
release: SanityRelease,
|
||||||
|
sanityFormats: string[],
|
||||||
|
releaseType: ReleaseType | undefined,
|
||||||
|
stockLocationId: string,
|
||||||
|
shippingProfileId: string,
|
||||||
|
logger: any
|
||||||
|
) {
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const productModule = container.resolve(Modules.PRODUCT)
|
||||||
|
const link = container.resolve(ContainerRegistrationKeys.LINK)
|
||||||
|
const catNo = release.catalogNo || ""
|
||||||
|
const ean = release.upc || ""
|
||||||
|
const label = catNo || ean
|
||||||
|
|
||||||
|
// Re-fetch product with full relations for variant diffing
|
||||||
|
const fullProduct = await productModule.retrieveProduct(product.id, {
|
||||||
|
relations: ["options", "options.values", "variants", "variants.options"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find the Format option
|
||||||
|
const formatOption = fullProduct.options?.find(
|
||||||
|
(o: any) => o.title === "Format"
|
||||||
|
)
|
||||||
|
const formatOptionId = formatOption?.id
|
||||||
|
const sanityFormatSet = new Set(sanityFormats)
|
||||||
|
|
||||||
|
// Map existing format variants: format name → variant id
|
||||||
|
const existingFormatVariants = new Map<string, string>()
|
||||||
|
for (const v of fullProduct.variants ?? []) {
|
||||||
|
const formatOpt = (v as any).options?.find(
|
||||||
|
(o: any) => o.option_id === formatOptionId
|
||||||
|
)
|
||||||
|
if (formatOpt?.value) {
|
||||||
|
existingFormatVariants.set(formatOpt.value, (v as any).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine adds and deletes
|
||||||
|
const toCreate = sanityFormats.filter(
|
||||||
|
(f) => !existingFormatVariants.has(f)
|
||||||
|
)
|
||||||
|
|
||||||
|
const toDeleteIds: string[] = []
|
||||||
|
for (const [format, variantId] of existingFormatVariants) {
|
||||||
|
if (!sanityFormatSet.has(format)) {
|
||||||
|
toDeleteIds.push(variantId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete non-Format variants (e.g. leftover "Default" variants)
|
||||||
|
const nonFormatVariantIds = (fullProduct.variants ?? [])
|
||||||
|
.filter((v: any) => {
|
||||||
|
const hasFormatOption =
|
||||||
|
formatOptionId &&
|
||||||
|
v.options?.some((o: any) => o.option_id === formatOptionId)
|
||||||
|
return !hasFormatOption
|
||||||
|
})
|
||||||
|
.map((v: any) => v.id)
|
||||||
|
|
||||||
|
const allToDelete = [...toDeleteIds, ...nonFormatVariantIds]
|
||||||
|
|
||||||
|
// Delete variants no longer in Sanity
|
||||||
|
if (allToDelete.length > 0) {
|
||||||
|
await deleteProductVariantsWorkflow(container).run({
|
||||||
|
input: { ids: allToDelete },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update product metadata, options, and Sanity fields
|
||||||
|
const options = formatOption
|
||||||
|
? [{ id: formatOption.id, title: "Format", values: sanityFormats }]
|
||||||
|
: [{ title: "Format", values: sanityFormats }]
|
||||||
|
|
||||||
|
const productUpdate: Record<string, unknown> = {
|
||||||
|
options,
|
||||||
|
metadata: {
|
||||||
|
...fullProduct.metadata,
|
||||||
|
ean,
|
||||||
|
catalogue_number: catNo,
|
||||||
|
...(release.releaseDate ? { release_date: release.releaseDate } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (release.name) productUpdate.title = release.name
|
||||||
|
if (release.albumArtist) productUpdate.subtitle = release.albumArtist
|
||||||
|
if (release.slug?.current) productUpdate.handle = release.slug.current
|
||||||
|
if (release.shortDescription)
|
||||||
|
productUpdate.description = release.shortDescription
|
||||||
|
if (release.albumCover?.asset?.url) {
|
||||||
|
productUpdate.thumbnail = release.albumCover.asset.url
|
||||||
|
productUpdate.images = [{ url: release.albumCover.asset.url }]
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProductsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
selector: { id: product.id },
|
||||||
|
update: productUpdate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ensure shipping profile link exists
|
||||||
|
try {
|
||||||
|
await link.create({
|
||||||
|
[Modules.PRODUCT]: { product_id: product.id },
|
||||||
|
[Modules.FULFILLMENT]: { shipping_profile_id: shippingProfileId },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Link already exists — ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sales channel link exists
|
||||||
|
const salesChannelModule = container.resolve(Modules.SALES_CHANNEL)
|
||||||
|
const [salesChannel] = await salesChannelModule.listSalesChannels({
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
})
|
||||||
|
if (salesChannel) {
|
||||||
|
try {
|
||||||
|
await link.create({
|
||||||
|
[Modules.PRODUCT]: { product_id: product.id },
|
||||||
|
[Modules.SALES_CHANNEL]: { sales_channel_id: salesChannel.id },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Link already exists — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create missing variants
|
||||||
|
if (toCreate.length > 0) {
|
||||||
|
const variants = toCreate.map((format) => ({
|
||||||
|
...buildVariant(catNo, format, undefined, releaseType),
|
||||||
|
product_id: product.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await createProductVariantsWorkflow(container).run({
|
||||||
|
input: { product_variants: variants },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create inventory levels for new physical variants
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
const { data: existingLevels } = await query.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["inventory_item_id"],
|
||||||
|
})
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
existingLevels.map(
|
||||||
|
(l: { inventory_item_id: string }) => l.inventory_item_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const newLevels: CreateInventoryLevelInput[] = inventoryItems
|
||||||
|
.filter((item: { id: string }) => !existingItemIds.has(item.id))
|
||||||
|
.map((item: { id: string }) => ({
|
||||||
|
location_id: stockLocationId,
|
||||||
|
stocked_quantity: 0,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newLevels.length > 0) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: { inventory_levels: newLevels },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: string[] = []
|
||||||
|
if (toCreate.length > 0) changes.push(`+${toCreate.length} formats`)
|
||||||
|
if (allToDelete.length > 0) changes.push(`-${allToDelete.length} formats`)
|
||||||
|
if (changes.length === 0) changes.push("metadata only")
|
||||||
|
|
||||||
|
logger.info(`Updated: ${label} — ${release.name} (${changes.join(", ")})`)
|
||||||
|
}
|
||||||
82
src/scripts/test-s3-download.ts
Normal file
82
src/scripts/test-s3-download.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { ExecArgs } from "@medusajs/framework/types"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
import { S3Client, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3"
|
||||||
|
import { getPresignedDownloadUrl, skuToS3Key } from "../lib/s3-download"
|
||||||
|
|
||||||
|
export default async function testS3Download({ container }: ExecArgs) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
|
||||||
|
const testSku = "TTK0001_88K24B2CH"
|
||||||
|
const expectedKey = skuToS3Key(testSku)
|
||||||
|
|
||||||
|
logger.info(`Testing S3 connection to Hetzner Object Storage...`)
|
||||||
|
logger.info(` Endpoint: ${process.env.HETZNER_S3_ENDPOINT}`)
|
||||||
|
logger.info(` Bucket: ${process.env.HETZNER_S3_BUCKET}`)
|
||||||
|
logger.info(` Region: ${process.env.HETZNER_S3_REGION}`)
|
||||||
|
logger.info(``)
|
||||||
|
logger.info(`SKU "${testSku}" → S3 key "${expectedKey}"`)
|
||||||
|
|
||||||
|
// Test 1: List objects in the bucket
|
||||||
|
const client = new S3Client({
|
||||||
|
endpoint: process.env.HETZNER_S3_ENDPOINT,
|
||||||
|
region: process.env.HETZNER_S3_REGION || "fsn1",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.HETZNER_S3_ACCESS_KEY || "",
|
||||||
|
secretAccessKey: process.env.HETZNER_S3_SECRET_KEY || "",
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`\n--- Test 1: List bucket contents ---`)
|
||||||
|
const listResult = await client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: process.env.HETZNER_S3_BUCKET || "trptk-downloads",
|
||||||
|
MaxKeys: 10,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const objects = listResult.Contents ?? []
|
||||||
|
if (objects.length === 0) {
|
||||||
|
logger.info(` Bucket is empty (or no access)`)
|
||||||
|
} else {
|
||||||
|
logger.info(` Found ${objects.length} object(s):`)
|
||||||
|
for (const obj of objects) {
|
||||||
|
logger.info(` - ${obj.Key} (${obj.Size} bytes)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(` Failed to list bucket: ${err.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Check if the specific file exists
|
||||||
|
try {
|
||||||
|
logger.info(`\n--- Test 2: Check file exists ---`)
|
||||||
|
logger.info(` Looking for: ${expectedKey}`)
|
||||||
|
const headResult = await client.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: process.env.HETZNER_S3_BUCKET || "trptk-downloads",
|
||||||
|
Key: expectedKey,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
logger.info(` File found! Size: ${headResult.ContentLength} bytes, Type: ${headResult.ContentType}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
||||||
|
logger.error(` File not found at key "${expectedKey}"`)
|
||||||
|
} else {
|
||||||
|
logger.error(` Error checking file: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Generate a presigned download URL
|
||||||
|
try {
|
||||||
|
logger.info(`\n--- Test 3: Generate presigned URL ---`)
|
||||||
|
const url = await getPresignedDownloadUrl(testSku)
|
||||||
|
logger.info(` Presigned URL (valid for 5 min):`)
|
||||||
|
logger.info(` ${url}`)
|
||||||
|
logger.info(`\n Try opening this URL in your browser to download the file!`)
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(` Failed to generate presigned URL: ${err.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`\nDone!`)
|
||||||
|
}
|
||||||
168
src/scripts/trptk-formats.ts
Normal file
168
src/scripts/trptk-formats.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TRPTK format definitions — shared between seed and product creation scripts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export type VariantMeta = { type: "physical" | "digital"; group: string };
|
||||||
|
|
||||||
|
export type ReleaseType = "single" | "ep" | "album" | "boxset";
|
||||||
|
|
||||||
|
// Metadata for frontend grouping
|
||||||
|
export const VARIANT_METADATA: Record<string, VariantMeta> = {
|
||||||
|
CD: { type: "physical", group: "physical" },
|
||||||
|
SACD: { type: "physical", group: "physical" },
|
||||||
|
LP: { type: "physical", group: "physical" },
|
||||||
|
"FLAC Stereo 88.2/24": { type: "digital", group: "stereo" },
|
||||||
|
"FLAC Stereo 176.4/24": { type: "digital", group: "stereo" },
|
||||||
|
"FLAC Stereo 352.8/24": { type: "digital", group: "stereo" },
|
||||||
|
"FLAC Stereo 352.8/32": { type: "digital", group: "stereo" },
|
||||||
|
"FLAC Surround 88.2/24": { type: "digital", group: "surround" },
|
||||||
|
"FLAC Surround 176.4/24": { type: "digital", group: "surround" },
|
||||||
|
"FLAC Surround 352.8/24": { type: "digital", group: "surround" },
|
||||||
|
"FLAC Surround 352.8/32": { type: "digital", group: "surround" },
|
||||||
|
"Dolby Atmos DTS:X Auro-3D MKV": { type: "digital", group: "immersive" },
|
||||||
|
"Auro-3D FLAC": { type: "digital", group: "immersive" },
|
||||||
|
"Dolby Atmos ADM 48kHz": { type: "digital", group: "immersive" },
|
||||||
|
"Dolby Atmos ADM 96kHz": { type: "digital", group: "immersive" },
|
||||||
|
"HD Video": { type: "digital", group: "video" },
|
||||||
|
"4K Video": { type: "digital", group: "video" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// HS codes for physical formats
|
||||||
|
export const HS_CODES: Record<string, string> = {
|
||||||
|
CD: "85234140",
|
||||||
|
SACD: "85234140",
|
||||||
|
LP: "85238090",
|
||||||
|
};
|
||||||
|
|
||||||
|
// SKU suffixes — appended to catalogue number (e.g. TTK0001_CD)
|
||||||
|
export const SKU_SUFFIXES: Record<string, string> = {
|
||||||
|
CD: "CD",
|
||||||
|
SACD: "SACD",
|
||||||
|
LP: "LP",
|
||||||
|
"FLAC Stereo 88.2/24": "88K24B2CH",
|
||||||
|
"FLAC Stereo 176.4/24": "176K24B2CH",
|
||||||
|
"FLAC Stereo 352.8/24": "352K24B2CH",
|
||||||
|
"FLAC Stereo 352.8/32": "352K32B2CH",
|
||||||
|
"FLAC Surround 88.2/24": "88K24B5CH",
|
||||||
|
"FLAC Surround 176.4/24": "176K24B5CH",
|
||||||
|
"FLAC Surround 352.8/24": "352K24B5CH",
|
||||||
|
"FLAC Surround 352.8/32": "352K32B5CH",
|
||||||
|
"Dolby Atmos DTS:X Auro-3D MKV": "MKV",
|
||||||
|
"Auro-3D FLAC": "A3D",
|
||||||
|
"Dolby Atmos ADM 48kHz": "ADM48",
|
||||||
|
"Dolby Atmos ADM 96kHz": "ADM96",
|
||||||
|
"HD Video": "HD",
|
||||||
|
"4K Video": "4K",
|
||||||
|
};
|
||||||
|
|
||||||
|
// All standard format names
|
||||||
|
export const ALL_FORMATS = Object.keys(VARIANT_METADATA);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sanity variant value → Medusa format name mapping
|
||||||
|
// Derived from SKU_SUFFIXES: { cd: "CD", "88k24b2ch": "FLAC Stereo 88.2/24", ... }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const SANITY_TO_MEDUSA_FORMAT: Record<string, string> =
|
||||||
|
Object.entries(SKU_SUFFIXES).reduce(
|
||||||
|
(acc, [medusaFormat, skuSuffix]) => {
|
||||||
|
acc[skuSuffix.toLowerCase()] = medusaFormat;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function resolveFormatsFromSanity(
|
||||||
|
availableVariants: string[],
|
||||||
|
): string[] {
|
||||||
|
return availableVariants
|
||||||
|
.map((v) => SANITY_TO_MEDUSA_FORMAT[v.toLowerCase()])
|
||||||
|
.filter((f): f is string => f !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Release-type-aware pricing — reads trptk-pricing.json at runtime
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let cachedPricing: Record<string, Record<string, number>> | null = null;
|
||||||
|
|
||||||
|
function loadReleasePricing(): Record<string, Record<string, number>> {
|
||||||
|
if (cachedPricing) return cachedPricing;
|
||||||
|
try {
|
||||||
|
const filePath = join(process.cwd(), "trptk-pricing.json");
|
||||||
|
const raw = readFileSync(filePath, "utf-8");
|
||||||
|
cachedPricing = JSON.parse(raw);
|
||||||
|
return cachedPricing!;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPriceForFormat(
|
||||||
|
format: string,
|
||||||
|
releaseType?: ReleaseType,
|
||||||
|
): number | undefined {
|
||||||
|
const pricing = loadReleasePricing();
|
||||||
|
return pricing[releaseType ?? "album"]?.[format];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build a single variant for a given format and catalogue number
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function buildVariant(
|
||||||
|
catalogueNumber: string,
|
||||||
|
format: string,
|
||||||
|
priceOverride?: number,
|
||||||
|
releaseType?: ReleaseType,
|
||||||
|
) {
|
||||||
|
const price = priceOverride ?? getPriceForFormat(format, releaseType);
|
||||||
|
if (price === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`No default price for format "${format}". Provide a priceOverride.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = VARIANT_METADATA[format] ?? {
|
||||||
|
type: "physical" as const,
|
||||||
|
group: "physical",
|
||||||
|
};
|
||||||
|
const isDigital = meta.type === "digital";
|
||||||
|
const skuSuffix = SKU_SUFFIXES[format];
|
||||||
|
const suffix = skuSuffix
|
||||||
|
?? format.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
const sku = catalogueNumber ? `${catalogueNumber}_${suffix}` : suffix;
|
||||||
|
|
||||||
|
const hsCode = HS_CODES[format];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: format,
|
||||||
|
sku,
|
||||||
|
manage_inventory: !isDigital,
|
||||||
|
...(hsCode ? { hs_code: hsCode } : {}),
|
||||||
|
options: { Format: format },
|
||||||
|
metadata: meta,
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
amount: price,
|
||||||
|
currency_code: "eur",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Release definition for the create-product script
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface TRPTKRelease {
|
||||||
|
// Required
|
||||||
|
catalogueNumber: string; // e.g. "TTK0001"
|
||||||
|
title: string; // e.g. "Bach: Goldberg Variations"
|
||||||
|
ean: string; // EAN barcode
|
||||||
|
|
||||||
|
// Optional
|
||||||
|
description?: string;
|
||||||
|
formats?: string[]; // defaults to ALL_FORMATS if omitted
|
||||||
|
excludeFormats?: string[]; // formats to exclude from ALL_FORMATS
|
||||||
|
priceOverrides?: Record<string, number>; // format -> price in EUR
|
||||||
|
}
|
||||||
237
src/scripts/update-shipping.ts
Normal file
237
src/scripts/update-shipping.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { ExecArgs } from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createShippingOptionsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All supported countries (must match seed.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// prettier-ignore
|
||||||
|
const allCountries = [
|
||||||
|
// Europe
|
||||||
|
"al", "ad", "at", "be", "ba", "bg", "hr", "cy", "cz", "dk",
|
||||||
|
"ee", "fi", "fr", "de", "gr", "hu", "is", "ie", "it", "xk",
|
||||||
|
"lv", "li", "lt", "lu", "mk", "mt", "md", "mc", "me", "nl",
|
||||||
|
"no", "pl", "pt", "ro", "sm", "rs", "sk", "si", "es", "se",
|
||||||
|
"ch", "ua", "gb", "va",
|
||||||
|
// North America
|
||||||
|
"us", "ca", "mx",
|
||||||
|
// Central America & Caribbean
|
||||||
|
"bz", "cr", "sv", "gt", "hn", "ni", "pa",
|
||||||
|
"ag", "bs", "bb", "cu", "dm", "do", "gd", "ht", "jm", "kn",
|
||||||
|
"lc", "vc", "tt",
|
||||||
|
// South America
|
||||||
|
"ar", "bo", "br", "cl", "co", "ec", "gy", "py", "pe", "sr",
|
||||||
|
"uy", "ve",
|
||||||
|
// East Asia & Pacific
|
||||||
|
"au", "bn", "kh", "cn", "fj", "hk", "id", "jp", "kr", "la",
|
||||||
|
"mo", "my", "mn", "mm", "nz", "pg", "ph", "sg", "tw", "th",
|
||||||
|
"tl", "vn",
|
||||||
|
// South Asia
|
||||||
|
"af", "bd", "bt", "in", "mv", "np", "pk", "lk",
|
||||||
|
// Central Asia
|
||||||
|
"kz", "kg", "tj", "tm", "uz",
|
||||||
|
// Middle East (excluding IR)
|
||||||
|
"bh", "iq", "il", "jo", "kw", "lb", "om", "ps", "qa", "sa",
|
||||||
|
"ae", "ye",
|
||||||
|
// Africa
|
||||||
|
"dz", "ao", "bj", "bw", "bf", "bi", "cv", "cm", "cf", "td",
|
||||||
|
"km", "cd", "cg", "ci", "dj", "eg", "gq", "er", "sz", "et",
|
||||||
|
"ga", "gm", "gh", "gn", "gw", "ke", "ls", "lr", "ly", "mg",
|
||||||
|
"mw", "ml", "mr", "mu", "ma", "mz", "na", "ne", "ng", "rw",
|
||||||
|
"st", "sn", "sc", "sl", "so", "za", "ss", "sd", "tz", "tg",
|
||||||
|
"tn", "ug", "zm", "zw",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Shipping zones
|
||||||
|
const ZONE_NL = ["nl"]
|
||||||
|
// prettier-ignore
|
||||||
|
const ZONE_EU1 = [
|
||||||
|
"be", "dk", "de", "fr", "it", "lu", "at", "es", "se",
|
||||||
|
]
|
||||||
|
// prettier-ignore
|
||||||
|
const ZONE_EU2 = [
|
||||||
|
"bg", "ee", "fi", "hu", "ie", "hr", "lv", "lt",
|
||||||
|
"pl", "pt", "ro", "si", "sk", "cz", "gb", "ch",
|
||||||
|
]
|
||||||
|
const ZONE_ROW = allCountries.filter(
|
||||||
|
(cc) =>
|
||||||
|
!ZONE_NL.includes(cc) &&
|
||||||
|
!ZONE_EU1.includes(cc) &&
|
||||||
|
!ZONE_EU2.includes(cc)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Migration: update existing DB shipping to 4 zones
|
||||||
|
// ===========================================================================
|
||||||
|
export default async function updateShipping({ container }: ExecArgs) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const link = container.resolve(ContainerRegistrationKeys.LINK)
|
||||||
|
const fulfillmentModule = container.resolve(Modules.FULFILLMENT)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1. Find existing fulfillment set
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const fulfillmentSets = await fulfillmentModule.listFulfillmentSets(
|
||||||
|
{},
|
||||||
|
{ relations: ["service_zones", "service_zones.shipping_options"] }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fulfillmentSets.length === 0) {
|
||||||
|
logger.error("No fulfillment sets found. Run the seed script first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSet = fulfillmentSets[0]
|
||||||
|
logger.info(`Found fulfillment set: "${existingSet.name}" (${existingSet.id})`)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Delete existing shipping options and service zones
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const existingShippingOptionIds = existingSet.service_zones.flatMap(
|
||||||
|
(zone) => (zone as any).shipping_options?.map((so: any) => so.id) ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingShippingOptionIds.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Deleting ${existingShippingOptionIds.length} existing shipping option(s)...`
|
||||||
|
)
|
||||||
|
await fulfillmentModule.deleteShippingOptions(existingShippingOptionIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingZoneIds = existingSet.service_zones.map((z) => z.id)
|
||||||
|
if (existingZoneIds.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Deleting ${existingZoneIds.length} existing service zone(s)...`
|
||||||
|
)
|
||||||
|
await fulfillmentModule.deleteServiceZones(existingZoneIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Create new service zones on the existing fulfillment set
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const toGeoZones = (countries: string[]) =>
|
||||||
|
countries.map((country_code) => ({
|
||||||
|
country_code,
|
||||||
|
type: "country" as const,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const newZones = await fulfillmentModule.createServiceZones([
|
||||||
|
{
|
||||||
|
name: "Netherlands",
|
||||||
|
fulfillment_set_id: existingSet.id,
|
||||||
|
geo_zones: toGeoZones(ZONE_NL),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EU-1",
|
||||||
|
fulfillment_set_id: existingSet.id,
|
||||||
|
geo_zones: toGeoZones(ZONE_EU1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EU-2",
|
||||||
|
fulfillment_set_id: existingSet.id,
|
||||||
|
geo_zones: toGeoZones(ZONE_EU2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rest of the World",
|
||||||
|
fulfillment_set_id: existingSet.id,
|
||||||
|
geo_zones: toGeoZones(ZONE_ROW),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
logger.info(`Created ${newZones.length} service zones.`)
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Get shipping profile and region for the new shipping options
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const shippingProfiles =
|
||||||
|
await fulfillmentModule.listShippingProfiles({ type: "default" })
|
||||||
|
const shippingProfile = shippingProfiles[0]
|
||||||
|
if (!shippingProfile) {
|
||||||
|
logger.error("No default shipping profile found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionModule = container.resolve(Modules.REGION)
|
||||||
|
const regions = await regionModule.listRegions({ name: "Worldwide" })
|
||||||
|
const worldwideRegion = regions[0]
|
||||||
|
if (!worldwideRegion) {
|
||||||
|
logger.error("Worldwide region not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 5. Create new shipping options (2 per zone)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const findZone = (name: string) => {
|
||||||
|
const zone = newZones.find((z) => z.name === name)
|
||||||
|
if (!zone) throw new Error(`Service zone "${name}" not found`)
|
||||||
|
return zone
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHIPPING_ZONES = [
|
||||||
|
{ zone: findZone("Netherlands"), mailboxPrice: 0, packagePrice: 0 },
|
||||||
|
{ zone: findZone("EU-1"), mailboxPrice: 700, packagePrice: 900 },
|
||||||
|
{ zone: findZone("EU-2"), mailboxPrice: 800, packagePrice: 1200 },
|
||||||
|
{ zone: findZone("Rest of the World"), mailboxPrice: 1000, packagePrice: 2200 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const shippingOptionInputs = SHIPPING_ZONES.flatMap(
|
||||||
|
({ zone, mailboxPrice, packagePrice }) => [
|
||||||
|
{
|
||||||
|
name: `Mailbox Package — ${zone.name}`,
|
||||||
|
price_type: "flat" as const,
|
||||||
|
provider_id: "manual_manual",
|
||||||
|
service_zone_id: zone.id,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
type: {
|
||||||
|
label: "Mailbox Package",
|
||||||
|
description:
|
||||||
|
"For orders with CDs/SACDs only (up to 4 items). Fits through the mailbox.",
|
||||||
|
code: "mailbox-package",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{ currency_code: "eur", amount: mailboxPrice },
|
||||||
|
{ region_id: worldwideRegion.id, amount: mailboxPrice },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ attribute: "enabled_in_store", value: "true", operator: "eq" as const },
|
||||||
|
{ attribute: "is_return", value: "false", operator: "eq" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `Package — ${zone.name}`,
|
||||||
|
price_type: "flat" as const,
|
||||||
|
provider_id: "manual_manual",
|
||||||
|
service_zone_id: zone.id,
|
||||||
|
shipping_profile_id: shippingProfile.id,
|
||||||
|
type: {
|
||||||
|
label: "Package",
|
||||||
|
description:
|
||||||
|
"For orders with LPs, 5+ CDs/SACDs, or mixed physical formats.",
|
||||||
|
code: "package",
|
||||||
|
},
|
||||||
|
prices: [
|
||||||
|
{ currency_code: "eur", amount: packagePrice },
|
||||||
|
{ region_id: worldwideRegion.id, amount: packagePrice },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ attribute: "enabled_in_store", value: "true", operator: "eq" as const },
|
||||||
|
{ attribute: "is_return", value: "false", operator: "eq" as const },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await createShippingOptionsWorkflow(container).run({
|
||||||
|
input: shippingOptionInputs,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Shipping migration complete: 4 zones, ${shippingOptionInputs.length} shipping options created.`
|
||||||
|
)
|
||||||
|
}
|
||||||
176
src/scripts/update-store.ts
Normal file
176
src/scripts/update-store.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { ExecArgs } from "@medusajs/framework/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
Modules,
|
||||||
|
} from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createRegionsWorkflow,
|
||||||
|
createTaxRegionsWorkflow,
|
||||||
|
deleteRegionsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VAT rates for countries where we collect tax (standard rates as of 2025)
|
||||||
|
// Countries not listed here get 0% tax automatically.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const VAT_RATES: Record<string, number> = {
|
||||||
|
// EU member states
|
||||||
|
nl: 21, de: 19, fr: 20, it: 22, es: 21, be: 21, at: 20,
|
||||||
|
pl: 23, cz: 21, dk: 25, se: 25, fi: 25.5, ie: 23, pt: 23,
|
||||||
|
bg: 20, hr: 21, cy: 19, ee: 22, gr: 24, hu: 27, lv: 21,
|
||||||
|
lt: 21, lu: 17, mt: 18, ro: 19, sk: 23, si: 22,
|
||||||
|
// Non-EU European
|
||||||
|
gb: 20, ch: 8.1, no: 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// All supported countries (worldwide except RU and IR)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// prettier-ignore
|
||||||
|
const allCountries = [
|
||||||
|
// Europe
|
||||||
|
"al", "ad", "at", "be", "ba", "bg", "hr", "cy", "cz", "dk",
|
||||||
|
"ee", "fi", "fr", "de", "gr", "hu", "is", "ie", "it", "xk",
|
||||||
|
"lv", "li", "lt", "lu", "mk", "mt", "md", "mc", "me", "nl",
|
||||||
|
"no", "pl", "pt", "ro", "sm", "rs", "sk", "si", "es", "se",
|
||||||
|
"ch", "ua", "gb", "va",
|
||||||
|
// North America
|
||||||
|
"us", "ca", "mx",
|
||||||
|
// Central America & Caribbean
|
||||||
|
"bz", "cr", "sv", "gt", "hn", "ni", "pa",
|
||||||
|
"ag", "bs", "bb", "cu", "dm", "do", "gd", "ht", "jm", "kn",
|
||||||
|
"lc", "vc", "tt",
|
||||||
|
// South America
|
||||||
|
"ar", "bo", "br", "cl", "co", "ec", "gy", "py", "pe", "sr",
|
||||||
|
"uy", "ve",
|
||||||
|
// East Asia & Pacific
|
||||||
|
"au", "bn", "kh", "cn", "fj", "hk", "id", "jp", "kr", "la",
|
||||||
|
"mo", "my", "mn", "mm", "nz", "pg", "ph", "sg", "tw", "th",
|
||||||
|
"tl", "vn",
|
||||||
|
// South Asia
|
||||||
|
"af", "bd", "bt", "in", "mv", "np", "pk", "lk",
|
||||||
|
// Central Asia
|
||||||
|
"kz", "kg", "tj", "tm", "uz",
|
||||||
|
// Middle East (excluding IR)
|
||||||
|
"bh", "iq", "il", "jo", "kw", "lb", "om", "ps", "qa", "sa",
|
||||||
|
"ae", "ye",
|
||||||
|
// Africa
|
||||||
|
"dz", "ao", "bj", "bw", "bf", "bi", "cv", "cm", "cf", "td",
|
||||||
|
"km", "cd", "cg", "ci", "dj", "eg", "gq", "er", "sz", "et",
|
||||||
|
"ga", "gm", "gh", "gn", "gw", "ke", "ls", "lr", "ly", "mg",
|
||||||
|
"mw", "ml", "mr", "mu", "ma", "mz", "na", "ne", "ng", "rw",
|
||||||
|
"st", "sn", "sc", "sl", "so", "za", "ss", "sd", "tz", "tg",
|
||||||
|
"tn", "ug", "zm", "zw",
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Migration: expand region to worldwide + set up tax rates
|
||||||
|
// ===========================================================================
|
||||||
|
export default async function updateStore({ container }: ExecArgs) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const regionModule = container.resolve(Modules.REGION)
|
||||||
|
const taxModule = container.resolve(Modules.TAX)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. Replace existing regions with a single Worldwide region
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Checking existing regions...")
|
||||||
|
const existingRegions = await regionModule.listRegions()
|
||||||
|
|
||||||
|
// Delete all existing regions and recreate (to update the country list)
|
||||||
|
if (existingRegions.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Deleting ${existingRegions.length} existing region(s): ${existingRegions.map((r) => r.name).join(", ")}`
|
||||||
|
)
|
||||||
|
await deleteRegionsWorkflow(container).run({
|
||||||
|
input: { ids: existingRegions.map((r) => r.id) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Creating Worldwide region with ${allCountries.length} countries...`)
|
||||||
|
await createRegionsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
regions: [
|
||||||
|
{
|
||||||
|
name: "Worldwide",
|
||||||
|
currency_code: "eur",
|
||||||
|
countries: allCountries,
|
||||||
|
is_tax_inclusive: true,
|
||||||
|
payment_providers: ["pp_system_default"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.info("Worldwide region created.")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. Set up tax regions with VAT rates (only for VAT countries)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
logger.info("Setting up tax rates...")
|
||||||
|
|
||||||
|
const vatCountries = Object.keys(VAT_RATES)
|
||||||
|
|
||||||
|
// Check which tax regions already exist
|
||||||
|
const existingTaxRegions = await taxModule.listTaxRegions()
|
||||||
|
const existingCountryCodes = new Set(
|
||||||
|
existingTaxRegions.map((tr) => tr.country_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create missing tax regions (only for VAT countries)
|
||||||
|
const missingCountries = vatCountries.filter(
|
||||||
|
(cc) => !existingCountryCodes.has(cc)
|
||||||
|
)
|
||||||
|
if (missingCountries.length > 0) {
|
||||||
|
logger.info(`Creating ${missingCountries.length} missing tax region(s)...`)
|
||||||
|
await createTaxRegionsWorkflow(container).run({
|
||||||
|
input: missingCountries.map((country_code) => ({
|
||||||
|
country_code,
|
||||||
|
provider_id: "tp_system",
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the list after creating new ones
|
||||||
|
const allTaxRegions = await taxModule.listTaxRegions()
|
||||||
|
|
||||||
|
// Set default tax rates for each VAT country
|
||||||
|
for (const taxRegion of allTaxRegions) {
|
||||||
|
const cc = taxRegion.country_code
|
||||||
|
if (!cc || !(cc in VAT_RATES)) continue
|
||||||
|
|
||||||
|
const rate = VAT_RATES[cc]
|
||||||
|
|
||||||
|
// Check if a default rate already exists
|
||||||
|
const existingRates = await taxModule.listTaxRates({
|
||||||
|
tax_region_id: taxRegion.id,
|
||||||
|
is_default: true,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
if (existingRates.length > 0) {
|
||||||
|
// Update existing default rate if it differs
|
||||||
|
const existing = existingRates[0]
|
||||||
|
if (existing.rate !== rate) {
|
||||||
|
logger.info(
|
||||||
|
`Updating ${cc.toUpperCase()} tax rate: ${existing.rate}% -> ${rate}%`
|
||||||
|
)
|
||||||
|
await taxModule.updateTaxRates(existing.id, {
|
||||||
|
rate,
|
||||||
|
name: `VAT ${cc.toUpperCase()} ${rate}%`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new default rate
|
||||||
|
logger.info(`Setting ${cc.toUpperCase()} VAT rate: ${rate}%`)
|
||||||
|
await taxModule.createTaxRates({
|
||||||
|
tax_region_id: taxRegion.id,
|
||||||
|
name: `VAT ${cc.toUpperCase()} ${rate}%`,
|
||||||
|
code: `vat-${cc}`,
|
||||||
|
rate,
|
||||||
|
is_default: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Tax rates configured.")
|
||||||
|
logger.info("Store update complete!")
|
||||||
|
}
|
||||||
61
src/subscribers/README.md
Normal file
61
src/subscribers/README.md
Normal file
|
|
@ -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",
|
||||||
|
}
|
||||||
|
```
|
||||||
69
src/subscribers/digital-fulfillment.ts
Normal file
69
src/subscribers/digital-fulfillment.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||||
|
import { fulfillDigitalItemsWorkflow } from "../workflows/fulfill-digital-items"
|
||||||
|
import { getDigitalItemsReadyForFulfillment } from "../lib/digital-fulfillment-utils"
|
||||||
|
|
||||||
|
export default async function digitalFulfillmentHandler({
|
||||||
|
event: { data },
|
||||||
|
container,
|
||||||
|
}: SubscriberArgs<{ id: string }>) {
|
||||||
|
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const orderId = data.id
|
||||||
|
|
||||||
|
// Fetch order with line items, variants, and product metadata
|
||||||
|
const { data: [order] } = await query.graph({
|
||||||
|
entity: "order",
|
||||||
|
filters: { id: orderId },
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"items.*",
|
||||||
|
"items.variant.*",
|
||||||
|
"items.variant.product.*",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
logger.warn(`[digital-fulfillment] Order ${orderId} not found`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect digital line items that are already released
|
||||||
|
const digitalItems = getDigitalItemsReadyForFulfillment(
|
||||||
|
(order.items ?? []) as any[],
|
||||||
|
{
|
||||||
|
checkFulfilledQty: false,
|
||||||
|
logger,
|
||||||
|
logPrefix: "[digital-fulfillment]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (digitalItems.length === 0) {
|
||||||
|
logger.info(`[digital-fulfillment] No released digital items in order ${orderId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fulfillDigitalItemsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
order_id: orderId,
|
||||||
|
items: digitalItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
quantity: item.quantity,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[digital-fulfillment] Auto-fulfilled ${digitalItems.length} digital item(s) for order ${orderId}`
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(
|
||||||
|
`[digital-fulfillment] Failed to fulfill digital items for order ${orderId}: ${err.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config: SubscriberConfig = {
|
||||||
|
event: "order.placed",
|
||||||
|
}
|
||||||
81
src/workflows/README.md
Normal file
81
src/workflows/README.md
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
```
|
||||||
50
src/workflows/create-download-grant.ts
Normal file
50
src/workflows/create-download-grant.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {
|
||||||
|
createWorkflow,
|
||||||
|
transform,
|
||||||
|
WorkflowResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
import { createDownloadGrantStep } from "./steps/create-download-grant"
|
||||||
|
import { DOWNLOAD_GRANT_MODULE } from "../modules/downloadGrant"
|
||||||
|
|
||||||
|
type CreateDownloadGrantWorkflowInput = {
|
||||||
|
customer_id: string
|
||||||
|
sku: string
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
note?: string | null
|
||||||
|
type?: "grant" | "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDownloadGrantWorkflow = createWorkflow(
|
||||||
|
"create-download-grant",
|
||||||
|
function (input: CreateDownloadGrantWorkflowInput) {
|
||||||
|
const grantInput = transform({ input }, ({ input }) => ({
|
||||||
|
sku: input.sku,
|
||||||
|
product_title: input.product_title,
|
||||||
|
variant_title: input.variant_title,
|
||||||
|
note: input.note ?? null,
|
||||||
|
type: input.type ?? "grant",
|
||||||
|
}))
|
||||||
|
|
||||||
|
const grant = createDownloadGrantStep(grantInput)
|
||||||
|
|
||||||
|
const linkData = transform({ grant, input }, ({ grant, input }) => [
|
||||||
|
{
|
||||||
|
[Modules.CUSTOMER]: {
|
||||||
|
customer_id: input.customer_id,
|
||||||
|
},
|
||||||
|
[DOWNLOAD_GRANT_MODULE]: {
|
||||||
|
download_grant_id: grant.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
createRemoteLinkStep(linkData)
|
||||||
|
|
||||||
|
return new WorkflowResponse(grant)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default createDownloadGrantWorkflow
|
||||||
366
src/workflows/create-trptk-format-variants.ts
Normal file
366
src/workflows/create-trptk-format-variants.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
StepResponse,
|
||||||
|
transform,
|
||||||
|
WorkflowResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createInventoryLevelsWorkflow,
|
||||||
|
createProductVariantsWorkflow,
|
||||||
|
deleteProductVariantsWorkflow,
|
||||||
|
updateProductsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
import {
|
||||||
|
ALL_FORMATS,
|
||||||
|
buildVariant,
|
||||||
|
resolveFormatsFromSanity,
|
||||||
|
} from "../scripts/trptk-formats"
|
||||||
|
import type { ReleaseType } from "../scripts/trptk-formats"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateTrptkFormatVariantsInput {
|
||||||
|
product_id: string
|
||||||
|
catalogue_number: string
|
||||||
|
ean: string
|
||||||
|
exclude_formats?: string[]
|
||||||
|
price_overrides?: Record<string, number>
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
handle?: string
|
||||||
|
description?: string
|
||||||
|
image_url?: string
|
||||||
|
release_type?: string
|
||||||
|
available_variants?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 1 — Resolve product data and determine format plan
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ResolveProductDataOutput {
|
||||||
|
product: any
|
||||||
|
formatsToCreate: string[]
|
||||||
|
nonFormatVariantIds: string[]
|
||||||
|
existingFormats: string[]
|
||||||
|
newFormats: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveProductDataStep = createStep(
|
||||||
|
"resolve-product-data-for-formats",
|
||||||
|
async (
|
||||||
|
input: {
|
||||||
|
product_id: string
|
||||||
|
available_variants?: string[]
|
||||||
|
exclude_formats: string[]
|
||||||
|
},
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
const productModule = container.resolve(Modules.PRODUCT)
|
||||||
|
const product = await productModule.retrieveProduct(input.product_id, {
|
||||||
|
relations: ["options", "options.values", "variants", "variants.options"],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine which formats to create
|
||||||
|
let formatsToCreate: string[]
|
||||||
|
if (input.available_variants && input.available_variants.length > 0) {
|
||||||
|
formatsToCreate = resolveFormatsFromSanity(input.available_variants)
|
||||||
|
} else {
|
||||||
|
const excluded = new Set(input.exclude_formats)
|
||||||
|
formatsToCreate = ALL_FORMATS.filter((f) => !excluded.has(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify non-format variants to delete (e.g. "Default" variant)
|
||||||
|
const nonFormatVariantIds = (product.variants ?? [])
|
||||||
|
.filter((v: any) => {
|
||||||
|
const hasFormatOption = v.options?.some(
|
||||||
|
(o: any) => o.option?.title === "Format"
|
||||||
|
)
|
||||||
|
return !hasFormatOption
|
||||||
|
})
|
||||||
|
.map((v: any) => v.id)
|
||||||
|
|
||||||
|
// Check which formats already exist
|
||||||
|
const existingFormats = (
|
||||||
|
product.variants?.map((v: any) => {
|
||||||
|
const formatOption = v.options?.find(
|
||||||
|
(o: any) => o.option?.title === "Format"
|
||||||
|
)
|
||||||
|
return formatOption?.value
|
||||||
|
}).filter(Boolean) ?? []
|
||||||
|
) as string[]
|
||||||
|
|
||||||
|
const existingSet = new Set(existingFormats)
|
||||||
|
const newFormats = formatsToCreate.filter((f) => !existingSet.has(f))
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
product,
|
||||||
|
formatsToCreate,
|
||||||
|
nonFormatVariantIds,
|
||||||
|
existingFormats,
|
||||||
|
newFormats,
|
||||||
|
} as ResolveProductDataOutput)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 2 — Delete non-format variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const deleteNonFormatVariantsStep = createStep(
|
||||||
|
"delete-non-format-variants",
|
||||||
|
async (input: { ids: string[] }, { container }) => {
|
||||||
|
if (input.ids.length === 0) return new StepResponse(void 0)
|
||||||
|
await deleteProductVariantsWorkflow(container).run({
|
||||||
|
input: { ids: input.ids },
|
||||||
|
})
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 3 — Update product (options, metadata, Sanity fields)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const updateProductStep = createStep(
|
||||||
|
"update-product-for-formats",
|
||||||
|
async (
|
||||||
|
input: {
|
||||||
|
product_id: string
|
||||||
|
product: any
|
||||||
|
newFormats: string[]
|
||||||
|
existingFormats: string[]
|
||||||
|
catalogue_number: string
|
||||||
|
ean: string
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
handle?: string
|
||||||
|
description?: string
|
||||||
|
image_url?: string
|
||||||
|
},
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
const { product } = input
|
||||||
|
const formatOption = product.options?.find(
|
||||||
|
(o: any) => o.title === "Format"
|
||||||
|
)
|
||||||
|
const existingFormatValues =
|
||||||
|
formatOption?.values?.map((v: any) => v.value) ?? []
|
||||||
|
const allFormatValues = [
|
||||||
|
...new Set([...existingFormatValues, ...input.newFormats]),
|
||||||
|
]
|
||||||
|
|
||||||
|
const metadata: Record<string, unknown> = {
|
||||||
|
...product.metadata,
|
||||||
|
ean: input.ean,
|
||||||
|
catalogue_number: input.catalogue_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = formatOption
|
||||||
|
? [{ id: formatOption.id, title: "Format", values: allFormatValues }]
|
||||||
|
: [{ title: "Format", values: allFormatValues }]
|
||||||
|
|
||||||
|
const productUpdate: Record<string, unknown> = {
|
||||||
|
metadata,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
if (input.title) productUpdate.title = input.title
|
||||||
|
if (input.subtitle) productUpdate.subtitle = input.subtitle
|
||||||
|
if (input.handle) productUpdate.handle = input.handle
|
||||||
|
if (input.description) productUpdate.description = input.description
|
||||||
|
if (input.image_url) {
|
||||||
|
productUpdate.thumbnail = input.image_url
|
||||||
|
productUpdate.images = [{ url: input.image_url }]
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProductsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
selector: { id: input.product_id },
|
||||||
|
update: productUpdate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 4 — Create format variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CreateFormatVariantsOutput {
|
||||||
|
formats_added: number
|
||||||
|
formats: string[]
|
||||||
|
skus: (string | undefined)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFormatVariantsStep = createStep(
|
||||||
|
"create-format-variants",
|
||||||
|
async (
|
||||||
|
input: {
|
||||||
|
product_id: string
|
||||||
|
catalogue_number: string
|
||||||
|
newFormats: string[]
|
||||||
|
price_overrides: Record<string, number>
|
||||||
|
release_type?: string
|
||||||
|
},
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
if (input.newFormats.length === 0) {
|
||||||
|
return new StepResponse({
|
||||||
|
formats_added: 0,
|
||||||
|
formats: [],
|
||||||
|
skus: [],
|
||||||
|
} as CreateFormatVariantsOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = input.newFormats.map((format) => {
|
||||||
|
const variant = buildVariant(
|
||||||
|
input.catalogue_number,
|
||||||
|
format,
|
||||||
|
input.price_overrides[format],
|
||||||
|
input.release_type as ReleaseType | undefined
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...variant,
|
||||||
|
product_id: input.product_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await createProductVariantsWorkflow(container).run({
|
||||||
|
input: { product_variants: variants },
|
||||||
|
})
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
formats_added: input.newFormats.length,
|
||||||
|
formats: input.newFormats,
|
||||||
|
skus: input.newFormats.map(
|
||||||
|
(f) => variants.find((v) => v.title === f)?.sku
|
||||||
|
),
|
||||||
|
} as CreateFormatVariantsOutput)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 5 — Create inventory levels for new variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const createInventoryLevelsStep = createStep(
|
||||||
|
"create-inventory-levels-for-formats",
|
||||||
|
async (_input: Record<string, never>, { container }) => {
|
||||||
|
const storeModule = container.resolve(Modules.STORE)
|
||||||
|
const [store] = await storeModule.listStores()
|
||||||
|
const stockLocationId = store.default_location_id
|
||||||
|
|
||||||
|
if (!stockLocationId) return new StepResponse(void 0)
|
||||||
|
|
||||||
|
const queryService = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const { data: inventoryItems } = await queryService.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
const { data: existingLevels } = await queryService.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["inventory_item_id"],
|
||||||
|
})
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
existingLevels.map(
|
||||||
|
(l: { inventory_item_id: string }) => l.inventory_item_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const newLevels = inventoryItems
|
||||||
|
.filter((item: { id: string }) => !existingItemIds.has(item.id))
|
||||||
|
.map((item: { id: string }) => ({
|
||||||
|
location_id: stockLocationId,
|
||||||
|
stocked_quantity: 0,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newLevels.length > 0) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: { inventory_levels: newLevels },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workflow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const createTrptkFormatVariantsWorkflow = createWorkflow(
|
||||||
|
"create-trptk-format-variants",
|
||||||
|
function (input: CreateTrptkFormatVariantsInput) {
|
||||||
|
const resolveInput = transform(input, (data) => ({
|
||||||
|
product_id: data.product_id,
|
||||||
|
available_variants: data.available_variants,
|
||||||
|
exclude_formats: data.exclude_formats ?? [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resolved = resolveProductDataStep(resolveInput)
|
||||||
|
|
||||||
|
// Delete non-format variants
|
||||||
|
const deleteInput = transform(resolved, (data) => ({
|
||||||
|
ids: data.nonFormatVariantIds,
|
||||||
|
}))
|
||||||
|
deleteNonFormatVariantsStep(deleteInput)
|
||||||
|
|
||||||
|
// Update product with options, metadata, and Sanity fields
|
||||||
|
const updateInput = transform(
|
||||||
|
{ input, resolved },
|
||||||
|
({ input: inp, resolved: res }) => ({
|
||||||
|
product_id: inp.product_id,
|
||||||
|
product: res.product,
|
||||||
|
newFormats: res.newFormats,
|
||||||
|
existingFormats: res.existingFormats,
|
||||||
|
catalogue_number: inp.catalogue_number,
|
||||||
|
ean: inp.ean,
|
||||||
|
title: inp.title,
|
||||||
|
subtitle: inp.subtitle,
|
||||||
|
handle: inp.handle,
|
||||||
|
description: inp.description,
|
||||||
|
image_url: inp.image_url,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
updateProductStep(updateInput)
|
||||||
|
|
||||||
|
// Create new format variants
|
||||||
|
const createInput = transform(
|
||||||
|
{ input, resolved },
|
||||||
|
({ input: inp, resolved: res }) => ({
|
||||||
|
product_id: inp.product_id,
|
||||||
|
catalogue_number: inp.catalogue_number,
|
||||||
|
newFormats: res.newFormats,
|
||||||
|
price_overrides: inp.price_overrides ?? {},
|
||||||
|
release_type: inp.release_type,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const createResult = createFormatVariantsStep(createInput)
|
||||||
|
|
||||||
|
// Create inventory levels for any new items
|
||||||
|
createInventoryLevelsStep({})
|
||||||
|
|
||||||
|
// Build the final response
|
||||||
|
const result = transform(
|
||||||
|
{ input, createResult },
|
||||||
|
({ input: inp, createResult: cr }) => ({
|
||||||
|
message:
|
||||||
|
cr.formats_added === 0
|
||||||
|
? "All formats already exist"
|
||||||
|
: `Added ${cr.formats_added} format variant(s)`,
|
||||||
|
product_id: inp.product_id,
|
||||||
|
formats_added: cr.formats_added,
|
||||||
|
formats: cr.formats,
|
||||||
|
skus: cr.skus,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return new WorkflowResponse(result)
|
||||||
|
}
|
||||||
|
)
|
||||||
42
src/workflows/delete-download-grant.ts
Normal file
42
src/workflows/delete-download-grant.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import {
|
||||||
|
createWorkflow,
|
||||||
|
transform,
|
||||||
|
WorkflowResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
import { deleteDownloadGrantStep } from "./steps/delete-download-grant"
|
||||||
|
import { DOWNLOAD_GRANT_MODULE } from "../modules/downloadGrant"
|
||||||
|
|
||||||
|
type DeleteDownloadGrantWorkflowInput = {
|
||||||
|
customer_id: string
|
||||||
|
grant_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDownloadGrantWorkflow = createWorkflow(
|
||||||
|
"delete-download-grant",
|
||||||
|
function (input: DeleteDownloadGrantWorkflowInput) {
|
||||||
|
const linkData = transform({ input }, ({ input }) => [
|
||||||
|
{
|
||||||
|
[Modules.CUSTOMER]: {
|
||||||
|
customer_id: input.customer_id,
|
||||||
|
},
|
||||||
|
[DOWNLOAD_GRANT_MODULE]: {
|
||||||
|
download_grant_id: input.grant_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
dismissRemoteLinkStep(linkData)
|
||||||
|
|
||||||
|
const grantInput = transform({ input }, ({ input }) => ({
|
||||||
|
id: input.grant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
deleteDownloadGrantStep(grantInput)
|
||||||
|
|
||||||
|
return new WorkflowResponse({ id: input.grant_id, deleted: true })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default deleteDownloadGrantWorkflow
|
||||||
42
src/workflows/fulfill-digital-items.ts
Normal file
42
src/workflows/fulfill-digital-items.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
StepResponse,
|
||||||
|
WorkflowResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { Modules } from "@medusajs/framework/utils"
|
||||||
|
|
||||||
|
type FulfillDigitalItemsInput = {
|
||||||
|
order_id: string
|
||||||
|
items: { id: string; quantity: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a fulfillment on the order for digital items.
|
||||||
|
*
|
||||||
|
* Unlike createOrderFulfillmentWorkflow (which creates a physical fulfillment
|
||||||
|
* record and requires a shipping method, provider, and stock location), this
|
||||||
|
* step simply marks the specified items as fulfilled on the order using the
|
||||||
|
* Order Module directly.
|
||||||
|
*/
|
||||||
|
const registerDigitalFulfillmentStep = createStep(
|
||||||
|
"register-digital-fulfillment",
|
||||||
|
async (input: FulfillDigitalItemsInput, { container }) => {
|
||||||
|
const orderModule = container.resolve(Modules.ORDER)
|
||||||
|
|
||||||
|
await orderModule.registerFulfillment({
|
||||||
|
order_id: input.order_id,
|
||||||
|
items: input.items,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const fulfillDigitalItemsWorkflow = createWorkflow(
|
||||||
|
"fulfill-digital-items",
|
||||||
|
function (input: FulfillDigitalItemsInput) {
|
||||||
|
registerDigitalFulfillmentStep(input)
|
||||||
|
return new WorkflowResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
26
src/workflows/steps/create-download-grant.ts
Normal file
26
src/workflows/steps/create-download-grant.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { DOWNLOAD_GRANT_MODULE } from "../../modules/downloadGrant"
|
||||||
|
|
||||||
|
type CreateDownloadGrantInput = {
|
||||||
|
sku: string
|
||||||
|
product_title: string
|
||||||
|
variant_title: string
|
||||||
|
note?: string | null
|
||||||
|
type?: "grant" | "block"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDownloadGrantStep = createStep(
|
||||||
|
"create-download-grant",
|
||||||
|
async (input: CreateDownloadGrantInput, { container }) => {
|
||||||
|
const downloadGrantService = container.resolve(DOWNLOAD_GRANT_MODULE)
|
||||||
|
|
||||||
|
const grant = await downloadGrantService.createDownloadGrants(input)
|
||||||
|
|
||||||
|
return new StepResponse(grant, grant.id)
|
||||||
|
},
|
||||||
|
async (id, { container }) => {
|
||||||
|
if (!id) return
|
||||||
|
const downloadGrantService = container.resolve(DOWNLOAD_GRANT_MODULE)
|
||||||
|
await downloadGrantService.deleteDownloadGrants(id)
|
||||||
|
}
|
||||||
|
)
|
||||||
24
src/workflows/steps/delete-download-grant.ts
Normal file
24
src/workflows/steps/delete-download-grant.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { DOWNLOAD_GRANT_MODULE } from "../../modules/downloadGrant"
|
||||||
|
|
||||||
|
type DeleteDownloadGrantInput = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteDownloadGrantStep = createStep(
|
||||||
|
"delete-download-grant",
|
||||||
|
async (input: DeleteDownloadGrantInput, { container }) => {
|
||||||
|
const downloadGrantService = container.resolve(DOWNLOAD_GRANT_MODULE)
|
||||||
|
|
||||||
|
const grant = await downloadGrantService.retrieveDownloadGrant(input.id)
|
||||||
|
|
||||||
|
await downloadGrantService.deleteDownloadGrants(input.id)
|
||||||
|
|
||||||
|
return new StepResponse(grant.id, grant)
|
||||||
|
},
|
||||||
|
async (grant, { container }) => {
|
||||||
|
if (!grant) return
|
||||||
|
const downloadGrantService = container.resolve(DOWNLOAD_GRANT_MODULE)
|
||||||
|
await downloadGrantService.createDownloadGrants(grant)
|
||||||
|
}
|
||||||
|
)
|
||||||
461
src/workflows/sync-product-from-sanity.ts
Normal file
461
src/workflows/sync-product-from-sanity.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
import {
|
||||||
|
createStep,
|
||||||
|
createWorkflow,
|
||||||
|
StepResponse,
|
||||||
|
transform,
|
||||||
|
WorkflowResponse,
|
||||||
|
} from "@medusajs/framework/workflows-sdk"
|
||||||
|
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
||||||
|
import {
|
||||||
|
createInventoryLevelsWorkflow,
|
||||||
|
createProductVariantsWorkflow,
|
||||||
|
deleteProductVariantsWorkflow,
|
||||||
|
updateProductsWorkflow,
|
||||||
|
} from "@medusajs/medusa/core-flows"
|
||||||
|
import {
|
||||||
|
buildVariant,
|
||||||
|
resolveFormatsFromSanity,
|
||||||
|
} from "../scripts/trptk-formats"
|
||||||
|
import type { ReleaseType } from "../scripts/trptk-formats"
|
||||||
|
import { getSanityClient } from "../scripts/sanity-client"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input / internal types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SyncProductFromSanityInput {
|
||||||
|
product_id: string
|
||||||
|
catalogue_number: string
|
||||||
|
ean: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SanityRelease {
|
||||||
|
name?: string
|
||||||
|
albumArtist?: string
|
||||||
|
slug?: { current?: string }
|
||||||
|
shortDescription?: string
|
||||||
|
catalogNo?: string
|
||||||
|
upc?: string
|
||||||
|
format?: string
|
||||||
|
releaseDate?: string
|
||||||
|
availableVariants?: string[]
|
||||||
|
albumCover?: { asset?: { url?: string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 1 — Fetch release data from Sanity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fetchSanityReleaseStep = createStep(
|
||||||
|
"fetch-sanity-release",
|
||||||
|
async (input: { catalogue_number: string; ean: string }) => {
|
||||||
|
if (!process.env.SANITY_PROJECT_ID) {
|
||||||
|
throw new Error(
|
||||||
|
"SANITY_PROJECT_ID is not configured. Add it to your .env file."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanity = getSanityClient(false)
|
||||||
|
|
||||||
|
const release = await sanity.fetch<SanityRelease | null>(
|
||||||
|
`*[_type == "release" && (upc == $ean || catalogNo == $catalogueNumber)][0]{
|
||||||
|
name,
|
||||||
|
albumArtist,
|
||||||
|
slug,
|
||||||
|
shortDescription,
|
||||||
|
catalogNo,
|
||||||
|
upc,
|
||||||
|
format,
|
||||||
|
releaseDate,
|
||||||
|
availableVariants,
|
||||||
|
"albumCover": albumCover{
|
||||||
|
"asset": asset->{
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ ean: input.ean, catalogueNumber: input.catalogue_number }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
throw new Error("Release not found in Sanity")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!release.availableVariants || release.availableVariants.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"No availableVariants defined in Sanity for this release. Add variants in Sanity first."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse(release as SanityRelease)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 2 — Resolve product and compute variant diff
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface VariantDiffOutput {
|
||||||
|
product: any
|
||||||
|
sanityFormats: string[]
|
||||||
|
releaseType: string | undefined
|
||||||
|
formatOptionId: string | undefined
|
||||||
|
formatOption: any
|
||||||
|
toCreate: string[]
|
||||||
|
toDeleteIds: string[]
|
||||||
|
toDeleteNames: string[]
|
||||||
|
nonFormatVariantIds: string[]
|
||||||
|
allToDelete: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveVariantDiffStep = createStep(
|
||||||
|
"resolve-variant-diff",
|
||||||
|
async (
|
||||||
|
input: { product_id: string; release: SanityRelease },
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
const productModule = container.resolve(Modules.PRODUCT)
|
||||||
|
const product = await productModule.retrieveProduct(input.product_id, {
|
||||||
|
relations: ["options", "options.values", "variants", "variants.options"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const sanityFormats = resolveFormatsFromSanity(
|
||||||
|
input.release.availableVariants!
|
||||||
|
)
|
||||||
|
const releaseType = input.release.format as ReleaseType | undefined
|
||||||
|
const sanityFormatSet = new Set(sanityFormats)
|
||||||
|
|
||||||
|
// Find the Format option
|
||||||
|
const formatOption = product.options?.find(
|
||||||
|
(o: any) => o.title === "Format"
|
||||||
|
)
|
||||||
|
const formatOptionId = formatOption?.id
|
||||||
|
|
||||||
|
// Map existing format variants
|
||||||
|
const existingFormatVariants = new Map<string, string>()
|
||||||
|
for (const v of product.variants ?? []) {
|
||||||
|
const formatOpt = (v as any).options?.find(
|
||||||
|
(o: any) => o.option_id === formatOptionId
|
||||||
|
)
|
||||||
|
if (formatOpt?.value) {
|
||||||
|
existingFormatVariants.set(formatOpt.value, (v as any).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine adds and deletes
|
||||||
|
const toCreate = sanityFormats.filter(
|
||||||
|
(f) => !existingFormatVariants.has(f)
|
||||||
|
)
|
||||||
|
|
||||||
|
const toDeleteIds: string[] = []
|
||||||
|
const toDeleteNames: string[] = []
|
||||||
|
for (const [format, variantId] of existingFormatVariants) {
|
||||||
|
if (!sanityFormatSet.has(format)) {
|
||||||
|
toDeleteIds.push(variantId)
|
||||||
|
toDeleteNames.push(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-format variants to delete
|
||||||
|
const nonFormatVariantIds = (product.variants ?? [])
|
||||||
|
.filter((v: any) => {
|
||||||
|
const hasFormatOption =
|
||||||
|
formatOptionId &&
|
||||||
|
v.options?.some((o: any) => o.option_id === formatOptionId)
|
||||||
|
return !hasFormatOption
|
||||||
|
})
|
||||||
|
.map((v: any) => v.id)
|
||||||
|
|
||||||
|
const allToDelete = [...toDeleteIds, ...nonFormatVariantIds]
|
||||||
|
|
||||||
|
return new StepResponse({
|
||||||
|
product,
|
||||||
|
sanityFormats,
|
||||||
|
releaseType: releaseType as string | undefined,
|
||||||
|
formatOptionId,
|
||||||
|
formatOption,
|
||||||
|
toCreate,
|
||||||
|
toDeleteIds,
|
||||||
|
toDeleteNames,
|
||||||
|
nonFormatVariantIds,
|
||||||
|
allToDelete,
|
||||||
|
} as VariantDiffOutput)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 3 — Delete stale variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const deleteStaleVariantsStep = createStep(
|
||||||
|
"delete-stale-variants",
|
||||||
|
async (input: { ids: string[] }, { container }) => {
|
||||||
|
if (input.ids.length === 0) return new StepResponse(void 0)
|
||||||
|
await deleteProductVariantsWorkflow(container).run({
|
||||||
|
input: { ids: input.ids },
|
||||||
|
})
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 4 — Update product from Sanity data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const updateProductFromSanityStep = createStep(
|
||||||
|
"update-product-from-sanity",
|
||||||
|
async (
|
||||||
|
input: {
|
||||||
|
product_id: string
|
||||||
|
product: any
|
||||||
|
release: SanityRelease
|
||||||
|
sanityFormats: string[]
|
||||||
|
formatOption: any
|
||||||
|
ean: string
|
||||||
|
catalogue_number: string
|
||||||
|
},
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
const { product, release, formatOption } = input
|
||||||
|
|
||||||
|
const options = formatOption
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: formatOption.id,
|
||||||
|
title: "Format",
|
||||||
|
values: input.sanityFormats,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [{ title: "Format", values: input.sanityFormats }]
|
||||||
|
|
||||||
|
const productUpdate: Record<string, unknown> = {
|
||||||
|
options,
|
||||||
|
metadata: {
|
||||||
|
...product.metadata,
|
||||||
|
ean: input.ean,
|
||||||
|
catalogue_number: input.catalogue_number,
|
||||||
|
...(release.releaseDate
|
||||||
|
? { release_date: release.releaseDate }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (release.name) productUpdate.title = release.name
|
||||||
|
if (release.albumArtist) productUpdate.subtitle = release.albumArtist
|
||||||
|
if (release.slug?.current) productUpdate.handle = release.slug.current
|
||||||
|
if (release.shortDescription)
|
||||||
|
productUpdate.description = release.shortDescription
|
||||||
|
if (release.albumCover?.asset?.url) {
|
||||||
|
productUpdate.thumbnail = release.albumCover.asset.url
|
||||||
|
productUpdate.images = [{ url: release.albumCover.asset.url }]
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateProductsWorkflow(container).run({
|
||||||
|
input: {
|
||||||
|
selector: { id: input.product_id },
|
||||||
|
update: productUpdate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 5 — Ensure shipping profile and sales channel links
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ensureProductLinksStep = createStep(
|
||||||
|
"ensure-product-links",
|
||||||
|
async (input: { product_id: string }, { container }) => {
|
||||||
|
const fulfillmentModule = container.resolve(Modules.FULFILLMENT)
|
||||||
|
const salesChannelModule = container.resolve(Modules.SALES_CHANNEL)
|
||||||
|
const link = container.resolve(ContainerRegistrationKeys.LINK)
|
||||||
|
|
||||||
|
const shippingProfiles =
|
||||||
|
await fulfillmentModule.listShippingProfiles({ type: "default" })
|
||||||
|
if (shippingProfiles[0]) {
|
||||||
|
try {
|
||||||
|
await link.create({
|
||||||
|
[Modules.PRODUCT]: { product_id: input.product_id },
|
||||||
|
[Modules.FULFILLMENT]: {
|
||||||
|
shipping_profile_id: shippingProfiles[0].id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Link already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [salesChannel] = await salesChannelModule.listSalesChannels({
|
||||||
|
name: "Default Sales Channel",
|
||||||
|
})
|
||||||
|
if (salesChannel) {
|
||||||
|
try {
|
||||||
|
await link.create({
|
||||||
|
[Modules.PRODUCT]: { product_id: input.product_id },
|
||||||
|
[Modules.SALES_CHANNEL]: { sales_channel_id: salesChannel.id },
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Link already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 6 — Create new variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const createSyncVariantsStep = createStep(
|
||||||
|
"create-sync-variants",
|
||||||
|
async (
|
||||||
|
input: {
|
||||||
|
product_id: string
|
||||||
|
catalogue_number: string
|
||||||
|
toCreate: string[]
|
||||||
|
releaseType?: string
|
||||||
|
},
|
||||||
|
{ container }
|
||||||
|
) => {
|
||||||
|
if (input.toCreate.length === 0) return new StepResponse(void 0)
|
||||||
|
|
||||||
|
const variants = input.toCreate.map((format) => ({
|
||||||
|
...buildVariant(
|
||||||
|
input.catalogue_number,
|
||||||
|
format,
|
||||||
|
undefined,
|
||||||
|
input.releaseType as ReleaseType | undefined
|
||||||
|
),
|
||||||
|
product_id: input.product_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await createProductVariantsWorkflow(container).run({
|
||||||
|
input: { product_variants: variants },
|
||||||
|
})
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Step 7 — Create inventory levels for new physical variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const createSyncInventoryLevelsStep = createStep(
|
||||||
|
"create-sync-inventory-levels",
|
||||||
|
async (_input: Record<string, never>, { container }) => {
|
||||||
|
const storeModule = container.resolve(Modules.STORE)
|
||||||
|
const [store] = await storeModule.listStores()
|
||||||
|
const stockLocationId = store.default_location_id
|
||||||
|
|
||||||
|
if (!stockLocationId) return new StepResponse(void 0)
|
||||||
|
|
||||||
|
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||||
|
const { data: inventoryItems } = await query.graph({
|
||||||
|
entity: "inventory_item",
|
||||||
|
fields: ["id"],
|
||||||
|
})
|
||||||
|
const { data: existingLevels } = await query.graph({
|
||||||
|
entity: "inventory_level",
|
||||||
|
fields: ["inventory_item_id"],
|
||||||
|
})
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
existingLevels.map(
|
||||||
|
(l: { inventory_item_id: string }) => l.inventory_item_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const newLevels = inventoryItems
|
||||||
|
.filter((item: { id: string }) => !existingItemIds.has(item.id))
|
||||||
|
.map((item: { id: string }) => ({
|
||||||
|
location_id: stockLocationId,
|
||||||
|
stocked_quantity: 0,
|
||||||
|
inventory_item_id: item.id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (newLevels.length > 0) {
|
||||||
|
await createInventoryLevelsWorkflow(container).run({
|
||||||
|
input: { inventory_levels: newLevels },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StepResponse(void 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workflow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const syncProductFromSanityWorkflow = createWorkflow(
|
||||||
|
"sync-product-from-sanity",
|
||||||
|
function (input: SyncProductFromSanityInput) {
|
||||||
|
// Step 1: Fetch from Sanity
|
||||||
|
const fetchInput = transform(input, (data) => ({
|
||||||
|
catalogue_number: data.catalogue_number,
|
||||||
|
ean: data.ean,
|
||||||
|
}))
|
||||||
|
const release = fetchSanityReleaseStep(fetchInput)
|
||||||
|
|
||||||
|
// Step 2: Resolve variant diff
|
||||||
|
const diffInput = transform({ input, release }, ({ input: inp, release: rel }) => ({
|
||||||
|
product_id: inp.product_id,
|
||||||
|
release: rel,
|
||||||
|
}))
|
||||||
|
const diff = resolveVariantDiffStep(diffInput)
|
||||||
|
|
||||||
|
// Step 3: Delete stale variants
|
||||||
|
const deleteInput = transform(diff, (d) => ({ ids: d.allToDelete }))
|
||||||
|
deleteStaleVariantsStep(deleteInput)
|
||||||
|
|
||||||
|
// Step 4: Update product from Sanity data
|
||||||
|
const updateInput = transform(
|
||||||
|
{ input, release, diff },
|
||||||
|
({ input: inp, release: rel, diff: d }) => ({
|
||||||
|
product_id: inp.product_id,
|
||||||
|
product: d.product,
|
||||||
|
release: rel,
|
||||||
|
sanityFormats: d.sanityFormats,
|
||||||
|
formatOption: d.formatOption,
|
||||||
|
ean: inp.ean,
|
||||||
|
catalogue_number: inp.catalogue_number,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
updateProductFromSanityStep(updateInput)
|
||||||
|
|
||||||
|
// Step 5: Ensure links
|
||||||
|
const linkInput = transform(input, (data) => ({
|
||||||
|
product_id: data.product_id,
|
||||||
|
}))
|
||||||
|
ensureProductLinksStep(linkInput)
|
||||||
|
|
||||||
|
// Step 6: Create new variants
|
||||||
|
const createInput = transform(
|
||||||
|
{ input, diff },
|
||||||
|
({ input: inp, diff: d }) => ({
|
||||||
|
product_id: inp.product_id,
|
||||||
|
catalogue_number: inp.catalogue_number,
|
||||||
|
toCreate: d.toCreate,
|
||||||
|
releaseType: d.releaseType,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
createSyncVariantsStep(createInput)
|
||||||
|
|
||||||
|
// Step 7: Create inventory levels (only after creating variants)
|
||||||
|
createSyncInventoryLevelsStep({})
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
const result = transform(
|
||||||
|
{ input, diff },
|
||||||
|
({ input: inp, diff: d }) => ({
|
||||||
|
message: `Sync complete: ${d.toCreate.length} added, ${d.toDeleteNames.length} removed`,
|
||||||
|
product_id: inp.product_id,
|
||||||
|
formats_added: d.toCreate,
|
||||||
|
formats_removed: d.toDeleteNames,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return new WorkflowResponse(result)
|
||||||
|
}
|
||||||
|
)
|
||||||
78
trptk-pricing.json
Normal file
78
trptk-pricing.json
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
{
|
||||||
|
"single": {
|
||||||
|
"CD": 6,
|
||||||
|
"SACD": 7,
|
||||||
|
"LP": 10,
|
||||||
|
"FLAC Stereo 88.2/24": 5,
|
||||||
|
"FLAC Stereo 176.4/24": 6,
|
||||||
|
"FLAC Stereo 352.8/24": 7,
|
||||||
|
"FLAC Stereo 352.8/32": 8,
|
||||||
|
"FLAC Surround 88.2/24": 6,
|
||||||
|
"FLAC Surround 176.4/24": 7,
|
||||||
|
"FLAC Surround 352.8/24": 8,
|
||||||
|
"FLAC Surround 352.8/32": 9,
|
||||||
|
"Dolby Atmos DTS:X Auro-3D MKV": 10,
|
||||||
|
"Auro-3D FLAC": 8,
|
||||||
|
"Dolby Atmos ADM 48kHz": 10,
|
||||||
|
"Dolby Atmos ADM 96kHz": 11,
|
||||||
|
"HD Video": 9,
|
||||||
|
"4K Video": 13
|
||||||
|
},
|
||||||
|
"ep": {
|
||||||
|
"CD": 10,
|
||||||
|
"SACD": 11,
|
||||||
|
"LP": 15,
|
||||||
|
"FLAC Stereo 88.2/24": 8,
|
||||||
|
"FLAC Stereo 176.4/24": 9,
|
||||||
|
"FLAC Stereo 352.8/24": 11,
|
||||||
|
"FLAC Stereo 352.8/32": 12,
|
||||||
|
"FLAC Surround 88.2/24": 11,
|
||||||
|
"FLAC Surround 176.4/24": 12,
|
||||||
|
"FLAC Surround 352.8/24": 14,
|
||||||
|
"FLAC Surround 352.8/32": 15,
|
||||||
|
"Dolby Atmos DTS:X Auro-3D MKV": 15,
|
||||||
|
"Auro-3D FLAC": 12,
|
||||||
|
"Dolby Atmos ADM 48kHz": 15,
|
||||||
|
"Dolby Atmos ADM 96kHz": 17,
|
||||||
|
"HD Video": 14,
|
||||||
|
"4K Video": 19
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"CD": 19,
|
||||||
|
"SACD": 21,
|
||||||
|
"LP": 29,
|
||||||
|
"FLAC Stereo 88.2/24": 16,
|
||||||
|
"FLAC Stereo 176.4/24": 18,
|
||||||
|
"FLAC Stereo 352.8/24": 22,
|
||||||
|
"FLAC Stereo 352.8/32": 24,
|
||||||
|
"FLAC Surround 88.2/24": 21,
|
||||||
|
"FLAC Surround 176.4/24": 23,
|
||||||
|
"FLAC Surround 352.8/24": 27,
|
||||||
|
"FLAC Surround 352.8/32": 29,
|
||||||
|
"Dolby Atmos DTS:X Auro-3D MKV": 29,
|
||||||
|
"Auro-3D FLAC": 24,
|
||||||
|
"Dolby Atmos ADM 48kHz": 29,
|
||||||
|
"Dolby Atmos ADM 96kHz": 34,
|
||||||
|
"HD Video": 29,
|
||||||
|
"4K Video": 39
|
||||||
|
},
|
||||||
|
"boxset": {
|
||||||
|
"CD": 29,
|
||||||
|
"SACD": 32,
|
||||||
|
"LP": 44,
|
||||||
|
"FLAC Stereo 88.2/24": 24,
|
||||||
|
"FLAC Stereo 176.4/24": 27,
|
||||||
|
"FLAC Stereo 352.8/24": 33,
|
||||||
|
"FLAC Stereo 352.8/32": 36,
|
||||||
|
"FLAC Surround 88.2/24": 32,
|
||||||
|
"FLAC Surround 176.4/24": 35,
|
||||||
|
"FLAC Surround 352.8/24": 41,
|
||||||
|
"FLAC Surround 352.8/32": 44,
|
||||||
|
"Dolby Atmos DTS:X Auro-3D MKV": 44,
|
||||||
|
"Auro-3D FLAC": 36,
|
||||||
|
"Dolby Atmos ADM 48kHz": 44,
|
||||||
|
"Dolby Atmos ADM 96kHz": 51,
|
||||||
|
"HD Video": 44,
|
||||||
|
"4K Video": 59
|
||||||
|
}
|
||||||
|
}
|
||||||
35
tsconfig.json
Normal file
35
tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"skipDefaultLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false,
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"outDir": "./.medusa/server",
|
||||||
|
"rootDir": "./",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"strictNullChecks": true
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"swc": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*",
|
||||||
|
".medusa/types/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
".medusa/server",
|
||||||
|
".medusa/admin",
|
||||||
|
".cache"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue