├── src ├── admin │ ├── vite-env.d.ts │ ├── components │ │ ├── modals │ │ │ ├── route-drawer │ │ │ │ ├── index.ts │ │ │ │ └── route-drawer.tsx │ │ │ ├── route-modal-form │ │ │ │ ├── index.ts │ │ │ │ └── route-modal-form.tsx │ │ │ ├── utilities │ │ │ │ ├── keybound-form │ │ │ │ │ ├── index.ts │ │ │ │ │ └── keybound-form.tsx │ │ │ │ └── visually-hidden │ │ │ │ │ ├── index.ts │ │ │ │ │ └── visually-hidden.tsx │ │ │ ├── route-modal-provider │ │ │ │ ├── index.ts │ │ │ │ ├── route-modal-context.tsx │ │ │ │ ├── use-route-modal.tsx │ │ │ │ └── route-provider.tsx │ │ │ ├── stacked-modal-provider │ │ │ │ ├── index.ts │ │ │ │ ├── stacked-modal-context.tsx │ │ │ │ ├── use-stacked-modal.ts │ │ │ │ └── stacked-modal-provider.tsx │ │ │ └── route-focus-modal │ │ │ │ └── index.tsx │ │ ├── single-column-layout.tsx │ │ ├── general │ │ │ ├── container.tsx │ │ │ ├── section-row.tsx │ │ │ ├── header.tsx │ │ │ ├── action-menu.tsx │ │ │ ├── duration-input.tsx │ │ │ └── chip-input.tsx │ │ ├── data-table-action.tsx │ │ └── form │ │ │ └── form.tsx │ ├── i18n │ │ ├── index.ts │ │ ├── en.json │ │ ├── it.json │ │ └── $schema.json │ ├── i18next.d.ts │ ├── lib │ │ ├── sdk.ts │ │ └── extended-admin.ts │ ├── hooks │ │ ├── use-postmark-options.tsx │ │ ├── use-state-aware-to.tsx │ │ ├── use-debounced-search.tsx │ │ ├── use-postmark-templates.tsx │ │ ├── use-combobox-data.tsx │ │ ├── use-reminder-schedules.ts │ │ └── use-postmark-table.tsx │ ├── tsconfig.json │ └── routes │ │ └── postmark │ │ ├── abandonded-carts │ │ ├── page.tsx │ │ ├── @create │ │ │ ├── page.tsx │ │ │ └── create-form.tsx │ │ ├── @edit │ │ │ └── [id] │ │ │ │ ├── page.tsx │ │ │ │ └── edit-form.tsx │ │ ├── validate-templates-section.tsx │ │ └── reminder-schedules-table.tsx │ │ └── page.tsx ├── workflows │ ├── index.ts │ ├── steps │ │ ├── default-hooks │ │ │ └── abandoned-cart.ts │ │ └── fetch-abandoned-carts.ts │ ├── notification-data.ts │ └── abandoned-carts.ts ├── types │ ├── templates.ts │ ├── validation.ts │ ├── abandoned-cart-tracking.ts │ └── reminder-schedules.ts ├── providers │ └── postmark │ │ ├── index.ts │ │ └── service.ts ├── modules │ ├── abandoned-cart │ │ ├── service.ts │ │ ├── models │ │ │ └── reminder-schedules.ts │ │ ├── index.ts │ │ └── migrations │ │ │ ├── Migration20251026135601.ts │ │ │ └── .snapshot-medusa-postmark-abandoned-cart.json │ └── postmark │ │ ├── index.ts │ │ ├── loaders │ │ └── postmark-server.ts │ │ └── service.ts ├── SUMMARY.md ├── api │ ├── admin │ │ └── postmark │ │ │ ├── options │ │ │ └── route.ts │ │ │ ├── templates │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ │ └── abandoned-carts │ │ │ └── reminders │ │ │ ├── schedules │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ │ └── validate │ │ │ └── route.ts │ └── middlewares.ts ├── links │ └── reminder-schedule_template.ts ├── docs │ ├── Variables.md │ ├── Templates.md │ ├── Events.md │ ├── API-Routes.md │ ├── Reminder-Schedules.md │ ├── Introduction.md │ └── Configuration.md └── jobs │ └── abandoned-carts.ts ├── integration-tests ├── setup.js └── medusa-config.ts ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── mdbook.yml │ └── release-and-publish.yaml ├── tsconfig.json ├── jest.config.js ├── LICENSE ├── CHANGELOG.md ├── .gitignore ├── package.json ├── REMINDER-SCHEDULES-BEHAVIOUR.md └── README.md /src/admin/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | declare const __BACKEND_URL__: string; 2 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-drawer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./route-drawer" 2 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-modal-form/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./route-modal-form" 2 | -------------------------------------------------------------------------------- /src/admin/components/modals/utilities/keybound-form/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./keybound-form" 2 | -------------------------------------------------------------------------------- /src/admin/components/modals/utilities/visually-hidden/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./visually-hidden" 2 | -------------------------------------------------------------------------------- /src/workflows/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./abandoned-carts" 2 | export * from "./notification-data" 3 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-modal-provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./route-provider" 2 | export * from "./use-route-modal" 3 | -------------------------------------------------------------------------------- /integration-tests/setup.js: -------------------------------------------------------------------------------- 1 | const { MetadataStorage } = require("@mikro-orm/core") 2 | 3 | process.chdir(__dirname) 4 | MetadataStorage.clear() 5 | -------------------------------------------------------------------------------- /src/admin/components/modals/stacked-modal-provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./stacked-modal-provider" 2 | export * from "./use-stacked-modal" 3 | -------------------------------------------------------------------------------- /src/types/templates.ts: -------------------------------------------------------------------------------- 1 | export type PostmarkTemplate = { 2 | Name: string 3 | Alias: string 4 | TemplateType: string 5 | LayoutTemplate: string | null 6 | TemplateId: number 7 | } 8 | -------------------------------------------------------------------------------- /src/admin/components/modals/utilities/visually-hidden/visually-hidden.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react" 2 | 3 | export const VisuallyHidden = ({ children }: PropsWithChildren) => { 4 | return {children} 5 | } 6 | -------------------------------------------------------------------------------- /src/providers/postmark/index.ts: -------------------------------------------------------------------------------- 1 | import PostmarkProviderService from "./service" 2 | import { ModuleProvider, Modules } from "@medusajs/framework/utils" 3 | 4 | export default ModuleProvider(Modules.NOTIFICATION, { 5 | services: [PostmarkProviderService], 6 | }) 7 | -------------------------------------------------------------------------------- /src/admin/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import en from "./en.json" with { type: "json" } 2 | import it from "./it.json" with { type: "json" } 3 | 4 | export default { 5 | en: { 6 | postmark: en, 7 | }, 8 | it: { 9 | postmark: it, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/abandoned-cart/service.ts: -------------------------------------------------------------------------------- 1 | import { MedusaService } from "@medusajs/framework/utils" 2 | import ReminderSchedule from "./models/reminder-schedules" 3 | 4 | class AbandonedCartModuleService extends MedusaService({ 5 | ReminderSchedule, 6 | }) { } 7 | 8 | export default AbandonedCartModuleService 9 | -------------------------------------------------------------------------------- /src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](docs/Introduction.md) 4 | - [Configuration](docs/Configuration.md) 5 | - [Events](docs/Events.md) 6 | - [Templates](docs/Templates.md) 7 | - [Variables](docs/Variables.md) 8 | - [Reminder Schedules](docs/Reminder-Schedules.md) 9 | - [API-Routes](docs/API-Routes.md) 10 | -------------------------------------------------------------------------------- /src/api/admin/postmark/options/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework" 2 | 3 | export async function GET(req: MedusaRequest, res: MedusaResponse) { 4 | const postmarkModuleService = req.scope.resolve("postmarkModuleService") 5 | res.json(await postmarkModuleService.getServerId()) 6 | } 7 | -------------------------------------------------------------------------------- /src/admin/components/single-column-layout.tsx: -------------------------------------------------------------------------------- 1 | export type SingleColumnLayoutProps = { 2 | children: React.ReactNode 3 | } 4 | 5 | export const SingleColumnLayout = ({ children }: SingleColumnLayoutProps) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/types/validation.ts: -------------------------------------------------------------------------------- 1 | export type ValidationResult = { 2 | templateId: string 3 | templateName?: string 4 | missingVariables?: Record 5 | providedData?: Record | null 6 | } 7 | 8 | export type ValidationResponse = { 9 | success: boolean 10 | message: string 11 | results: ValidationResult[] 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | allow: 6 | - dependency-name: "@medusajs/*" 7 | groups: 8 | medusa: 9 | dependency-type: "development" 10 | patterns: 11 | - "@medusajs/*" 12 | schedule: 13 | interval: "weekly" 14 | commit-message: 15 | prefix: "npm" 16 | -------------------------------------------------------------------------------- /src/admin/components/general/container.tsx: -------------------------------------------------------------------------------- 1 | import { Container as UiContainer, clx } from "@medusajs/ui" 2 | 3 | type ContainerProps = React.ComponentProps 4 | 5 | export const Container = (props: ContainerProps) => { 6 | return ( 7 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/admin/components/modals/stacked-modal-provider/stacked-modal-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | 3 | type StackedModalState = { 4 | getIsOpen: (id: string) => boolean 5 | setIsOpen: (id: string, open: boolean) => void 6 | register: (id: string) => void 7 | unregister: (id: string) => void 8 | } 9 | 10 | export const StackedModalContext = createContext(null) 11 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-modal-provider/route-modal-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | 3 | type RouteModalProviderState = { 4 | handleSuccess: (path?: string) => void 5 | setCloseOnEscape: (value: boolean) => void 6 | __internal: { 7 | closeOnEscape: boolean 8 | } 9 | } 10 | 11 | export const RouteModalProviderContext = 12 | createContext(null) 13 | -------------------------------------------------------------------------------- /src/admin/i18next.d.ts: -------------------------------------------------------------------------------- 1 | import type enTranslation from "./i18n/en.json" 2 | import type { Resources } from "@medusajs/dashboard" 3 | 4 | declare module "i18next" { 5 | interface CustomTypeOptions { 6 | fallbackNS: "translation" 7 | resources: { 8 | translation: Resources["translation"] 9 | postmark: typeof enTranslation & Resources["translation"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-modal-provider/use-route-modal.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { RouteModalProviderContext } from "./route-modal-context" 3 | 4 | export const useRouteModal = () => { 5 | const context = useContext(RouteModalProviderContext) 6 | 7 | if (!context) { 8 | throw new Error("useRouteModal must be used within a RouteModalProvider") 9 | } 10 | 11 | return context 12 | } 13 | -------------------------------------------------------------------------------- /src/admin/components/modals/stacked-modal-provider/use-stacked-modal.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { StackedModalContext } from "./stacked-modal-context" 3 | 4 | export const useStackedModal = () => { 5 | const context = useContext(StackedModalContext) 6 | 7 | if (!context) { 8 | throw new Error( 9 | "useStackedModal must be used within a StackedModalProvider" 10 | ) 11 | } 12 | 13 | return context 14 | } 15 | -------------------------------------------------------------------------------- /src/admin/lib/sdk.ts: -------------------------------------------------------------------------------- 1 | import Medusa from "@medusajs/js-sdk" 2 | import { ExtendedAdmin } from "./extended-admin" 3 | 4 | const baseSdk = new Medusa({ 5 | baseUrl: __BACKEND_URL__ || "/", 6 | auth: { 7 | type: "session", 8 | }, 9 | }) 10 | const extendedAdmin = new ExtendedAdmin(baseSdk.client) 11 | 12 | export const sdk = { 13 | ...baseSdk, 14 | admin: { 15 | ...baseSdk.admin, 16 | postmark: extendedAdmin.postmark, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/abandoned-cart/models/reminder-schedules.ts: -------------------------------------------------------------------------------- 1 | import { model } from "@medusajs/framework/utils" 2 | 3 | const ReminderSchedule = model.define("reminder_schedule", { 4 | id: model.id().primaryKey(), 5 | enabled: model.boolean(), 6 | template_id: model.text().unique(), 7 | delays_iso: model.array(), 8 | notify_existing: model.boolean().default(false), 9 | reset_on_cart_update: model.boolean().default(true), 10 | }) 11 | 12 | export default ReminderSchedule 13 | -------------------------------------------------------------------------------- /src/modules/abandoned-cart/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@medusajs/framework/utils" 2 | import AbandonedCartModuleService from "./service" 3 | 4 | declare module "@medusajs/framework/types" { 5 | interface ModuleImplementations { 6 | [ABANDONED_CART_MODULE]: AbandonedCartModuleService; 7 | } 8 | } 9 | 10 | export const ABANDONED_CART_MODULE = "postmark_abandoned_cart" 11 | 12 | export default Module(ABANDONED_CART_MODULE, { 13 | service: AbandonedCartModuleService, 14 | }) 15 | -------------------------------------------------------------------------------- /src/modules/postmark/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import postmarkServerLoader from "./loaders/postmark-server"; 3 | import PostmarkModuleService from "./service" 4 | import { Module } from "@medusajs/framework/utils" 5 | 6 | declare module "@medusajs/framework/types" { 7 | interface ModuleImplementations { 8 | [POSTMARK_MODULE]: PostmarkModuleService; 9 | } 10 | } 11 | 12 | export const POSTMARK_MODULE = "postmarkModuleService" 13 | 14 | export default Module(POSTMARK_MODULE, { 15 | service: PostmarkModuleService, loaders: [postmarkServerLoader] 16 | }) 17 | -------------------------------------------------------------------------------- /src/links/reminder-schedule_template.ts: -------------------------------------------------------------------------------- 1 | import { defineLink } from "@medusajs/framework/utils" 2 | import AbandonedCartModule from "../modules/abandoned-cart" 3 | import { POSTMARK_MODULE } from "../modules/postmark" 4 | 5 | export default defineLink( 6 | { 7 | linkable: AbandonedCartModule.linkable.reminderSchedule, 8 | field: "template_id", 9 | }, 10 | { 11 | linkable: { 12 | serviceName: POSTMARK_MODULE, 13 | alias: "template", 14 | primaryKey: "template_id", 15 | }, 16 | }, 17 | { 18 | readOnly: true, 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /src/workflows/steps/default-hooks/abandoned-cart.ts: -------------------------------------------------------------------------------- 1 | import { NotificationDataWorkflowInput } from "../../notification-data" 2 | 3 | export const defaultAbandonedCartData = 4 | async (carts: NotificationDataWorkflowInput["carts"]) => 5 | carts.flatMap(({ cart, reminders }) => 6 | reminders.map(reminder => ({ 7 | to: cart.email!, 8 | channel: "feed", 9 | template: reminder.template, 10 | data: { 11 | cart 12 | } 13 | })) 14 | ) 15 | 16 | -------------------------------------------------------------------------------- /src/admin/hooks/use-postmark-options.tsx: -------------------------------------------------------------------------------- 1 | import { sdk } from "../lib/sdk" 2 | import { useQuery } from "@tanstack/react-query" 3 | 4 | type PostmarkServerOptions = { 5 | server_id: string 6 | } 7 | 8 | export const usePostmarkOptions = () => { 9 | const { data, isPending, error } = useQuery({ 10 | queryFn: () => sdk.client.fetch("/admin/postmark/options"), 11 | queryKey: ["postmark-options"], 12 | staleTime: 5 * 60 * 1000, // 5 minutes 13 | }) 14 | 15 | return { 16 | options: data, 17 | isLoading: isPending, 18 | error, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/postmark/loaders/postmark-server.ts: -------------------------------------------------------------------------------- 1 | import { LoaderOptions } from "@medusajs/framework/types"; 2 | import { MedusaError } from "@medusajs/framework/utils"; 3 | import { asValue } from "awilix"; 4 | import { ServerClient } from "postmark"; 5 | 6 | export default async function postmarkServerLoader({ 7 | container, 8 | options, 9 | }: LoaderOptions<{ apiKey?: string }>) { 10 | if (!options?.apiKey) 11 | throw new MedusaError(MedusaError.Types.NOT_FOUND, "Postmark API key not provided") 12 | 13 | const client = new ServerClient(options.apiKey) 14 | container.register("postmarkClient", asValue(client)) 15 | } 16 | -------------------------------------------------------------------------------- /src/admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["."] 24 | } 25 | -------------------------------------------------------------------------------- /integration-tests/medusa-config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv, defineConfig, Modules } from '@medusajs/framework/utils' 2 | 3 | loadEnv(process.env.NODE_ENV || 'development', process.cwd()) 4 | 5 | module.exports = defineConfig({ 6 | projectConfig: { 7 | databaseUrl: process.env.DATABASE_URL, 8 | http: { 9 | storeCors: process.env.STORE_CORS!, 10 | adminCors: process.env.ADMIN_CORS!, 11 | authCors: process.env.AUTH_CORS!, 12 | jwtSecret: process.env.JWT_SECRET || "supersecret", 13 | cookieSecret: process.env.COOKIE_SECRET || "supersecret", 14 | } 15 | }, 16 | plugins: [ 17 | { 18 | resolve: `${process.cwd()}/..`, options: { 19 | server_api: process.env.POSTMARK_API_KEY!, 20 | } 21 | }, 22 | ] 23 | }) 24 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/page.tsx: -------------------------------------------------------------------------------- 1 | import { defineRouteConfig } from "@medusajs/admin-sdk" 2 | import { Outlet } from "react-router-dom" 3 | import { SingleColumnLayout } from "../../../components/single-column-layout" 4 | import ReminderSchedulesTable from "./reminder-schedules-table" 5 | import { ValidateTemplatesSection } from './validate-templates-section' 6 | 7 | const AbandonedCartsPage = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default AbandonedCartsPage 18 | 19 | export const config = defineRouteConfig({ 20 | label: "Abandoned Carts" // "menuLabels.abandoned_carts", 21 | // translationNs: "postmark", 22 | }) 23 | -------------------------------------------------------------------------------- /src/admin/hooks/use-state-aware-to.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import { Path, useLocation } from "react-router-dom" 3 | 4 | /** 5 | * Checks if the current location has a restore_params property. 6 | * If it does, it will return a new path with the params added to it. 7 | * Otherwise, it will return the previous path. 8 | * 9 | * This is useful if the modal needs to return to the original path, with 10 | * the params that were present when the modal was opened. 11 | */ 12 | export const useStateAwareTo = (prev: string | Partial) => { 13 | const location = useLocation() 14 | 15 | const to = useMemo(() => { 16 | const params = location.state?.restore_params 17 | 18 | if (!params) { 19 | return prev 20 | } 21 | 22 | return `${prev}?${params.toString()}` 23 | }, [location.state, prev]) 24 | 25 | return to 26 | } 27 | -------------------------------------------------------------------------------- /src/docs/Variables.md: -------------------------------------------------------------------------------- 1 | # Variables 2 | 3 | Template variables are the dynamic data passed to Postmark templates when sending notifications. The plugin ensures that all required variables are generated and validated before sending. 4 | 5 | ## Generation 6 | - Variables are generated by the notification data workflow based on the cart, customer, and reminder schedule context. 7 | - Each template may require different variables, which are validated before sending. 8 | 9 | ## Validation 10 | - The plugin uses Postmark's template validation endpoint to check for missing variables. 11 | - If required variables are missing, the notification is not sent and an error is logged. 12 | 13 | ## Usage 14 | - Variables are passed as the `TemplateModel` when sending templated emails through Postmark. 15 | - Ensure your templates reference only variables that are provided by the workflow. -------------------------------------------------------------------------------- /src/admin/hooks/use-debounced-search.tsx: -------------------------------------------------------------------------------- 1 | import debounce from "lodash/debounce" 2 | import { useCallback, useEffect, useState } from "react" 3 | 4 | /** 5 | * Hook for debouncing search input 6 | * @returns searchValue, onSearchValueChange, query 7 | */ 8 | export const useDebouncedSearch = () => { 9 | const [searchValue, onSearchValueChange] = useState("") 10 | const [debouncedQuery, setDebouncedQuery] = useState("") 11 | 12 | // eslint-disable-next-line react-hooks/exhaustive-deps 13 | const debouncedUpdate = useCallback( 14 | debounce((query: string) => setDebouncedQuery(query), 300), 15 | [] 16 | ) 17 | 18 | useEffect(() => { 19 | debouncedUpdate(searchValue) 20 | 21 | return () => debouncedUpdate.cancel() 22 | }, [searchValue, debouncedUpdate]) 23 | 24 | return { 25 | searchValue, 26 | onSearchValueChange, 27 | query: debouncedQuery || undefined, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/docs/Templates.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | Templates are managed through the Postmark integration and are used to render the content of abandoned cart and other notification emails. The plugin provides CRUD operations for templates and supports validation to ensure all required variables are provided. 4 | 5 | ## Features 6 | - **List Templates**: Fetch all templates from Postmark. 7 | - **Create Template**: Add a new template to Postmark. 8 | - **Update Template**: Modify an existing template. 9 | - **Delete Template**: Remove a template from Postmark. 10 | - **Layout Templates**: Support for layout templates in Postmark. 11 | - **Validation**: Check that all required variables are present in the template model before sending. 12 | 13 | ## Template Association 14 | Each reminder schedule is linked to a specific template by `template_id`. Templates can be standard or layout types, and are validated before use in workflows. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2024", 4 | "esModuleInterop": true, 5 | "module": "Node16", 6 | "moduleResolution": "Node16", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "skipLibCheck": true, 10 | "skipDefaultLibCheck": true, 11 | "declaration": true, 12 | "sourceMap": false, 13 | "inlineSourceMap": true, 14 | "outDir": "./.medusa/server", 15 | "rootDir": "./", 16 | "jsx": "react-jsx", 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "checkJs": false, 20 | "strictNullChecks": true 21 | }, 22 | "ts-node": { 23 | "swc": true 24 | }, 25 | "include": [ 26 | "**/*", 27 | ".medusa/types/*" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | ".medusa/server", 32 | ".medusa/admin", 33 | "integration-tests", 34 | "src/admin", 35 | ".cache" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/@create/page.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@medusajs/ui" 2 | import { RouteDrawer } from "../../../../components/modals/route-drawer" 3 | import { CreateReminderScheduleForm } from "./create-form" 4 | import { useTranslation } from "react-i18next" 5 | 6 | const CreateAbandonedCartPage = () => { 7 | const { t } = useTranslation("postmark") 8 | return ( 9 | 10 | 11 | 12 | {t("reminder_schedules.create_title")} 13 | 14 | 15 | {t("reminder_schedules.create_description")} 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | export default CreateAbandonedCartPage 23 | -------------------------------------------------------------------------------- /src/api/admin/postmark/templates/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework" 2 | 3 | export async function DELETE(req: MedusaRequest, res: MedusaResponse) { 4 | const { id } = req.params 5 | 6 | if (!id) { 7 | return res.status(400).json({ 8 | error: "Template ID is required" 9 | }) 10 | } 11 | 12 | // Validate template ID is a number 13 | const templateId = parseInt(id as string) 14 | if (isNaN(templateId)) { 15 | return res.status(400).json({ 16 | error: "Invalid template ID", 17 | message: "Template ID must be a valid number" 18 | }) 19 | } 20 | 21 | const postmarkModuleService = req.scope.resolve("postmarkModuleService") 22 | const cache = req.scope.resolve("caching") 23 | 24 | await postmarkModuleService.deleteTemplate(templateId) 25 | await cache?.clear({ tags: ["PostmarkTemplate:list:*"] }) 26 | 27 | res.json({ success: true, message: "Template deleted successfully" }) 28 | } 29 | -------------------------------------------------------------------------------- /src/admin/components/data-table-action.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@medusajs/ui" 2 | import { Link } from "react-router-dom" 3 | 4 | type DataTableActionProps = { 5 | label: string 6 | disabled?: boolean 7 | } & ( 8 | | { 9 | to: string 10 | } 11 | | { 12 | onClick: () => void 13 | } 14 | ) 15 | 16 | export const DataTableAction = ({ 17 | label, 18 | disabled, 19 | ...props 20 | }: DataTableActionProps) => { 21 | const buttonProps = { 22 | size: "small" as const, 23 | disabled: disabled ?? false, 24 | type: "button" as const, 25 | variant: "secondary" as const, 26 | } 27 | 28 | if ("to" in props) { 29 | return ( 30 | 33 | ) 34 | } 35 | 36 | return ( 37 | 40 | ) 41 | } 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/docs/Events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | The plugin uses event-driven workflows to automate abandoned cart notifications and template validation. Events are triggered by cart updates, schedule changes, and workflow executions. 4 | 5 | ## Main Events 6 | - **Cart Abandoned**: Triggers the abandoned cart workflow to check for eligible reminders. 7 | - **Reminder Schedule Updated**: Re-evaluates which carts are eligible for notifications. 8 | - **Template Validated**: Ensures all required variables are present before sending. 9 | 10 | # Workflows 11 | 12 | ## Abandoned Cart Workflow 13 | - Fetches eligible carts based on reminder schedules. 14 | - Determines which reminders should be sent and when. 15 | - Triggers notification sending using the associated Postmark template. 16 | 17 | ## Notification Data Workflow 18 | - Prepares the data required for each notification, including template variables. 19 | - Validates that all required variables are present for the selected template. 20 | 21 | These workflows ensure that notifications are sent reliably and only when all conditions are met. -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { loadEnv } = require("@medusajs/utils"); 2 | loadEnv("test", process.cwd()); 3 | 4 | module.exports = { 5 | transform: { 6 | "^.+\\.[jt]s$": [ 7 | "@swc/jest", 8 | { 9 | jsc: { 10 | parser: { syntax: "typescript", decorators: true }, 11 | target: "es2021", 12 | }, 13 | }, 14 | ], 15 | }, 16 | testEnvironment: "node", 17 | moduleFileExtensions: ["js", "ts", "json"], 18 | modulePathIgnorePatterns: ["dist/", "/.medusa/"], 19 | setupFiles: ["./integration-tests/setup.js"], 20 | }; 21 | 22 | if (process.env.TEST_TYPE === "integration:http") { 23 | module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"]; 24 | } else if (process.env.TEST_TYPE === "integration:workflows") { 25 | module.exports.testMatch = ["**/integration-tests/workflows/*.spec.[jt]s"]; 26 | } else if (process.env.TEST_TYPE === "integration:modules") { 27 | module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"]; 28 | } else if (process.env.TEST_TYPE === "unit") { 29 | module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"]; 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bram Hammer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/admin/components/modals/utilities/keybound-form/keybound-form.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | /** 4 | * A form that can only be submitted when using the meta or control key. 5 | */ 6 | export const KeyboundForm = React.forwardRef< 7 | HTMLFormElement, 8 | React.FormHTMLAttributes 9 | >(({ onSubmit, onKeyDown, ...rest }, ref) => { 10 | const handleSubmit = (event: React.FormEvent) => { 11 | event.preventDefault() 12 | onSubmit?.(event) 13 | } 14 | 15 | const handleKeyDown = (event: React.KeyboardEvent) => { 16 | if (event.key === "Enter") { 17 | if ( 18 | event.target instanceof HTMLTextAreaElement && 19 | !(event.metaKey || event.ctrlKey) 20 | ) { 21 | return 22 | } 23 | 24 | event.preventDefault() 25 | 26 | if (event.metaKey || event.ctrlKey) { 27 | handleSubmit(event) 28 | } 29 | } 30 | } 31 | 32 | return ( 33 |
39 | ) 40 | }) 41 | 42 | KeyboundForm.displayName = "KeyboundForm" 43 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-modal-provider/route-provider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useCallback, useMemo, useState } from "react" 2 | import { Path, useNavigate } from "react-router-dom" 3 | import { RouteModalProviderContext } from "./route-modal-context" 4 | 5 | type RouteModalProviderProps = PropsWithChildren<{ 6 | prev: string | Partial 7 | }> 8 | 9 | export const RouteModalProvider = ({ 10 | prev, 11 | children, 12 | }: RouteModalProviderProps) => { 13 | const navigate = useNavigate() 14 | 15 | const [closeOnEscape, setCloseOnEscape] = useState(true) 16 | 17 | const handleSuccess = useCallback( 18 | (path?: string) => { 19 | const to = path || prev 20 | navigate(to, { replace: true, state: { isSubmitSuccessful: true } }) 21 | }, 22 | [navigate, prev] 23 | ) 24 | 25 | const value = useMemo( 26 | () => ({ 27 | handleSuccess, 28 | setCloseOnEscape, 29 | __internal: { closeOnEscape }, 30 | }), 31 | [handleSuccess, setCloseOnEscape, closeOnEscape] 32 | ) 33 | 34 | return ( 35 | 36 | {children} 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/@edit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from "@medusajs/ui" 2 | import { RouteDrawer } from "../../../../../components/modals/route-drawer" 3 | import { EditReminderScheduleForm } from "./edit-form" 4 | import { useReminderSchedule } from "../../../../../hooks/use-reminder-schedules" 5 | import { useParams } from "react-router-dom" 6 | import { useTranslation } from "react-i18next" 7 | 8 | const EditAbandonedCartPage = () => { 9 | const { t } = useTranslation("postmark") 10 | const { id = "" } = useParams() 11 | const { schedule, isPending } = useReminderSchedule(id) 12 | return ( 13 | 14 | 15 | 16 | {t("reminder_schedules.edit_title")} 17 | 18 | 19 | {t("reminder_schedules.edit_description")} 20 | 21 | 22 | {schedule && !isPending && } 23 | 24 | ) 25 | } 26 | export default EditAbandonedCartPage 27 | 28 | -------------------------------------------------------------------------------- /src/modules/abandoned-cart/migrations/Migration20251026135601.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '@mikro-orm/migrations'; 2 | 3 | export class Migration20251026135601 extends Migration { 4 | 5 | override async up(): Promise { 6 | this.addSql(`alter table if exists "reminder_schedule" drop constraint if exists "reminder_schedule_template_id_unique";`); 7 | this.addSql(`create table if not exists "reminder_schedule" ("id" text not null, "enabled" boolean not null, "template_id" text not null, "delays_iso" text[] not null, "notify_existing" boolean not null default false, "reset_on_cart_update" boolean not null default true, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "reminder_schedule_pkey" primary key ("id"));`); 8 | this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_reminder_schedule_template_id_unique" ON "reminder_schedule" (template_id) WHERE deleted_at IS NULL;`); 9 | this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_reminder_schedule_deleted_at" ON "reminder_schedule" (deleted_at) WHERE deleted_at IS NULL;`); 10 | } 11 | 12 | override async down(): Promise { 13 | this.addSql(`drop table if exists "reminder_schedule" cascade;`); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/admin/components/general/section-row.tsx: -------------------------------------------------------------------------------- 1 | import { Text, clx } from "@medusajs/ui" 2 | 3 | export type SectionRowProps = { 4 | title: string 5 | value?: React.ReactNode | string | null 6 | actions?: React.ReactNode 7 | } 8 | 9 | export const SectionRow = ({ title, value, actions }: SectionRowProps) => { 10 | const isValueString = typeof value === "string" || !value 11 | 12 | return ( 13 |
21 | 22 | {title} 23 | 24 | {isValueString ? ( 25 | 30 | {value ?? "-"} 31 | 32 | ) : ( 33 |
{value}
34 | )} 35 | {actions &&
{actions}
} 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/admin/components/modals/stacked-modal-provider/stacked-modal-provider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from "react" 2 | import { StackedModalContext } from "./stacked-modal-context" 3 | 4 | type StackedModalProviderProps = PropsWithChildren<{ 5 | onOpenChange: (open: boolean) => void 6 | }> 7 | 8 | export const StackedModalProvider = ({ 9 | children, 10 | onOpenChange, 11 | }: StackedModalProviderProps) => { 12 | const [state, setState] = useState>({}) 13 | 14 | const getIsOpen = (id: string) => { 15 | return state[id] || false 16 | } 17 | 18 | const setIsOpen = (id: string, open: boolean) => { 19 | setState((prevState) => ({ 20 | ...prevState, 21 | [id]: open, 22 | })) 23 | 24 | onOpenChange(open) 25 | } 26 | 27 | const register = (id: string) => { 28 | setState((prevState) => ({ 29 | ...prevState, 30 | [id]: false, 31 | })) 32 | } 33 | 34 | const unregister = (id: string) => { 35 | setState((prevState) => { 36 | const newState = { ...prevState } 37 | delete newState[id] 38 | return newState 39 | }) 40 | } 41 | 42 | return ( 43 | 51 | {children} 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/api/admin/postmark/abandoned-carts/reminders/schedules/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" 2 | import { ContainerRegistrationKeys } from "@medusajs/framework/utils" 3 | import { ABANDONED_CART_MODULE } from "../../../../../../modules/abandoned-cart" 4 | import { 5 | CreateReminderSchedule 6 | } from "../../../../../../types/reminder-schedules" 7 | 8 | export async function GET(req: MedusaRequest, res: MedusaResponse) { 9 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) 10 | 11 | const { data: schedules } = await query.graph( 12 | { 13 | entity: "reminder_schedule", 14 | ...req.queryConfig, 15 | }, 16 | { 17 | cache: { enable: true }, 18 | } 19 | ) 20 | 21 | res.json({ schedules }) 22 | } 23 | 24 | export async function POST(req: MedusaRequest, res: MedusaResponse) { 25 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE) 26 | const schedule = await abandonedCartModuleService.createReminderSchedules(req.validatedBody) 27 | 28 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) 29 | const { data: schedules } = await query.graph({ 30 | entity: "reminder_schedule", 31 | fields: [ 32 | "*", 33 | "template.*", 34 | ], 35 | filters: { 36 | id: schedule.id, 37 | }, 38 | }) 39 | 40 | res.json({ schedules }) 41 | } 42 | -------------------------------------------------------------------------------- /src/docs/API-Routes.md: -------------------------------------------------------------------------------- 1 | # API Routes 2 | 3 | The plugin exposes several API endpoints for managing reminder schedules, templates, layouts, and sending emails through Postmark. All routes are proxied through the Medusa server for security and consistency. 4 | 5 | ## Reminder Schedules 6 | - `GET /admin/postmark/abandoned-carts/reminders/schedules` — List all reminder schedules 7 | - `GET /admin/postmark/abandoned-carts/reminders/schedules/:id` — Get a reminder schedule by id 8 | - `POST /admin/postmark/abandoned-carts/reminders/schedules` — Create a new reminder schedule 9 | - `POST /admin/postmark/abandoned-carts/reminders/schedules/:id` — Update a reminder schedule 10 | - `DELETE /admin/postmark/abandoned-carts/reminders/schedules/:id` — Delete a reminder schedule 11 | 12 | ## Templates 13 | - `GET /admin/postmark/templates` — List all templates 14 | - `GET /admin/postmark/templates/:id` — Get a template by ID 15 | - `POST /admin/postmark/templates` — Create a new template 16 | - `POST /admin/postmark/templates/:id` — Update a template 17 | - `DELETE /admin/postmark/templates/:id` — Delete a template 18 | 19 | ## Options 20 | - `GET /admin/postmark/options` — Returns configuration options about the postmark server, currently just returns the server's ID. 21 | 22 | ## Validation 23 | - `POST /admin/postmark/abandoned-carts/reminders/validate` — Validate reminder schedules against template data,to make sure that no template misses any data. 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '22 9 * * 5' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | runs-on: 'ubuntu-latest' 26 | timeout-minutes: 360 27 | permissions: 28 | security-events: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | language: [ 'javascript-typescript' ] 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v4 38 | 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@v3 41 | with: 42 | languages: ${{ matrix.language }} 43 | 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v3 46 | 47 | - name: Perform CodeQL Analysis 48 | uses: github/codeql-action/analyze@v3 49 | with: 50 | category: "/language:${{matrix.language}}" 51 | -------------------------------------------------------------------------------- /.github/workflows/mdbook.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a mdBook site to GitHub Pages 2 | # 3 | # To get started with mdBook see: https://rust-lang.github.io/mdBook/index.html 4 | # 5 | name: Deploy mdBook site to Pages 6 | 7 | on: 8 | push: 9 | branches: ["main"] 10 | 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: false 21 | 22 | jobs: 23 | # Build job 24 | build: 25 | runs-on: ubuntu-latest 26 | env: 27 | MDBOOK_VERSION: 0.4.37 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Install mdBook 31 | run: | 32 | curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -y | sh 33 | rustup update 34 | cargo install --version ${MDBOOK_VERSION} mdbook 35 | - name: Setup Pages 36 | id: pages 37 | uses: actions/configure-pages@v4 38 | - name: Build with mdBook 39 | run: mdbook build 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: ./book 44 | 45 | # Deployment job 46 | deploy: 47 | environment: 48 | name: github-pages 49 | url: ${{ steps.deployment.outputs.page_url }} 50 | runs-on: ubuntu-latest 51 | needs: build 52 | steps: 53 | - name: Deploy to GitHub Pages 54 | id: deployment 55 | uses: actions/deploy-pages@v4 56 | -------------------------------------------------------------------------------- /src/docs/Reminder-Schedules.md: -------------------------------------------------------------------------------- 1 | # Reminder Schedules 2 | 3 | Reminder schedules define when and how abandoned cart reminder emails are sent to customers. Each schedule specifies a set of delays (in ISO 8601 duration format), the Postmark template to use, and additional flags to control notification behavior. 4 | 5 | ## Fields 6 | - **id**: Unique identifier for the schedule. 7 | - **enabled**: Whether the schedule is active. 8 | - **template_id**: The Postmark template associated with this schedule. 9 | - **delays_iso**: Array of ISO 8601 durations (e.g., `['PT1H', 'P1D']`) specifying when reminders are sent after cart abandonment. 10 | - **notify_existing**: If true, carts created before a schedule update are eligible for notifications. 11 | - **reset_on_cart_update**: If true, the notification cycle restarts if the cart is updated after abandonment. 12 | - **created_at / updated_at / deleted_at**: Timestamps for schedule lifecycle management. 13 | 14 | ## Behavior 15 | - Each schedule can be enabled or disabled. 16 | - Multiple schedules can exist, each with its own template and delays. 17 | - The system ensures that reminders are not sent more than once for the same delay and cart, unless `reset_on_cart_update` is enabled and the cart is updated. 18 | - Schedules are linked to templates, and referential integrity is enforced. 19 | 20 | ## Use Case 21 | Reminder schedules are used by the abandoned cart workflow to determine which customers should receive reminder emails and when, ensuring a flexible and robust notification strategy. -------------------------------------------------------------------------------- /src/docs/Introduction.md: -------------------------------------------------------------------------------- 1 | # medusa-plugin-postmark 2 | 3 | [![stars - medusa-plugin-postmark](https://img.shields.io/github/stars/Fullstak-nl/medusa-plugin-postmark?style=social)](https://github.com/Fullstak-nl/medusa-plugin-postmark) 4 | [![forks - medusa-plugin-postmark](https://img.shields.io/github/forks/Fullstak-nl/medusa-plugin-postmark?style=social)](https://github.com/Fullstak-nl/medusa-plugin-postmark) 5 | 6 | [![GitHub tag](https://img.shields.io/github/tag/Fullstak-nl/medusa-plugin-postmark?include_prereleases=&sort=semver&color=blue)](https://github.com/Fullstak-nl/medusa-plugin-postmark/releases/) 7 | [![License](https://img.shields.io/badge/License-MIT-blue)](#license) 8 | [![issues - medusa-plugin-postmark](https://img.shields.io/github/issues/Fullstak-nl/medusa-plugin-postmark)](https://github.com/Fullstak-nl/medusa-plugin-postmark/issues) 9 | 10 | Notifications plugin for Medusa ecommerce server that sends transactional emails via [PostMark](https://postmarkapp.com/). 11 | 12 | ## Features 13 | 14 | - Uses the email templating features built into Postmark 15 | - You can import/use tools like [stripo.email](https://stripo.email) 16 | - The plugin is in active development. If you have any feature requests, please open an issue. 17 | - Create PDF invoices and credit notes and attach them to the email 18 | - Send out upsell emails to customers that have recently placed an order with certain collections 19 | - Send out automated abandoned cart emails to customers that have abandoned their cart (based on last updated date of cart) 20 | -------------------------------------------------------------------------------- /src/types/abandoned-cart-tracking.ts: -------------------------------------------------------------------------------- 1 | import { CartDTO } from "@medusajs/framework/types" 2 | 3 | /** 4 | * Record of a sent notification for a specific delay in a schedule 5 | */ 6 | export interface SentNotification { 7 | sent_at: string // ISO timestamp when notification was sent 8 | cart_reference_at_send: string // ISO timestamp of cart's reference time when sent 9 | } 10 | 11 | /** 12 | * Tracking data stored in cart metadata 13 | */ 14 | export interface AbandonedCartTracking { 15 | sent_notifications: { 16 | [key: string]: SentNotification // Key format: "schedule_id:delay_iso" 17 | } 18 | } 19 | 20 | /** 21 | * Compute the reference timestamp for a cart 22 | * This is the most recent of: cart.created_at or any item.updated_at 23 | */ 24 | export function computeCartReferenceTimestamp(cart: CartDTO): number { 25 | const timestamps = [ 26 | new Date(cart.created_at!).getTime(), 27 | ...(cart.items?.map(item => new Date(item!.updated_at!).getTime()) || []) 28 | ] 29 | return Math.max(...timestamps) 30 | } 31 | 32 | /** 33 | * Get sent notification record for a specific schedule and delay 34 | */ 35 | export function getSentNotification( 36 | cart: CartDTO, 37 | scheduleId: string, 38 | delayIso: string 39 | ): SentNotification | null { 40 | const tracking = cart.metadata?.abandoned_cart_tracking as AbandonedCartTracking | null 41 | if (!tracking) return null 42 | 43 | const key = `${scheduleId}:${delayIso}` 44 | return tracking.sent_notifications?.[key] || null 45 | } 46 | -------------------------------------------------------------------------------- /src/admin/hooks/use-postmark-templates.tsx: -------------------------------------------------------------------------------- 1 | import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query" 2 | import { sdk } from "../lib/sdk" 3 | import { PostmarkTemplate } from "../../types/templates" 4 | import { HttpTypes } from "@medusajs/framework/types" 5 | import { FetchError } from "@medusajs/js-sdk" 6 | 7 | type TemplatesResponse = { 8 | TotalCount: number 9 | Templates: PostmarkTemplate[] 10 | } 11 | 12 | type QueryInput = HttpTypes.FindParams & { 13 | id?: string 14 | q?: string 15 | templateType?: "Standard" | "Layout" 16 | } 17 | 18 | const POSTMARK_TEMPLATES_QUERY_KEY = "postmark_templates" as const 19 | 20 | const postmarkTemplatesQueryKeys = { 21 | all: [POSTMARK_TEMPLATES_QUERY_KEY] as const, 22 | templates: (query?: Record) => [...postmarkTemplatesQueryKeys.all, "templates", query ? { query } : undefined].filter( 23 | (k) => !!k 24 | ), 25 | template: (id: string, query?: Record) => [...postmarkTemplatesQueryKeys.templates(), id, query ? { query } : undefined].filter( 26 | (k) => !!k 27 | ) 28 | } 29 | 30 | export const usePostmarkTemplates = ( 31 | query?: QueryInput, 32 | options?: Omit< 33 | UseQueryOptions< 34 | TemplatesResponse, 35 | FetchError, 36 | TemplatesResponse, 37 | QueryKey 38 | >, 39 | "queryFn" | "queryKey" 40 | >) => { 41 | const { data, ...rest } = useQuery({ 42 | queryKey: postmarkTemplatesQueryKeys.templates(query), 43 | queryFn: () => sdk.admin.postmark.templates.list(query), 44 | ...options 45 | }) 46 | return { ...data, ...rest } 47 | } 48 | -------------------------------------------------------------------------------- /src/api/admin/postmark/templates/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework" 2 | import { Models } from "postmark" 3 | 4 | export async function GET(req: MedusaRequest, res: MedusaResponse) { 5 | // TODO: query validation 6 | const { limit: count, offset, q, order, templateType = Models.TemplateTypes.Standard } = req.query as { q: string, order: string, limit: string, offset: string, templateType: Models.TemplateTypes } 7 | 8 | const postmarkModuleService = req.scope.resolve("postmarkModuleService") 9 | const cache = req.scope.resolve("caching") 10 | 11 | const queryParams = { 12 | count: parseInt(count), 13 | offset: parseInt(offset), 14 | templateType 15 | } 16 | 17 | // Dont use cache if caching module is not enabled 18 | const cacheKey = await cache?.computeKey(req.query) 19 | let templates: Models.Templates = cacheKey ? await cache.get({ key: cacheKey }) : await postmarkModuleService.getTemplates(queryParams) 20 | if (!templates) { 21 | templates = await postmarkModuleService.getTemplates(queryParams) 22 | await cache.set({ key: cacheKey, data: templates, tags: ["PostmarkTemplate:list:*"] }) 23 | } 24 | if (q) 25 | templates.Templates = templates.Templates.filter( 26 | (template) => template.Name.toLowerCase().includes(q.toLowerCase()) 27 | ) 28 | if (order) 29 | templates.Templates.sort((a, b) => { 30 | const ascending = !order?.startsWith("-") 31 | const field = order?.replace("-", "") 32 | if (ascending) { 33 | return a[field]?.localeCompare(b[field]) 34 | } else { 35 | return b[field]?.localeCompare(a[field]) 36 | } 37 | }) 38 | 39 | res.json(templates) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/api/admin/postmark/abandoned-carts/reminders/schedules/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" 2 | import { ContainerRegistrationKeys } from "@medusajs/framework/utils" 3 | import { ABANDONED_CART_MODULE } from "../../../../../../../modules/abandoned-cart" 4 | import { 5 | UpdateReminderSchedule 6 | } from "../../../../../../../types/reminder-schedules" 7 | 8 | export async function GET(req: MedusaRequest, res: MedusaResponse) { 9 | const { id } = req.params 10 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) 11 | 12 | const { data: [schedule] } = await query.graph( 13 | { 14 | entity: "reminder_schedule", 15 | ...req.queryConfig, 16 | filters: { 17 | id, 18 | }, 19 | }, 20 | { 21 | cache: { enable: true }, 22 | } 23 | ) 24 | 25 | res.json({ schedule }) 26 | } 27 | 28 | export async function POST(req: MedusaRequest, res: MedusaResponse) { 29 | const { id } = req.params 30 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE) 31 | const schedule = await abandonedCartModuleService.updateReminderSchedules({ id, ...req.validatedBody }) 32 | 33 | const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) 34 | const { data: schedules } = await query.graph({ 35 | entity: "reminder_schedule", 36 | fields: [ 37 | "*", 38 | "template.*", 39 | ], 40 | filters: { 41 | id: schedule.id, 42 | }, 43 | }) 44 | 45 | res.json({ schedules }) 46 | } 47 | 48 | export async function DELETE(req: MedusaRequest, res: MedusaResponse) { 49 | const { id } = req.params 50 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE) 51 | await abandonedCartModuleService.deleteReminderSchedules(id) 52 | res.json({ success: true }) 53 | } 54 | -------------------------------------------------------------------------------- /src/docs/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The plugin requires several configuration options and environment variables to function correctly. 4 | 5 | ## Environment Variables 6 | - `POSTMARK_API_KEY`: Your Postmark server token 7 | - `POSTMARK_FROM`: Default sender email address 8 | - `POSTMARK_BCC`: Default bcc email address 9 | 10 | 11 | ## Setup 12 | 1. Install the plugin and its dependencies. 13 | 2. Add the plugin to your Medusa configuration. 14 | 3. Set the required environment variables. 15 | 4. Configure reminder schedules and templates as needed. 16 | 17 | ## Example: Plugin Registration 18 | 19 | Add the plugin to your `medusa-config.ts`: 20 | 21 | ```ts 22 | defineConfig({ 23 | // ... 24 | plugins: [ 25 | // ... 26 | { 27 | resolve: "medusa-plugin-postmark", 28 | options: { 29 | apiKey: process.env.POSTMARK_API_KEY!, 30 | } 31 | }, 32 | ] 33 | }) 34 | ``` 35 | 36 | ## Example: Adding the Postmark Notification Provider 37 | 38 | To use Postmark as a notification provider, add it to the `modules` property in your Medusa config: 39 | 40 | ```ts 41 | defineConfig({ 42 | // ... 43 | modules: [ 44 | { 45 | resolve: "@medusajs/medusa/notification", 46 | options: { 47 | providers: [ 48 | { 49 | resolve: "medusa-plugin-postmark/providers/postmark", 50 | id: "postmark", 51 | options: { 52 | channels: ["email"], 53 | apiKey: process.env.POSTMARK_API_KEY!, 54 | default: { 55 | from: process.env.POSTMARK_FROM, 56 | bcc: process.env.POSTMARK_BCC, 57 | } 58 | }, 59 | }, 60 | ], 61 | }, 62 | }, 63 | ], 64 | }) 65 | ``` 66 | 67 | Only one provider can be set per channel (e.g., "email"). 68 | 69 | ## Options 70 | - **Reminder Schedules**: Configure delays, template associations, and flags. 71 | - **Templates**: Manage templates and layouts through the admin interface or API. 72 | 73 | Refer to the other documentation sections for details on each configuration area. 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [4.5.1] - 2024-01-08 9 | 10 | - General: sigh... didn't add build files to the last release 11 | 12 | ## [4.5.0] - 2024-01-08 13 | 14 | - Bugfix: GiftcardService had a typo that caused an error upon startup 15 | 16 | ## [4.4.0] - 2023-11-12 17 | 18 | - Feature: Added giftcard service to the mailer 19 | 20 | ## [4.3.0] - 2023-07-14 21 | 22 | - Feature: Added country filter to convert ISO country code to country name 23 | 24 | ## [4.2.2] - 2023-07-14 25 | 26 | - Bugfix: Fix that nested variables didn't work in the if statement 27 | 28 | ## [4.2.1] - 2023-07-03 29 | 30 | - Bugfix: Renamed the attachments since IOS didn't recognize it as a pdf in some cases 31 | 32 | ## [4.2.0] - 2023-07-03 33 | 34 | - Feature: Added if statements to the pdf generator 35 | - Feature: Added config option to alter what to show when a variable can't be found or is invalid 36 | 37 | ## [4.1.0] - 2023-06-16 38 | 39 | - Bugfix: Added a check if the cart has been converted to an order. No need to send abandoned cart mails if the cart has been converted to an order. 40 | 41 | ## [4.0.2] - 2023-06-09 42 | 43 | - Bugfix: Extra check on created at since typeorm lessthen didn't filter correctly somehow 44 | 45 | ## [4.0.1] - 2023-06-09 46 | 47 | - Bugfix: First mail could be send multiple times in some occasions 48 | - Bugfix: Small piece of testcode was left in the codebase 49 | 50 | ## [4.0.0] - 2023-06-09 51 | 52 | - Feature: added automated abandoned cart mail options 53 | 54 | ## [3.1.1] - 2023-06-04 55 | 56 | - Fix: added `shipping_total_inc` to `order` object in `order.placed` event since MedusaJS can return shippign excl. tax instead of inc tax. 57 | 58 | ## [3.1.0] - 2023-06-03 59 | 60 | - First use of this changelog. Previous changes are not documented. 61 | -------------------------------------------------------------------------------- /src/workflows/notification-data.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createWorkflow, 3 | WorkflowResponse, 4 | transform, 5 | createHook, 6 | } from "@medusajs/framework/workflows-sdk" 7 | import zod from "zod" 8 | import { defaultAbandonedCartData } from "./steps/default-hooks/abandoned-cart" 9 | import { CartDTO, CreateNotificationDTO, CustomerDTO } from "@medusajs/framework/types" 10 | import { ReminderSchedule } from "../types/reminder-schedules" 11 | import { Temporal } from "temporal-polyfill" 12 | 13 | export type NotificationDataWorkflowInput = { 14 | carts: Array<{ cart: CartDTO & { customer: CustomerDTO }, reminders: Array<{ delay: Temporal.Duration, delayIso: string, template: string, schedule: ReminderSchedule }> }> 15 | } 16 | 17 | export const notificationDataWorkflow = createWorkflow( 18 | "notification-data", 19 | function (input: NotificationDataWorkflowInput) { 20 | const notificationDataHook = createHook( 21 | "abandonedCartNotificationData", 22 | input, 23 | { 24 | resultValidator: zod.array(zod.object({ 25 | to: zod.string(), 26 | channel: zod.string(), 27 | template: zod.string(), 28 | data: zod.record(zod.any()).default({}), 29 | attachments: zod.array(zod.any()).optional(), 30 | })) 31 | } 32 | ) 33 | const notificationDataHookResult = notificationDataHook.getResult() 34 | 35 | const notificationData: CreateNotificationDTO[] = transform( 36 | { notificationDataHookResult, carts: input.carts }, 37 | async ({ notificationDataHookResult, carts }) => { 38 | if (notificationDataHookResult) 39 | return notificationDataHookResult 40 | 41 | return await defaultAbandonedCartData(carts) 42 | } 43 | ) 44 | 45 | return new WorkflowResponse({ notificationData }, { hooks: [notificationDataHook] }) 46 | } 47 | ) 48 | -------------------------------------------------------------------------------- /src/jobs/abandoned-carts.ts: -------------------------------------------------------------------------------- 1 | import { MedusaContainer } from "@medusajs/framework/types" 2 | import { sendAbandonedCartsWorkflow } from "../workflows/abandoned-carts" 3 | import { defineFileConfig } from "@medusajs/framework/utils" 4 | import { ABANDONED_CART_MODULE } from "../modules/abandoned-cart" 5 | 6 | export default async function abandonedCartJob( 7 | container: MedusaContainer 8 | ) { 9 | const logger = container.resolve("logger") 10 | 11 | const limit = 100 12 | let offset = 0 13 | let totalCount = 0 14 | let abandonedCartsCount = 0 15 | 16 | const abandonedCartService = container.resolve(ABANDONED_CART_MODULE) 17 | const reminderSchedules = await abandonedCartService.listReminderSchedules({ 18 | enabled: true 19 | }) 20 | 21 | if (!reminderSchedules?.length) { 22 | logger.debug("medusa-plugin-postmark: No enabled reminder schedules found") 23 | return 24 | } 25 | 26 | do { 27 | try { 28 | const { result: { updatedCarts, pagination } } = await sendAbandonedCartsWorkflow(container).run({ 29 | input: { 30 | reminderSchedules, 31 | pagination: { limit, offset }, 32 | }, 33 | }) 34 | abandonedCartsCount += updatedCarts.length 35 | totalCount = pagination.totalCount ?? 0 36 | } catch (error) { 37 | logger.error( 38 | `medusa-plugin-postmark: Failed to send abandoned cart notification: ${error.message}` 39 | ) 40 | } 41 | 42 | offset += limit 43 | } while (offset < totalCount) 44 | 45 | logger.debug(`medusa-plugin-postmark: Sent ${abandonedCartsCount} abandoned cart notifications`) 46 | } 47 | 48 | export const config = { 49 | name: "abandoned-cart-notification", 50 | schedule: process.env.PLUGIN_POSTMARK_ABANDONED_CART_SCHEDULE || "0 * * * *", // Run every hour by default 51 | } 52 | 53 | defineFileConfig({ 54 | isDisabled: () => process.env.PLUGIN_POSTMARK_ABANDONED_CART_ENABLE === "false", 55 | }) 56 | -------------------------------------------------------------------------------- /src/api/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { defineMiddlewares, validateAndTransformBody, validateAndTransformQuery } from "@medusajs/framework/http" 2 | import { createFindParams } from "@medusajs/medusa/api/utils/validators" 3 | import { 4 | CreateReminderScheduleSchema, 5 | UpdateReminderScheduleSchema 6 | } from "../types/reminder-schedules" 7 | 8 | export default defineMiddlewares({ 9 | routes: [ 10 | { 11 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules", 12 | methods: ["POST"], 13 | middlewares: [ 14 | validateAndTransformBody(CreateReminderScheduleSchema), 15 | ], 16 | }, 17 | { 18 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules", 19 | methods: ["GET"], 20 | middlewares: [ 21 | validateAndTransformQuery(createFindParams(), { 22 | defaults: [ 23 | "id", 24 | "template_id", 25 | "delays_iso", 26 | "enabled", 27 | "notify_existing", 28 | "reset_on_cart_update" 29 | ], 30 | isList: true, 31 | }), 32 | ], 33 | }, 34 | { 35 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules/:id", 36 | methods: ["POST"], 37 | middlewares: [ 38 | validateAndTransformBody(UpdateReminderScheduleSchema), 39 | ], 40 | }, 41 | { 42 | matcher: "/admin/postmark/abandoned-carts/reminders/schedules/:id", 43 | methods: ["GET"], 44 | middlewares: [ 45 | validateAndTransformQuery(createFindParams(), { 46 | defaults: [ 47 | "id", 48 | "template_id", 49 | "delays_iso", 50 | "enabled", 51 | "notify_existing", 52 | "reset_on_cart_update" 53 | ], 54 | isList: false, 55 | }), 56 | ], 57 | }, 58 | ], 59 | }) 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ project files 2 | .idea 3 | *.iml 4 | out 5 | gen 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Package manager / lock files 17 | package-lock.json 18 | 19 | # Diagnostic reports (https://nodejs.org/api/report.html) 20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | *.lcov 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | bower_components 43 | 44 | # node-waf configuration 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directories 51 | node_modules/ 52 | jspm_packages/ 53 | 54 | # Snowpack dependency directory (https://snowpack.dev/) 55 | web_modules/ 56 | 57 | # TypeScript cache 58 | *.tsbuildinfo 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # Optional stylelint cache 67 | .stylelintcache 68 | 69 | # Microbundle cache 70 | .rpt2_cache/ 71 | .rts2_cache_cjs/ 72 | .rts2_cache_es/ 73 | .rts2_cache_umd/ 74 | 75 | # Optional REPL history 76 | .node_repl_history 77 | 78 | # Output of 'npm pack' 79 | *.tgz 80 | 81 | # Yarn Integrity file 82 | .yarn-integrity 83 | 84 | # dotenv environment variable files 85 | .env 86 | .env.development.local 87 | .env.test.local 88 | .env.production.local 89 | .env.local 90 | 91 | # parcel-bundler cache (https://parceljs.org/) 92 | .cache 93 | .parcel-cache 94 | 95 | # Next.js build output 96 | .next 97 | out 98 | 99 | 100 | # Stores VSCode versions used for testing VSCode extensions 101 | .vscode-test 102 | 103 | # yarn v2 104 | .yarn/cache 105 | .yarn/unplugged 106 | .yarn/build-state.yml 107 | .yarn/install-state.gz 108 | .pnp.* 109 | 110 | .medusa 111 | 112 | /.vscode 113 | -------------------------------------------------------------------------------- /src/admin/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./$schema.json", 3 | "templates": { 4 | "title": "Templates" 5 | }, 6 | "layouts": { 7 | "title": "Layouts" 8 | }, 9 | "reminder_schedules": { 10 | "title": "Reminder Schedules", 11 | "delays": "Delays", 12 | "notify_existing": "Notify Existing Carts", 13 | "notify_existing_hint": "When disabled, only carts created after the last schedule update will receive notifications", 14 | "notify_existing_warning_title": "Notify all existing carts?", 15 | "notify_existing_warning_desc": "Enabling this will notify a large number of existing abandoned carts. This action may result in a high volume of notifications being sent. Proceed carefully.", 16 | "reset_on_cart_update": "Reset on Cart Update", 17 | "reset_on_cart_update_hint": "When enabled, the notification cycle restarts if a cart is updated. When disabled, notifications continue from the original abandonment time regardless of cart updates.", 18 | "create_title": "Create schedule", 19 | "create_description": "Create a new reminder schedule for abandoned carts", 20 | "edit_title": "Edit schedule", 21 | "edit_description": "Edit the reminder schedule for abandoned carts", 22 | "delete": { 23 | "confirmation": "You are about to delete a reminder schedule. This action cannot be undone.", 24 | "successToast": "Reminder schedule was successfully deleted." 25 | } 26 | }, 27 | "validate_schedules": { 28 | "title": "Validate Notification Data", 29 | "error_title": "Validation Error", 30 | "all_valid": "All Valid", 31 | "missing_data": "Missing Data ({{count}})", 32 | "validating": "Validating...", 33 | "validate_button": "Validate Templates", 34 | "unknown_template": "Unknown Template", 35 | "missing_variables": "Missing Variables", 36 | "view_provided_data": "View Provided Data" 37 | }, 38 | "fields": { 39 | "unit": "Unit", 40 | "template": "Template", 41 | "layout": "Layout" 42 | }, 43 | "menuLabels": { 44 | "abandoned_carts": "Abandoned Carts", 45 | "postmark_templates": "Postmark Templates" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/workflows/abandoned-carts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createWorkflow, 3 | WorkflowResponse, 4 | transform, 5 | } from "@medusajs/framework/workflows-sdk" 6 | import { sendNotificationsStep, updateCartsStep } from "@medusajs/medusa/core-flows" 7 | import { fetchAbandonedCarts } from "./steps/fetch-abandoned-carts" 8 | import { ReminderSchedule } from "../types/reminder-schedules" 9 | import { notificationDataWorkflow } from "./notification-data" 10 | import { computeCartReferenceTimestamp } from "../types/abandoned-cart-tracking" 11 | import { deepMerge } from "@medusajs/utils" 12 | 13 | export type SendAbandonedCartsWorkflowInput = { 14 | reminderSchedules: ReminderSchedule[] 15 | pagination: { limit: number, offset: number } 16 | } 17 | 18 | export const sendAbandonedCartsWorkflow = createWorkflow( 19 | "send-abandoned-carts", 20 | function (input: SendAbandonedCartsWorkflowInput) { 21 | const { carts, totalCount } = fetchAbandonedCarts(input) 22 | 23 | const { notificationData } = notificationDataWorkflow.runAsStep({ 24 | input: { carts } 25 | }) 26 | 27 | sendNotificationsStep(notificationData) 28 | 29 | const updateCartsData = transform(carts, cartReminderGroups => { 30 | const now = new Date() 31 | return cartReminderGroups.map(({ cart, reminders }) => { 32 | const cartReferenceAtSend = new Date(computeCartReferenceTimestamp(cart)) 33 | 34 | // Build a sent_notifications object for all reminders 35 | const sent_notifications = Object.fromEntries( 36 | reminders.map(reminder => [ 37 | `${reminder.schedule.id}:${reminder.delayIso}`, 38 | { 39 | sent_at: now.toISOString(), 40 | cart_reference_at_send: cartReferenceAtSend.toISOString() 41 | } 42 | ]) 43 | ) 44 | 45 | return { 46 | id: cart.id, 47 | metadata: deepMerge(cart.metadata, { abandoned_cart_tracking: { sent_notifications } }) 48 | } 49 | }) 50 | }) 51 | 52 | const updatedCarts = updateCartsStep(updateCartsData) 53 | 54 | return new WorkflowResponse({ updatedCarts, pagination: { totalCount } }) 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /src/admin/i18n/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./$schema.json", 3 | "templates": { 4 | "title": "Template" 5 | }, 6 | "layouts": { 7 | "title": "Layout" 8 | }, 9 | "reminder_schedules": { 10 | "title": "Promemoria", 11 | "delays": "Ritardi", 12 | "notify_existing": "Notifica Carrelli Esistenti", 13 | "notify_existing_hint": "Quando disabilitato, solo i carrelli creati dopo l'ultimo aggiornamento del promemoria riceveranno notifiche", 14 | "notify_existing_warning_title": "Notificare tutti i carrelli esistenti?", 15 | "notify_existing_warning_desc": "Abilitando questa opzione, verrà inviata una notifica a un gran numero di carrelli abbandonati esistenti. Questa azione potrebbe comportare l'invio di un elevato volume di notifiche. Procedi con cautela.", 16 | "reset_on_cart_update": "Riavvia al Aggiornamento Carrello", 17 | "reset_on_cart_update_hint": "Quando abilitato, il ciclo di notifiche si riavvia se un carrello viene aggiornato. Quando disabilitato, le notifiche continuano dal momento originale di abbandono indipendentemente dagli aggiornamenti del carrello.", 18 | "create_title": "Crea promemoria", 19 | "create_description": "Crea un nuovo promemoria per i carrelli abbandonati", 20 | "edit_title": "Modifica promemoria", 21 | "edit_description": "Modifica il promemoria per i carrelli abbandonati", 22 | "delete": { 23 | "confirmation": "Stai per eliminare un promemoria. Questa azione non può essere annullata.", 24 | "successToast": "Il promemoria è stato eliminato con successo." 25 | } 26 | }, 27 | "validate_schedules": { 28 | "title": "Valida Dati di Notifica", 29 | "error_title": "Errore di Validazione", 30 | "all_valid": "Tutto Valido", 31 | "missing_data": "Dati Mancanti ({{count}})", 32 | "validating": "Validazione in corso...", 33 | "validate_button": "Valida Template", 34 | "unknown_template": "Template Sconosciuto", 35 | "missing_variables": "Variabili Mancanti", 36 | "view_provided_data": "Visualizza Dati Forniti" 37 | }, 38 | "fields": { 39 | "unit": "Unità", 40 | "template": "Template", 41 | "layout": "Layout" 42 | }, 43 | "menuLabels": { 44 | "abandoned_carts": "Carrelli Abbandonati", 45 | "postmark_templates": "Template Postmark" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/admin/components/general/header.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, Button, Text } from "@medusajs/ui" 2 | import React from "react" 3 | import { Link, LinkProps } from "react-router-dom" 4 | import { ActionMenu, ActionMenuProps } from "./action-menu" 5 | 6 | export type HeadingProps = { 7 | title: string 8 | subtitle?: string 9 | actions?: ( 10 | | { 11 | type: "button" 12 | props: React.ComponentProps 13 | link?: LinkProps 14 | } 15 | | { 16 | type: "action-menu" 17 | props: ActionMenuProps 18 | } 19 | | { 20 | type: "custom" 21 | children: React.ReactNode 22 | } 23 | )[] 24 | } 25 | 26 | export const Header = ({ 27 | title, 28 | subtitle, 29 | actions = [], 30 | }: HeadingProps) => { 31 | return ( 32 |
33 |
34 | {title} 35 | {subtitle && ( 36 | 37 | {subtitle} 38 | 39 | )} 40 |
41 | {actions.length > 0 && ( 42 |
43 | {actions.map((action, index) => ( 44 | 45 | {action.type === "button" && ( 46 | 55 | )} 56 | {action.type === "action-menu" && ( 57 | 58 | )} 59 | {action.type === "custom" && action.children} 60 | 61 | ))} 62 |
63 | )} 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/providers/postmark/service.ts: -------------------------------------------------------------------------------- 1 | import { NotificationTypes } from "@medusajs/framework/types" 2 | import { AbstractNotificationProviderService, MedusaError } from "@medusajs/framework/utils" 3 | import { ServerClient, TemplatedMessage } from "postmark" 4 | 5 | export type PostmarkProviderOptions = { 6 | apiKey: string, 7 | default: { 8 | from: string, 9 | bcc?: string 10 | } 11 | } 12 | 13 | class PostmarkProviderService extends AbstractNotificationProviderService { 14 | static identifier = "postmark" 15 | protected client: ServerClient 16 | protected options: PostmarkProviderOptions 17 | 18 | constructor(_, options: PostmarkProviderOptions) { 19 | super() 20 | this.options = options 21 | this.client = new ServerClient(options.apiKey) 22 | } 23 | 24 | async send(notification: NotificationTypes.ProviderSendNotificationDTO): Promise { 25 | const templateId = parseInt(notification.template) 26 | 27 | const sendOptions: TemplatedMessage = { 28 | From: notification.from ?? this.options.default.from, 29 | To: notification.to, 30 | TemplateId: templateId, 31 | TemplateModel: { 32 | ...notification.data 33 | } 34 | } 35 | 36 | if (this.options?.default?.bcc) 37 | sendOptions.Bcc = this.options.default.bcc 38 | 39 | if (notification.attachments?.length) { 40 | sendOptions.Attachments = notification.attachments.map((a) => { 41 | return { 42 | Content: a.content, 43 | Name: a.filename, 44 | ContentType: a.content_type ?? "", 45 | ContentID: `cid:${a.id}`, 46 | } 47 | }) 48 | } 49 | 50 | return await this.client.sendEmailWithTemplate(sendOptions) 51 | .then((res) => ({ id: res.MessageID })) 52 | .catch((error) => { 53 | throw new MedusaError(MedusaError.Types.INVALID_DATA, error) 54 | }) 55 | } 56 | 57 | static validateOptions(options: Record) { 58 | if (!options.apiKey) { 59 | throw new MedusaError( 60 | MedusaError.Types.INVALID_DATA, 61 | "Postmark API key is required in the provider's options." 62 | ) 63 | } 64 | if (!options.default?.from) { 65 | throw new MedusaError( 66 | MedusaError.Types.INVALID_DATA, 67 | "'From' email address is required in the provider's options." 68 | ) 69 | } 70 | } 71 | } 72 | 73 | 74 | export default PostmarkProviderService 75 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/page.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { defineRouteConfig } from "@medusajs/admin-sdk" 3 | import { Container, DataTable, Tabs } from "@medusajs/ui" 4 | import { usePostmarkOptions } from "../../hooks/use-postmark-options" 5 | import { usePostmarkDataTable } from "../../hooks/use-postmark-table" 6 | import { DataTableAction } from "../../components/data-table-action" 7 | import { useTranslation } from "react-i18next" 8 | 9 | const PostmarkPage = () => { 10 | const { t } = useTranslation("postmark") 11 | const { options: { server_id: serverId } = {} } = usePostmarkOptions() 12 | const templatesTable = usePostmarkDataTable({ type: "template", serverId }) 13 | const layoutsTable = usePostmarkDataTable({ type: "layout", serverId }) 14 | 15 | const [activeTab, setActiveTab] = useState("template") 16 | const currentTable = activeTab === "template" ? templatesTable : layoutsTable 17 | 18 | const createUrl = serverId 19 | ? `https://account.postmarkapp.com/servers/${serverId}/templates/starter/new-${activeTab}` 20 | : "#" 21 | 22 | const paginationTranslations = { 23 | of: t("general.of"), 24 | results: t("general.results"), 25 | pages: t("general.pages"), 26 | prev: t("general.prev"), 27 | next: t("general.next"), 28 | } 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | {t("templates.title")} 37 | {t("layouts.title")} 38 | 39 |
40 | 41 | window.open(createUrl, "_blank")} 44 | /> 45 |
46 |
47 | 48 | 49 |
50 |
51 |
52 | ) 53 | } 54 | 55 | export const config = defineRouteConfig({ 56 | label: "Postmark Templates" // "menuLabels.postmark_templates", 57 | //translationNs: "postmark", 58 | }) 59 | 60 | export default PostmarkPage 61 | 62 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-drawer/route-drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Drawer, clx } from "@medusajs/ui" 2 | import { PropsWithChildren, useEffect, useState } from "react" 3 | import { Path, useNavigate } from "react-router-dom" 4 | import { useStateAwareTo } from "../../../hooks/use-state-aware-to" 5 | import { RouteModalForm } from "../route-modal-form" 6 | import { RouteModalProvider } from "../route-modal-provider/route-provider" 7 | import { StackedModalProvider } from "../stacked-modal-provider" 8 | 9 | type RouteDrawerProps = PropsWithChildren<{ 10 | prev?: string | Partial 11 | }> 12 | 13 | const Root = ({ prev = "..", children }: RouteDrawerProps) => { 14 | const navigate = useNavigate() 15 | const [open, setOpen] = useState(false) 16 | const [stackedModalOpen, onStackedModalOpen] = useState(false) 17 | 18 | const to = useStateAwareTo(prev) 19 | 20 | /** 21 | * Open the modal when the component mounts. This 22 | * ensures that the entry animation is played. 23 | */ 24 | useEffect(() => { 25 | setOpen(true) 26 | 27 | return () => { 28 | setOpen(false) 29 | onStackedModalOpen(false) 30 | } 31 | }, []) 32 | 33 | const handleOpenChange = (open: boolean) => { 34 | if (!open) { 35 | document.body.style.pointerEvents = "auto" 36 | navigate(to, { replace: true }) 37 | return 38 | } 39 | 40 | setOpen(open) 41 | } 42 | 43 | return ( 44 | 45 | 46 | 47 | 53 | {children} 54 | 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | const Header = Drawer.Header 62 | const Title = Drawer.Title 63 | const Description = Drawer.Description 64 | const Body = Drawer.Body 65 | const Footer = Drawer.Footer 66 | const Close = Drawer.Close 67 | const Form = RouteModalForm 68 | 69 | /** 70 | * Drawer that is used to render a form on a separate route. 71 | * 72 | * Typically used for forms editing a resource. 73 | */ 74 | export const RouteDrawer = Object.assign(Root, { 75 | Header, 76 | Title, 77 | Body, 78 | Description, 79 | Footer, 80 | Close, 81 | Form, 82 | }) 83 | -------------------------------------------------------------------------------- /src/modules/postmark/service.ts: -------------------------------------------------------------------------------- 1 | import { MedusaService } from "@medusajs/framework/utils" 2 | import { ServerClient, Models } from "postmark" 3 | 4 | export type PostmarkModuleOptions = { 5 | server_api?: string 6 | } 7 | 8 | class PostmarkModuleService extends MedusaService({}) { 9 | protected client: ServerClient 10 | protected serverId: Promise 11 | 12 | constructor({ postmarkClient }: { postmarkClient: ServerClient }) { 13 | super(arguments) 14 | this.client = postmarkClient 15 | this.serverId = this.client.getServer().then(s => s.ID) 16 | } 17 | 18 | async getServerId() { 19 | return { server_id: await this.serverId } 20 | } 21 | 22 | async getTemplates(params?: { 23 | count?: number 24 | offset?: number 25 | templateType?: Models.TemplateTypes 26 | }) { 27 | return await this.client.getTemplates(params) 28 | } 29 | 30 | async getTemplate(templateId: number) { 31 | return await this.client.getTemplate(templateId) 32 | } 33 | 34 | async deleteTemplate(templateId: number) { 35 | return await this.client.deleteTemplate(templateId) 36 | } 37 | 38 | async validateTemplate(params: { 39 | Subject: string 40 | HtmlBody: string 41 | TextBody: string 42 | TestRenderModel: Record 43 | }) { 44 | return await this.client.validateTemplate(params) 45 | } 46 | 47 | async list(filter: { template_id: string | string[] }) { 48 | const ids = Array.isArray(filter.template_id) 49 | ? filter.template_id 50 | : [filter.template_id] 51 | 52 | const templates = await Promise.all( 53 | ids.map(async (id) => { 54 | try { 55 | // Template IDs can be numeric or alias strings 56 | const numericId = parseInt(id, 10) 57 | const template = isNaN(numericId) 58 | ? await this.client.getTemplate(id) 59 | : await this.client.getTemplate(numericId) 60 | 61 | return { 62 | ...template, 63 | template_id: id, 64 | } 65 | } catch (error) { 66 | console.warn(`Template with ID ${id} not found:`, error) 67 | return null 68 | } 69 | }) 70 | ) 71 | 72 | return templates.filter((t) => t !== null) 73 | } 74 | } 75 | 76 | 77 | export default PostmarkModuleService 78 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-modal-form/route-modal-form.tsx: -------------------------------------------------------------------------------- 1 | import { Prompt } from "@medusajs/ui" 2 | import { PropsWithChildren } from "react" 3 | import { FieldValues, UseFormReturn } from "react-hook-form" 4 | import { useTranslation } from "react-i18next" 5 | import { useBlocker } from "react-router-dom" 6 | import { Form } from "../../form/form" 7 | 8 | type RouteModalFormProps = PropsWithChildren<{ 9 | form: UseFormReturn 10 | blockSearchParams?: boolean 11 | onClose?: (isSubmitSuccessful: boolean) => void 12 | }> 13 | 14 | export const RouteModalForm = ({ 15 | form, 16 | blockSearchParams: blockSearch = false, 17 | children, 18 | onClose, 19 | }: RouteModalFormProps) => { 20 | const { t } = useTranslation() 21 | 22 | const { 23 | formState: { isDirty }, 24 | } = form 25 | 26 | const blocker = useBlocker(({ currentLocation, nextLocation }) => { 27 | const { isSubmitSuccessful } = nextLocation.state || {} 28 | 29 | if (isSubmitSuccessful) { 30 | onClose?.(true) 31 | return false 32 | } 33 | 34 | const isPathChanged = currentLocation.pathname !== nextLocation.pathname 35 | const isSearchChanged = currentLocation.search !== nextLocation.search 36 | 37 | if (blockSearch) { 38 | const ret = isDirty && (isPathChanged || isSearchChanged) 39 | 40 | if (!ret) { 41 | onClose?.(isSubmitSuccessful) 42 | } 43 | 44 | return ret 45 | } 46 | 47 | const ret = isDirty && isPathChanged 48 | 49 | if (!ret) { 50 | onClose?.(isSubmitSuccessful) 51 | } 52 | 53 | return ret 54 | }) 55 | 56 | const handleCancel = () => { 57 | blocker?.reset?.() 58 | } 59 | 60 | const handleContinue = () => { 61 | blocker?.proceed?.() 62 | onClose?.(false) 63 | } 64 | 65 | return ( 66 | 67 | {children} 68 | 69 | 70 | 71 | {t("general.unsavedChangesTitle")} 72 | 73 | {t("general.unsavedChangesDescription")} 74 | 75 | 76 | 77 | 78 | {t("actions.cancel")} 79 | 80 | 81 | {t("actions.continue")} 82 | 83 | 84 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/types/reminder-schedules.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod" 2 | import { PostmarkTemplate } from "./templates" 3 | import { Temporal } from "temporal-polyfill" 4 | 5 | /** 6 | * Base reminder schedule entity - represents the database model 7 | */ 8 | export interface ReminderSchedule { 9 | id: string 10 | enabled: boolean 11 | template_id: string 12 | template?: PostmarkTemplate 13 | delays_iso: string[] // ISO 8601 duration strings (e.g., "PT1H", "PT24H", "P1D") 14 | notify_existing: boolean // Whether to notify for carts created before the last schedule update 15 | reset_on_cart_update: boolean // Whether to restart delay cycle when cart is updated 16 | created_at?: Date 17 | updated_at?: Date 18 | deleted_at?: Date | null 19 | } 20 | 21 | 22 | /** 23 | * Request payload for creating a new reminder schedule 24 | */ 25 | export interface CreateReminderScheduleRequest { 26 | enabled: boolean 27 | template_id: string 28 | delays_iso: string[] // ISO 8601 duration strings 29 | notify_existing: boolean // Whether to notify for carts created before the last schedule update 30 | reset_on_cart_update: boolean // Whether to restart delay cycle when cart is updated 31 | } 32 | 33 | /** 34 | * Request payload for updating an existing reminder schedule 35 | */ 36 | export interface UpdateReminderScheduleRequest { 37 | enabled?: boolean 38 | template_id?: string 39 | delays_iso?: string[] // ISO 8601 duration strings 40 | notify_existing?: boolean // Whether to notify for carts created before the last schedule update 41 | reset_on_cart_update?: boolean // Whether to restart delay cycle when cart is updated 42 | } 43 | 44 | /** 45 | * API response for listing reminder schedules 46 | */ 47 | export interface ReminderScheduleListResponse { 48 | schedules: ReminderSchedule[] 49 | } 50 | 51 | /** 52 | * API response for single reminder schedule operations 53 | */ 54 | export interface ReminderScheduleResponse { 55 | schedule: ReminderSchedule 56 | } 57 | 58 | /** 59 | * Zod schema for creating reminder schedules 60 | */ 61 | export const CreateReminderScheduleSchema = z.object({ 62 | enabled: z.boolean(), 63 | template_id: z.string().nonempty(), 64 | delays_iso: z.array( 65 | z.string().duration() 66 | ).nonempty(), 67 | notify_existing: z.boolean().default(false), 68 | reset_on_cart_update: z.boolean().default(true) 69 | }) 70 | 71 | /** 72 | * Zod schema for updating reminder schedules 73 | */ 74 | export const UpdateReminderScheduleSchema = z.object({ 75 | enabled: z.boolean().optional(), 76 | template_id: z.string().nonempty().optional(), 77 | delays_iso: z.array( 78 | z.string().duration() 79 | ).nonempty().optional(), 80 | notify_existing: z.boolean().optional(), 81 | reset_on_cart_update: z.boolean().optional() 82 | }) 83 | 84 | /** 85 | * Inferred types from Zod schemas 86 | */ 87 | export type CreateReminderSchedule = z.infer 88 | export type UpdateReminderSchedule = z.infer 89 | -------------------------------------------------------------------------------- /src/admin/components/modals/route-focus-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { FocusModal, clx } from "@medusajs/ui" 2 | import { PropsWithChildren, useEffect, useState } from "react" 3 | import { Path, useNavigate } from "react-router-dom" 4 | import { useStateAwareTo } from "../../../hooks/use-state-aware-to" 5 | import { RouteModalForm } from "../route-modal-form" 6 | import { useRouteModal } from "../route-modal-provider" 7 | import { RouteModalProvider } from "../route-modal-provider/route-provider" 8 | import { StackedModalProvider } from "../stacked-modal-provider" 9 | 10 | type RouteFocusModalProps = PropsWithChildren<{ 11 | prev?: string | Partial 12 | }> 13 | 14 | const Root = ({ prev = "..", children }: RouteFocusModalProps) => { 15 | const navigate = useNavigate() 16 | const [open, setOpen] = useState(false) 17 | const [stackedModalOpen, onStackedModalOpen] = useState(false) 18 | 19 | const to = useStateAwareTo(prev) 20 | 21 | /** 22 | * Open the modal when the component mounts. This 23 | * ensures that the entry animation is played. 24 | */ 25 | useEffect(() => { 26 | setOpen(true) 27 | 28 | return () => { 29 | setOpen(false) 30 | onStackedModalOpen(false) 31 | } 32 | }, []) 33 | 34 | const handleOpenChange = (open: boolean) => { 35 | if (!open) { 36 | document.body.style.pointerEvents = "auto" 37 | navigate(to, { replace: true }) 38 | return 39 | } 40 | 41 | setOpen(open) 42 | } 43 | 44 | return ( 45 | 46 | 47 | 48 | {children} 49 | 50 | 51 | 52 | ) 53 | } 54 | 55 | type ContentProps = PropsWithChildren<{ 56 | stackedModalOpen: boolean 57 | }> 58 | 59 | const Content = ({ stackedModalOpen, children }: ContentProps) => { 60 | const { __internal } = useRouteModal() 61 | 62 | const shouldPreventClose = !__internal.closeOnEscape 63 | 64 | return ( 65 | { 69 | e.preventDefault() 70 | } 71 | : undefined 72 | } 73 | className={clx({ 74 | "!bg-ui-bg-disabled !inset-x-5 !inset-y-3": stackedModalOpen, 75 | })} 76 | > 77 | {children} 78 | 79 | ) 80 | } 81 | 82 | const Header = FocusModal.Header 83 | const Title = FocusModal.Title 84 | const Description = FocusModal.Description 85 | const Footer = FocusModal.Footer 86 | const Body = FocusModal.Body 87 | const Close = FocusModal.Close 88 | const Form = RouteModalForm 89 | 90 | /** 91 | * FocusModal that is used to render a form on a separate route. 92 | * 93 | * Typically used for forms creating a resource or forms that require 94 | * a lot of space. 95 | */ 96 | export const RouteFocusModal = Object.assign(Root, { 97 | Header, 98 | Title, 99 | Body, 100 | Description, 101 | Footer, 102 | Close, 103 | Form, 104 | }) 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medusa-plugin-postmark", 3 | "version": "6.0.0", 4 | "description": "Postmark notification plugin for MedusaJS", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Fullstak-nl/medusa-plugin-postmark" 8 | }, 9 | "author": "Bram Hammer", 10 | "license": "MIT", 11 | "files": [ 12 | ".medusa/server" 13 | ], 14 | "exports": { 15 | "./package.json": "./package.json", 16 | "./workflows": "./.medusa/server/src/workflows/index.js", 17 | "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js", 18 | ".": "./.medusa/server/src/modules/postmark/index.js", 19 | "./providers/*": "./.medusa/server/src/providers/*/index.js", 20 | "./admin": { 21 | "import": "./.medusa/server/src/admin/index.mjs", 22 | "require": "./.medusa/server/src/admin/index.js", 23 | "default": "./.medusa/server/src/admin/index.js" 24 | }, 25 | "./*": "./.medusa/server/src/*.js" 26 | }, 27 | "keywords": [ 28 | "medusa-v2", 29 | "medusa-plugin", 30 | "medusa-plugin-integration", 31 | "medusa-plugin-notification" 32 | ], 33 | "scripts": { 34 | "build": "medusa plugin:build", 35 | "dev": "medusa plugin:develop", 36 | "prepublishOnly": "medusa plugin:build", 37 | "test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit", 38 | "test:integration:workflows": "TEST_TYPE=integration:workflows NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit", 39 | "test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit", 40 | "test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit" 41 | }, 42 | "devDependencies": { 43 | "@medusajs/admin-sdk": "2.11.2", 44 | "@medusajs/cli": "2.11.2", 45 | "@medusajs/framework": "2.11.2", 46 | "@medusajs/icons": "2.11.2", 47 | "@medusajs/medusa": "2.11.2", 48 | "@medusajs/test-utils": "2.11.2", 49 | "@medusajs/ui": "4.0.26", 50 | "@swc/core": "1.5.7", 51 | "@swc/jest": "^0.2.36", 52 | "@types/jest": "^29.5.13", 53 | "@types/lodash": "4.17.20", 54 | "@types/node": "^20.0.0", 55 | "@types/react": "^18.3.2", 56 | "@types/react-dom": "^18.2.25", 57 | "jest": "^29.7.0", 58 | "prop-types": "^15.8.1", 59 | "react": "^18.2.0", 60 | "react-dom": "^18.2.0", 61 | "ts-node": "^10.9.2", 62 | "typescript": "^5.6.2", 63 | "vite": "^5.2.11", 64 | "yalc": "^1.0.0-pre.53" 65 | }, 66 | "peerDependencies": { 67 | "@medusajs/admin-sdk": "^2.11.1", 68 | "@medusajs/cli": "^2.11.1", 69 | "@medusajs/framework": "^2.11.1", 70 | "@medusajs/icons": "^2.11.1", 71 | "@medusajs/medusa": "^2.11.1", 72 | "@medusajs/test-utils": "^2.11.1", 73 | "@medusajs/ui": "^4.0.25", 74 | "react-i18next": "*" 75 | }, 76 | "dependencies": { 77 | "@formatjs/intl-durationformat": "^0.7.6", 78 | "postmark": "^4.0.5", 79 | "temporal-polyfill": "^0.3.0" 80 | }, 81 | "engines": { 82 | "node": ">=20" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /REMINDER-SCHEDULES-BEHAVIOUR.md: -------------------------------------------------------------------------------- 1 | # Reminder Schedules: Behaviour Specification 2 | 3 | ## 1. Overview 4 | 5 | A **reminder schedule** defines when and how abandoned cart notifications are sent to customers. Each schedule can specify multiple delays (e.g., 1 minute, 1 hour), a single unique template, and options for handling cart updates and existing carts. 6 | 7 | --- 8 | 9 | ## 2. Notification Logic 10 | 11 | ### 2.1. Eligibility 12 | - **A cart is eligible for reminders if:** 13 | - It has an email address. 14 | - It contains at least one item. 15 | 16 | ### 2.2. Reminder Delays 17 | - Each schedule can have one or more delays (e.g., `PT1M`, `PT2H`). 18 | - A notification is sent when the time since the cart's last relevant update (see below) exceeds the delay. 19 | 20 | ### 2.3. Sending Reminders 21 | - For each eligible cart and schedule: 22 | - Only the **latest** applicable reminder is sent (if multiple delays are passed, only the largest is sent). 23 | - No duplicate notifications are sent for the same schedule/delay. 24 | 25 | #### Example 26 | > If a schedule has delays of 1 minute and 2 minutes, and a cart is abandoned for 2.5 minutes, only the 2-minute reminder is sent. 27 | 28 | ### 2.4. Outdated Notifications 29 | - If a cart becomes eligible for a longer delay, shorter (missed) reminders are **not** sent retroactively. 30 | 31 | #### Example 32 | > If a cart is abandoned for 30 minutes and the schedule is updated to add a 10-minute delay, the 10-minute reminder is **not** sent if the 30-minute one was already sent. 33 | 34 | --- 35 | 36 | ## 3. Cart Updates and Reminder Cycle 37 | 38 | 39 | ### 3.1. Resetting the Cycle 40 | 41 | - **By default** (`reset_on_cart_update: true`): 42 | - Updating cart items resets the reminder cycle for all delays. 43 | - All reminders (even those already sent) become eligible to be sent again, starting from the new update time. 44 | 45 | - If `reset_on_cart_update` is **false**: 46 | - The timer for all remaining (not-yet-sent) delays is always based on the latest item update time. 47 | - However, delays that have already been sent will **not** be sent again after an update. 48 | - This means only new, not-yet-sent delays are considered after each update, and their timer is always relative to the most recent update. 49 | 50 | #### Example (Reset = true) 51 | > Cart abandoned at 12:00, 10-min reminder sent at 12:10. If items are updated at 12:12, the 10-min reminder will be sent again at 12:22 (since all delays are re-eligible). 52 | 53 | #### Example (Reset = false) 54 | > Cart abandoned at 12:00, 10-min reminder sent at 12:10. If items are updated at 12:12, the 10-min reminder will **not** be sent again. If there is a 15-min delay, it will be sent at 12:27 (15 minutes after the latest update), but the 10-min one is never re-sent. 55 | 56 | --- 57 | 58 | ## 4. Existing Carts 59 | 60 | ### 4.1. Notifying Existing Carts 61 | - If `notify_existing` is **true**, reminders are sent to carts that were created before the schedule was last edited. 62 | - If `notify_existing` is **false**, only carts created after the schedule is edited are notified. 63 | 64 | #### Example 65 | > A cart abandoned before a new delay is added to a schedule: 66 | > - If `notify_existing: true`, it will receive reminders. 67 | > - If `notify_existing: false`, it will not. 68 | 69 | ## 5. Best Practices 70 | - Use `notify_existing` carefully to avoid spamming old carts. 71 | 72 | --- 73 | -------------------------------------------------------------------------------- /src/admin/lib/extended-admin.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@medusajs/js-sdk" 2 | import { 3 | CreateReminderScheduleRequest, 4 | UpdateReminderScheduleRequest, 5 | ReminderScheduleListResponse, 6 | ReminderScheduleResponse 7 | } from "../../types/reminder-schedules" 8 | import { PostmarkTemplate } from "../../types/templates" 9 | import { ValidationResponse } from "../../types/validation" 10 | import { HttpTypes } from "@medusajs/framework/types" 11 | 12 | 13 | class ReminderSchedules { 14 | private client: Client 15 | 16 | constructor(client: Client) { 17 | this.client = client 18 | } 19 | 20 | async list(query?: { id?: string; q?: string } & HttpTypes.FindParams) { 21 | return this.client.fetch( 22 | "/admin/postmark/abandoned-carts/reminders/schedules", 23 | { 24 | query 25 | } 26 | ) 27 | } 28 | 29 | async retrieve(id: string, query?: HttpTypes.SelectParams) { 30 | return this.client.fetch( 31 | `/admin/postmark/abandoned-carts/reminders/schedules/${id}`, 32 | { 33 | query 34 | } 35 | ) 36 | } 37 | 38 | async create(data: CreateReminderScheduleRequest) { 39 | return this.client.fetch( 40 | "/admin/postmark/abandoned-carts/reminders/schedules", 41 | { 42 | method: "POST", 43 | body: data, 44 | } 45 | ) 46 | } 47 | 48 | async update(id: string, data: UpdateReminderScheduleRequest) { 49 | return this.client.fetch( 50 | `/admin/postmark/abandoned-carts/reminders/schedules/${id}`, 51 | { 52 | method: "POST", 53 | body: data, 54 | } 55 | ) 56 | } 57 | 58 | async delete(id: string) { 59 | return this.client.fetch<{ success: boolean }>( 60 | `/admin/postmark/abandoned-carts/reminders/schedules/${id}`, 61 | { 62 | method: "DELETE", 63 | } 64 | ) 65 | } 66 | 67 | async validate() { 68 | return this.client.fetch( 69 | "/admin/postmark/abandoned-carts/reminders/validate", 70 | { 71 | method: "POST", 72 | } 73 | ) 74 | } 75 | } 76 | class Templates { 77 | private client: Client 78 | 79 | constructor(client: Client) { 80 | this.client = client 81 | } 82 | async list(query?: HttpTypes.FindParams & { 83 | id?: string 84 | q?: string 85 | templateType?: "Standard" | "Layout" 86 | }) { 87 | return this.client.fetch<{ Templates: PostmarkTemplate[], TotalCount: number, offset: number, limit: number, count: number }>( 88 | "/admin/postmark/templates", 89 | { 90 | method: "GET", 91 | query 92 | } 93 | ) 94 | } 95 | } 96 | 97 | class Postmark { 98 | public reminderSchedules: ReminderSchedules 99 | public templates: Templates 100 | 101 | constructor(client: Client) { 102 | this.reminderSchedules = new ReminderSchedules(client) 103 | this.templates = new Templates(client) 104 | } 105 | } 106 | 107 | export class ExtendedAdmin { 108 | public postmark: Postmark 109 | 110 | constructor(client: Client) { 111 | this.postmark = new Postmark(client) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /.github/workflows/release-and-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Release NPM Package 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Release type (patch, minor, major, or specific version)' 8 | required: true 9 | default: 'patch' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | services: 15 | postgres: 16 | image: postgres:17 17 | env: 18 | POSTGRES_USER: postgres 19 | POSTGRES_PASSWORD: postgres 20 | ports: 21 | - 5432:5432 22 | permissions: 23 | contents: write 24 | packages: write 25 | steps: 26 | - uses: actions/checkout@v3 27 | with: 28 | fetch-depth: 0 29 | token: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Setup Node.js 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version: '22.x' 35 | registry-url: 'https://registry.npmjs.org' 36 | 37 | # - name: Setup pnpm 38 | # uses: pnpm/action-setup@v2 39 | # with: 40 | # version: latest 41 | # run_install: false 42 | 43 | - name: Install dependencies 44 | run: yarn install --frozen-lockfile 45 | 46 | # - name: Create env file 47 | # run: | 48 | # echo 'DB_USERNAME=postgres' >> ./integration-tests/.env.test 49 | # echo 'DB_PASSWORD=postgres' >> ./integration-tests/.env.test 50 | # echo 'TOLGEE_API_URL=https://app.tolgee.io' >> ./integration-tests/.env.test 51 | # echo 'TOLGEE_API_KEY=${{ secrets.TOLGEE_API_KEY }}' >> ./integration-tests/.env.test 52 | # echo 'TOLGEE_PROJECT_ID=${{ secrets.TOLGEE_PROJECT_ID }}' >> ./integration-tests/.env.test 53 | 54 | # - name: Run tests 55 | # run: pnpm run --filter integration-tests test:integration:http 56 | 57 | - name: Build 58 | run: | 59 | yarn build 60 | 61 | - name: Configure Git 62 | run: | 63 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 64 | git config --local user.name "github-actions[bot]" 65 | 66 | - name: Bump version and push 67 | id: version-bump 68 | run: | 69 | yarn version --new-version ${{ github.event.inputs.version }} -m "chore: release v%s" 70 | # Get the new version after bumping 71 | VERSION=$(node -p "require('./package.json').version") 72 | 73 | # Set version output 74 | echo "version=$VERSION" >> $GITHUB_OUTPUT 75 | 76 | # Push all changes and tags 77 | git push && git push --tags 78 | 79 | - name: Publish to NPM 80 | run: | 81 | if [[ "${{ steps.version-bump.outputs.version }}" == *"beta"* ]]; then 82 | echo "Publishing with dist-tag beta" 83 | yarn publish --tag beta 84 | elif [[ "${{ steps.version-bump.outputs.version }}" == *"alpha"* ]]; then 85 | echo "Publishing with dist-tag alpha" 86 | yarn publish --tag alpha 87 | elif [[ "${{ steps.version-bump.outputs.version }}" == *"rc"* ]]; then 88 | echo "Publishing with dist-tag rc" 89 | yarn publish --tag rc 90 | else 91 | echo "Publishing with default dist-tag latest" 92 | yarn publish 93 | fi 94 | env: 95 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 96 | 97 | - name: Create GitHub Release 98 | uses: softprops/action-gh-release@v1 99 | with: 100 | tag_name: v${{ steps.version-bump.outputs.version }} 101 | name: v${{ steps.version-bump.outputs.version }} 102 | generate_release_notes: true 103 | draft: true 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # medusa-plugin-postmark 2 | 3 | [![stars - medusa-plugin-postmark](https://img.shields.io/github/stars/Fullstak-nl/medusa-plugin-postmark?style=social)](https://github.com/Fullstak-nl/medusa-plugin-postmark) 4 | [![forks - medusa-plugin-postmark](https://img.shields.io/github/forks/Fullstak-nl/medusa-plugin-postmark?style=social)](https://github.com/Fullstak-nl/medusa-plugin-postmark) 5 | [![CodeQL](https://github.com/Fullstak-nl/medusa-plugin-postmark/actions/workflows/codeql.yml/badge.svg)](https://github.com/Fullstak-nl/medusa-plugin-postmark/actions/workflows/codeql.yml) 6 | 7 | [![NPM Version (with dist tag)](https://img.shields.io/npm/v/medusa-plugin-postmark/latest)](https://www.npmjs.com/package/medusa-plugin-postmark) 8 | [![License](https://img.shields.io/badge/License-MIT-blue)](#license) 9 | [![issues - medusa-plugin-postmark](https://img.shields.io/github/issues/Fullstak-nl/medusa-plugin-postmark)](https://github.com/Fullstak-nl/medusa-plugin-postmark/issues) 10 | 11 | > **Postmark notification plugin for Medusa v2** 12 | 13 | This plugin provides robust transactional email support for MedusaJS using [Postmark](https://postmarkapp.com/). It supports advanced workflows for abandoned cart reminders, template management, PDF attachments, and more. 14 | 15 | --- 16 | 17 | ## Features 18 | 19 | - **Transactional Emails**: Send order, customer, and workflow notifications via Postmark. 20 | - **Abandoned Cart Reminders**: Automated, configurable reminder schedules for abandoned carts. 21 | - **Template Management**: CRUD and validation for Postmark templates, including layouts. 22 | - **PDF Attachments**: Attach PDF invoices/credit notes to emails. 23 | - **Admin UI**: Manage templates, reminder schedules, and validate data from the Medusa admin panel. 24 | 25 | --- 26 | 27 | ## Installation & Setup 28 | 29 | ### 1. Install 30 | 31 | ```sh 32 | yarn add medusa-plugin-postmark 33 | npm install medusa-plugin-postmark 34 | ``` 35 | 36 | ### 2. Configure Plugin 37 | 38 | Add to your `medusa-config.ts` as a notification provider and as a plugin to enable UI and abandoned carts: 39 | 40 | ```ts 41 | defineConfig({ 42 | modules: [ 43 | { 44 | resolve: "@medusajs/medusa/notification", 45 | options: { 46 | providers: [ 47 | { 48 | resolve: "medusa-plugin-postmark/providers/postmark", 49 | id: "postmark", 50 | options: { 51 | channels: ["email"], 52 | apiKey: process.env.POSTMARK_API_KEY!, 53 | default: { 54 | from: process.env.POSTMARK_FROM, 55 | bcc: process.env.POSTMARK_BCC, 56 | }, 57 | }, 58 | }, 59 | ], 60 | }, 61 | }, 62 | ], 63 | 64 | plugins: [ 65 | { 66 | resolve: "medusa-plugin-postmark", 67 | options: { 68 | apiKey: process.env.POSTMARK_API_KEY!, 69 | }, 70 | }, 71 | ], 72 | }) 73 | ``` 74 | 75 | ### 3. Environment Variables 76 | 77 | - `POSTMARK_API_KEY`: Your Postmark server token 78 | - `POSTMARK_FROM`: Default sender email address (must be verified in Postmark) 79 | - `POSTMARK_BCC`: (Optional) Default BCC address 80 | 81 | --- 82 | 83 | ## Workflows 84 | 85 | - **Abandoned Cart Workflow**: Triggers reminders based on schedule and cart state. 86 | - **Template Validation**: Ensures all required variables are present before sending. 87 | 88 | ## Reminder Schedules 89 | 90 | - Define when and how reminders are sent for abandoned carts. 91 | - Use ISO 8601 durations for delays (e.g., `PT1H`, `P1D`). 92 | - Link schedules to specific Postmark templates. 93 | - Control notification behavior with flags (e.g., notify existing carts, reset on update). 94 | 95 | ## License 96 | 97 | MIT License © 2023 Bram Hammer 98 | 99 | 100 | ## Acknowledgement 101 | 102 | This plugin is originally based on medusa-plugin-sendgrid by Oliver Juhl. 103 | -------------------------------------------------------------------------------- /src/admin/hooks/use-combobox-data.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | QueryKey, 3 | keepPreviousData, 4 | useInfiniteQuery, 5 | useQuery, 6 | } from "@tanstack/react-query" 7 | import { useDebouncedSearch } from "./use-debounced-search" 8 | 9 | type ComboboxExternalData = { 10 | offset: number 11 | limit: number 12 | count: number 13 | } 14 | 15 | type ComboboxQueryParams = { 16 | id?: string 17 | q?: string 18 | offset?: number 19 | limit?: number 20 | } 21 | 22 | export const useComboboxData = < 23 | TResponse extends ComboboxExternalData, 24 | TParams extends ComboboxQueryParams, 25 | >({ 26 | queryKey, 27 | queryFn, 28 | getOptions, 29 | defaultValue, 30 | defaultValueKey, 31 | selectedValue, 32 | pageSize = 10, 33 | enabled = true, 34 | }: { 35 | queryKey: QueryKey 36 | queryFn: (params: TParams) => Promise 37 | getOptions: (data: TResponse) => { label: string; value: string }[] 38 | defaultValueKey?: keyof TParams 39 | defaultValue?: string | string[] 40 | selectedValue?: string 41 | pageSize?: number 42 | enabled?: boolean 43 | }) => { 44 | const { searchValue, onSearchValueChange, query } = useDebouncedSearch() 45 | 46 | const queryInitialDataBy = defaultValueKey || "id" 47 | const { data: initialData } = useQuery({ 48 | queryKey: [...queryKey, defaultValue].filter(Boolean) as QueryKey, 49 | queryFn: async () => { 50 | return queryFn({ 51 | [queryInitialDataBy]: defaultValue, 52 | limit: Array.isArray(defaultValue) ? defaultValue.length : 1, 53 | } as TParams) 54 | }, 55 | enabled: !!defaultValue && enabled, 56 | }) 57 | 58 | // always load selected value in case current data dosn't contain the value 59 | const { data: selectedData } = useQuery({ 60 | queryKey: [...queryKey, selectedValue].filter(Boolean) as QueryKey, 61 | queryFn: async () => { 62 | return queryFn({ 63 | id: selectedValue, 64 | limit: 1, 65 | } as TParams) 66 | }, 67 | enabled: !!selectedValue && enabled, 68 | }) 69 | 70 | const { data, ...rest } = useInfiniteQuery({ 71 | // prevent infinite query response shape beeing stored under regualr list reponse QKs 72 | queryKey: [...queryKey, "_cbx_", query].filter(Boolean) as QueryKey, 73 | queryFn: async ({ pageParam = 0 }) => { 74 | return await queryFn({ 75 | q: query, 76 | limit: pageSize, 77 | offset: pageParam, 78 | } as TParams) 79 | }, 80 | initialPageParam: 0, 81 | getNextPageParam: (lastPage) => { 82 | const moreItemsExist = lastPage.count > lastPage.offset + lastPage.limit 83 | return moreItemsExist ? lastPage.offset + lastPage.limit : undefined 84 | }, 85 | placeholderData: keepPreviousData, 86 | enabled: enabled, 87 | }) 88 | 89 | const options = data?.pages.flatMap((page) => getOptions(page)) ?? [] 90 | const defaultOptions = initialData ? getOptions(initialData) : [] 91 | const selectedOptions = selectedData ? getOptions(selectedData) : [] 92 | /** 93 | * If there are no options and the query is empty, then the combobox should be disabled, 94 | * as there is no data to search for. 95 | */ 96 | const disabled = 97 | (!rest.isPending && !options.length && !searchValue) || !enabled 98 | 99 | // make sure that the default value is included in the options 100 | if (defaultValue && defaultOptions.length && !searchValue) { 101 | defaultOptions.forEach((option) => { 102 | if (!options.find((o) => o.value === option.value)) { 103 | options.unshift(option) 104 | } 105 | }) 106 | } 107 | 108 | if (selectedValue && selectedOptions.length) { 109 | selectedOptions.forEach((option) => { 110 | if (!options.find((o) => o.value === option.value)) { 111 | options.unshift(option) 112 | } 113 | }) 114 | } 115 | 116 | return { 117 | options, 118 | searchValue, 119 | onSearchValueChange, 120 | disabled, 121 | ...rest, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/admin/components/general/action-menu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | IconButton, 4 | clx, 5 | } from "@medusajs/ui" 6 | import { EllipsisHorizontal } from "@medusajs/icons" 7 | import { Link } from "react-router-dom" 8 | 9 | export type Action = { 10 | icon: React.ReactNode 11 | label: string 12 | disabled?: boolean 13 | } & ( 14 | | { 15 | to: string 16 | onClick?: never 17 | } 18 | | { 19 | onClick: () => void 20 | to?: never 21 | } 22 | ) 23 | 24 | export type ActionGroup = { 25 | actions: Action[] 26 | } 27 | 28 | export type ActionMenuProps = { 29 | groups: ActionGroup[] 30 | } 31 | 32 | export const ActionMenu = ({ groups }: ActionMenuProps) => { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {groups.map((group, index) => { 42 | if (!group.actions.length) { 43 | return null 44 | } 45 | 46 | const isLast = index === groups.length - 1 47 | 48 | return ( 49 | 50 | {group.actions.map((action, index) => { 51 | if (action.onClick) { 52 | return ( 53 | { 57 | e.stopPropagation() 58 | action.onClick() 59 | }} 60 | className={clx( 61 | "[&_svg]:text-ui-fg-subtle flex items-center gap-x-2", 62 | { 63 | "[&_svg]:text-ui-fg-disabled": action.disabled, 64 | } 65 | )} 66 | > 67 | {action.icon} 68 | {action.label} 69 | 70 | ) 71 | } 72 | 73 | return ( 74 |
75 | 85 | e.stopPropagation()}> 86 | {action.icon} 87 | {action.label} 88 | 89 | 90 |
91 | ) 92 | })} 93 | {!isLast && } 94 |
95 | ) 96 | })} 97 |
98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/admin/hooks/use-reminder-schedules.ts: -------------------------------------------------------------------------------- 1 | import { FetchError } from "@medusajs/js-sdk" 2 | import { 3 | UseMutationOptions, 4 | UseQueryOptions, 5 | useMutation, 6 | useQuery, 7 | useQueryClient, 8 | keepPreviousData, 9 | } from "@tanstack/react-query" 10 | import { sdk } from "../lib/sdk" 11 | import { CreateReminderScheduleRequest, ReminderScheduleListResponse, ReminderScheduleResponse, UpdateReminderScheduleRequest } from "../../types/reminder-schedules" 12 | import { HttpTypes } from "@medusajs/framework/types" 13 | 14 | const POSTMARK_REMINDER_SCHEDULES_QUERY_KEY = "postmark_reminder_schedules" as const 15 | 16 | const postmarkReminderSchedulesQueryKeys = { 17 | all: [POSTMARK_REMINDER_SCHEDULES_QUERY_KEY] as const, 18 | schedules: (query?: Record) => [...postmarkReminderSchedulesQueryKeys.all, "schedules", query ? { query } : undefined].filter( 19 | (k) => !!k 20 | ), 21 | schedule: (id: string, query?: Record) => [...postmarkReminderSchedulesQueryKeys.schedules(), id, query ? { query } : undefined].filter( 22 | (k) => !!k 23 | ) 24 | } 25 | 26 | export const useReminderSchedules = ( 27 | query?: HttpTypes.FindParams, 28 | options?: UseQueryOptions 29 | ) => { 30 | const { data, ...rest } = useQuery({ 31 | queryFn: async () => sdk.admin.postmark.reminderSchedules.list(query), 32 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(query), 33 | placeholderData: keepPreviousData, 34 | ...options, 35 | }) 36 | 37 | return { ...data, ...rest } 38 | } 39 | 40 | export const useReminderSchedule = ( 41 | id: string, 42 | query?: Record, 43 | options?: UseQueryOptions 44 | ) => { 45 | const { data, ...rest } = useQuery({ 46 | queryFn: async () => sdk.admin.postmark.reminderSchedules.retrieve(id, query), 47 | queryKey: postmarkReminderSchedulesQueryKeys.schedule(id, query), 48 | placeholderData: keepPreviousData, 49 | ...options, 50 | }) 51 | 52 | return { ...data, ...rest } 53 | } 54 | 55 | export const useCreateReminderSchedules = ( 56 | options?: UseMutationOptions< 57 | ReminderScheduleResponse, 58 | FetchError, 59 | CreateReminderScheduleRequest 60 | > 61 | ) => { 62 | const queryClient = useQueryClient() 63 | 64 | return useMutation({ 65 | mutationFn: async (payload: CreateReminderScheduleRequest) => sdk.admin.postmark.reminderSchedules.create(payload), 66 | onSuccess: async (data, variables, context) => { 67 | await queryClient.invalidateQueries({ 68 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(), 69 | }) 70 | options?.onSuccess?.(data, variables, context) 71 | }, 72 | ...options, 73 | }) 74 | } 75 | 76 | export const useUpdateReminderSchedules = ( 77 | id: string, 78 | options?: UseMutationOptions< 79 | ReminderScheduleResponse, 80 | FetchError, 81 | UpdateReminderScheduleRequest 82 | > 83 | ) => { 84 | const queryClient = useQueryClient() 85 | 86 | return useMutation({ 87 | mutationFn: async (payload: UpdateReminderScheduleRequest) => sdk.admin.postmark.reminderSchedules.update(id, payload), 88 | onSuccess: async (data, variables, context) => { 89 | await queryClient.invalidateQueries({ 90 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(), 91 | }) 92 | options?.onSuccess?.(data, variables, context) 93 | }, 94 | ...options, 95 | }) 96 | } 97 | 98 | export const useDeleteReminderSchedules = ( 99 | options?: UseMutationOptions< 100 | { success: boolean }, 101 | FetchError, 102 | string 103 | > 104 | ) => { 105 | const queryClient = useQueryClient() 106 | 107 | return useMutation({ 108 | mutationFn: async (id: string) => sdk.admin.postmark.reminderSchedules.delete(id), 109 | onSuccess: async (data, variables, context) => { 110 | await queryClient.invalidateQueries({ 111 | queryKey: postmarkReminderSchedulesQueryKeys.schedules(), 112 | }) 113 | options?.onSuccess?.(data, variables, context) 114 | }, 115 | ...options, 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /src/modules/abandoned-cart/migrations/.snapshot-medusa-postmark-abandoned-cart.json: -------------------------------------------------------------------------------- 1 | { 2 | "namespaces": [ 3 | "public" 4 | ], 5 | "name": "public", 6 | "tables": [ 7 | { 8 | "columns": { 9 | "id": { 10 | "name": "id", 11 | "type": "text", 12 | "unsigned": false, 13 | "autoincrement": false, 14 | "primary": false, 15 | "nullable": false, 16 | "mappedType": "text" 17 | }, 18 | "enabled": { 19 | "name": "enabled", 20 | "type": "boolean", 21 | "unsigned": false, 22 | "autoincrement": false, 23 | "primary": false, 24 | "nullable": false, 25 | "mappedType": "boolean" 26 | }, 27 | "template_id": { 28 | "name": "template_id", 29 | "type": "text", 30 | "unsigned": false, 31 | "autoincrement": false, 32 | "primary": false, 33 | "nullable": false, 34 | "mappedType": "text" 35 | }, 36 | "delays_iso": { 37 | "name": "delays_iso", 38 | "type": "text[]", 39 | "unsigned": false, 40 | "autoincrement": false, 41 | "primary": false, 42 | "nullable": false, 43 | "mappedType": "array" 44 | }, 45 | "notify_existing": { 46 | "name": "notify_existing", 47 | "type": "boolean", 48 | "unsigned": false, 49 | "autoincrement": false, 50 | "primary": false, 51 | "nullable": false, 52 | "default": "false", 53 | "mappedType": "boolean" 54 | }, 55 | "reset_on_cart_update": { 56 | "name": "reset_on_cart_update", 57 | "type": "boolean", 58 | "unsigned": false, 59 | "autoincrement": false, 60 | "primary": false, 61 | "nullable": false, 62 | "default": "true", 63 | "mappedType": "boolean" 64 | }, 65 | "created_at": { 66 | "name": "created_at", 67 | "type": "timestamptz", 68 | "unsigned": false, 69 | "autoincrement": false, 70 | "primary": false, 71 | "nullable": false, 72 | "length": 6, 73 | "default": "now()", 74 | "mappedType": "datetime" 75 | }, 76 | "updated_at": { 77 | "name": "updated_at", 78 | "type": "timestamptz", 79 | "unsigned": false, 80 | "autoincrement": false, 81 | "primary": false, 82 | "nullable": false, 83 | "length": 6, 84 | "default": "now()", 85 | "mappedType": "datetime" 86 | }, 87 | "deleted_at": { 88 | "name": "deleted_at", 89 | "type": "timestamptz", 90 | "unsigned": false, 91 | "autoincrement": false, 92 | "primary": false, 93 | "nullable": true, 94 | "length": 6, 95 | "mappedType": "datetime" 96 | } 97 | }, 98 | "name": "reminder_schedule", 99 | "schema": "public", 100 | "indexes": [ 101 | { 102 | "keyName": "IDX_reminder_schedule_template_id_unique", 103 | "columnNames": [], 104 | "composite": false, 105 | "constraint": false, 106 | "primary": false, 107 | "unique": false, 108 | "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_reminder_schedule_template_id_unique\" ON \"reminder_schedule\" (template_id) WHERE deleted_at IS NULL" 109 | }, 110 | { 111 | "keyName": "IDX_reminder_schedule_deleted_at", 112 | "columnNames": [], 113 | "composite": false, 114 | "constraint": false, 115 | "primary": false, 116 | "unique": false, 117 | "expression": "CREATE INDEX IF NOT EXISTS \"IDX_reminder_schedule_deleted_at\" ON \"reminder_schedule\" (deleted_at) WHERE deleted_at IS NULL" 118 | }, 119 | { 120 | "keyName": "reminder_schedule_pkey", 121 | "columnNames": [ 122 | "id" 123 | ], 124 | "composite": false, 125 | "constraint": true, 126 | "primary": true, 127 | "unique": true 128 | } 129 | ], 130 | "checks": [], 131 | "foreignKeys": {}, 132 | "nativeEnums": {} 133 | } 134 | ], 135 | "nativeEnums": {} 136 | } 137 | -------------------------------------------------------------------------------- /src/admin/hooks/use-postmark-table.tsx: -------------------------------------------------------------------------------- 1 | import { DataTablePaginationState, DataTableSortingState, toast, useDataTable, createDataTableColumnHelper } from "@medusajs/ui" 2 | import { useState, useMemo, useCallback } from "react" 3 | import { sdk } from "../lib/sdk" 4 | import { Trash } from "@medusajs/icons" 5 | import { PostmarkTemplate } from "../../types/templates" 6 | import { useTranslation } from "react-i18next" 7 | import { usePostmarkTemplates } from "./use-postmark-templates" 8 | import { keepPreviousData } from "@tanstack/react-query" 9 | 10 | type UsePostmarkDataTableProps = { 11 | type: "template" | "layout" 12 | serverId?: string 13 | } 14 | 15 | export const usePostmarkDataTable = ({ type, serverId }: UsePostmarkDataTableProps) => { 16 | const [pagination, setPagination] = useState({ 17 | pageSize: limit, 18 | pageIndex: 0, 19 | }) 20 | const [search, setSearch] = useState("") 21 | const [sorting, setSorting] = useState(null) 22 | 23 | const offset = useMemo(() => { 24 | return pagination.pageIndex * limit 25 | }, [pagination]) 26 | 27 | const { t } = useTranslation("postmark") 28 | 29 | const { Templates, TotalCount, isPending, refetch } = usePostmarkTemplates({ 30 | templateType: type === "template" ? "Standard" : "Layout", 31 | limit: limit, 32 | offset: offset, 33 | q: search, 34 | order: sorting ? `${sorting.desc ? "-" : ""}${sorting.id}` : undefined, 35 | }, { 36 | placeholderData: keepPreviousData, 37 | }) 38 | 39 | const handleDeleteTemplate = async (templateId: number) => { 40 | try { 41 | await sdk.client.fetch( 42 | `/admin/postmark/templates/${templateId}`, 43 | { 44 | method: "DELETE", 45 | headers: { 46 | "Accept": "application/json", 47 | }, 48 | } 49 | ) 50 | 51 | toast.success("Template deleted successfully") 52 | // Refresh the data 53 | refetch() 54 | } catch (error) { 55 | toast.error("Failed to delete template", { 56 | description: error instanceof Error ? error.message : "An unexpected error occurred", 57 | }) 58 | } 59 | } 60 | 61 | const columns = useMemo(() => [ 62 | columnHelper.accessor("Name", { header: t("fields.name"), enableSorting: true }), 63 | ...(type === "template" 64 | ? [columnHelper.accessor("LayoutTemplate", { header: t("fields.layout"), enableSorting: true })] 65 | : [] 66 | ), 67 | columnHelper.action({ 68 | actions: [ 69 | { 70 | label: t("actions.delete"), 71 | icon: , 72 | onClick: async (ctx) => { 73 | await handleDeleteTemplate(ctx.row.original.TemplateId) 74 | }, 75 | }, 76 | ] 77 | }), 78 | ], [t, handleDeleteTemplate]) 79 | 80 | 81 | const onRowClick = useCallback( 82 | (_: unknown, row: any) => { 83 | if (!serverId) { 84 | return 85 | } 86 | const editUrl = `https://account.postmarkapp.com/servers/${serverId}/templates/${row.id}/edit` 87 | // Always open in a new tab regardless of modifier keys 88 | window.open(editUrl, "_blank", "noopener noreferrer") 89 | }, 90 | [serverId] 91 | ) 92 | 93 | const table = useDataTable({ 94 | columns, 95 | data: Templates || [], 96 | getRowId: (row) => row.TemplateId.toString(), 97 | rowCount: TotalCount || 0, 98 | isLoading: isPending, 99 | onRowClick, 100 | pagination: { 101 | state: pagination, 102 | onPaginationChange: setPagination, 103 | }, 104 | search: { 105 | state: search, 106 | onSearchChange: setSearch, 107 | }, 108 | sorting: { 109 | state: sorting, 110 | onSortingChange: setSorting, 111 | }, 112 | }) 113 | 114 | return { 115 | table, 116 | refetch, 117 | } 118 | } 119 | 120 | const limit = 15 121 | const columnHelper = createDataTableColumnHelper() 122 | 123 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/validate-templates-section.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "../../../components/general/container" 2 | import { Header } from "../../../components/general/header" 3 | import { useMutation } from "@tanstack/react-query" 4 | import { Badge, Text, Tooltip, toast } from "@medusajs/ui" 5 | import { MedusaError } from "@medusajs/framework/utils" 6 | import { sdk } from "../../../lib/sdk" 7 | import { useTranslation } from "react-i18next" 8 | import _ from "lodash" 9 | 10 | export const ValidateTemplatesSection = () => { 11 | const { t } = useTranslation("postmark") 12 | const { mutateAsync, isPending, data } = useMutation({ 13 | mutationFn: async () => { 14 | return await sdk.admin.postmark.reminderSchedules.validate() 15 | }, 16 | onError: (err: MedusaError) => { 17 | toast.error(t("validate_schedules.error_title"), { description: err.message }) 18 | } 19 | }) 20 | 21 | return ( 22 | 23 |
30 | 31 | {t("validate_schedules.all_valid")} 32 | 33 | 34 | ) : ( 35 | 36 | 37 | {t("validate_schedules.missing_data", { count: data?.results?.length })} 38 | 39 | 40 | ) 41 | }] : []), 42 | { 43 | type: "button", 44 | props: { 45 | onClick: () => mutateAsync(), 46 | disabled: isPending, 47 | children: isPending ? t("validate_schedules.validating") : t("validate_schedules.validate_button"), 48 | variant: "secondary" 49 | } 50 | } 51 | ]} 52 | /> 53 | {data?.results && data.results.length > 0 && ( 54 |
55 | {data.results.map((result) => ( 56 |
57 |
58 | {result.templateName || t("validate_schedules.unknown_template")} 59 | {result.templateId && ( 60 | 61 | ID: {result.templateId} 62 | 63 | )} 64 |
65 | 66 | {result.missingVariables && Object.keys(result.missingVariables).length > 0 && ( 67 |
68 | 69 | {t("validate_schedules.missing_variables")}: 70 | 71 |
72 | {renderMissingVariables(result.missingVariables)} 73 |
74 |
75 | )} 76 | 77 | {result.providedData && ( 78 |
79 | 80 | {t("validate_schedules.view_provided_data")} 81 | 82 |
 83 |                     {JSON.stringify(result.providedData, null, 2)}
 84 |                   
85 |
86 | )} 87 |
88 | ))} 89 |
90 | )} 91 | 92 | ) 93 | } 94 | 95 | // Helper function to render missing variables object as a nested structure 96 | const renderMissingVariables = (obj: Record, prefix = ""): JSX.Element[] => { 97 | const elements: JSX.Element[] = [] 98 | for (const [key, value] of Object.entries(obj)) { 99 | const fullKey = prefix ? `${prefix}.${key}` : key 100 | 101 | if (value === null) { 102 | // Primitive missing value 103 | elements.push( 104 | 105 | {fullKey} 106 | 107 | ) 108 | } else if (Array.isArray(value)) { 109 | // Array with missing nested values 110 | elements.push( 111 | 112 | {fullKey}[0] 113 | 114 | ) 115 | if (value.length > 0 && typeof value[0] === 'object') { 116 | elements.push(...renderMissingVariables(value[0], `${fullKey}[0]`)) 117 | } 118 | } else if (typeof value === 'object') { 119 | // Nested object with missing values 120 | elements.push(...renderMissingVariables(value, fullKey)) 121 | } 122 | } 123 | 124 | return elements 125 | } 126 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/reminder-schedules-table.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | createDataTableColumnHelper, 4 | DataTable, 5 | Heading, 6 | toast, 7 | useDataTable, 8 | usePrompt, 9 | } from "@medusajs/ui" 10 | import { useMemo } from "react" 11 | import { useNavigate } from "react-router-dom" 12 | import { PencilSquare, Trash } from "@medusajs/icons" 13 | import { useTranslation } from "react-i18next" 14 | import { DataTableAction } from "../../../components/data-table-action" 15 | import { ReminderSchedule } from "../../../../types/reminder-schedules" 16 | import { useDeleteReminderSchedules, useReminderSchedules } from "../../../hooks/use-reminder-schedules" 17 | import { Container } from "../../../components/general/container" 18 | import { DurationFormat } from "@formatjs/intl-durationformat" 19 | import { Temporal } from "temporal-polyfill" 20 | 21 | const ReminderSchedulesTable = () => { 22 | const { t } = useTranslation("postmark") 23 | 24 | const { schedules = [], isPending } = useReminderSchedules({ fields: "+template.*" }) 25 | const columns = useColumns() 26 | 27 | const table = useDataTable({ 28 | columns, 29 | data: schedules, 30 | getRowId: (row) => row.id, 31 | rowCount: schedules?.length || 0, 32 | isLoading: isPending, 33 | }) 34 | 35 | return ( 36 | 37 | 38 | 39 | {t("reminder_schedules.title")} 40 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export default ReminderSchedulesTable 52 | 53 | const columnHelper = createDataTableColumnHelper() 54 | 55 | const useColumns = () => { 56 | const { t, i18n: { language } } = useTranslation("postmark") 57 | const formatter = useMemo(() => { 58 | return new DurationFormat(language, { style: 'long' }) 59 | }, [language]) 60 | const navigate = useNavigate() 61 | const prompt = usePrompt() 62 | 63 | const { mutateAsync } = useDeleteReminderSchedules() 64 | 65 | const handleDelete = async (schedule: ReminderSchedule) => { 66 | const res = await prompt({ 67 | title: t("general.areYouSure"), 68 | description: t("reminder_schedules.delete.confirmation"), 69 | confirmText: t("actions.delete"), 70 | cancelText: t("actions.cancel"), 71 | }) 72 | 73 | if (!res) { 74 | return 75 | } 76 | 77 | await mutateAsync(schedule.id, { 78 | onSuccess: () => { 79 | toast.success( 80 | t("reminder_schedules.delete.successToast") 81 | ) 82 | }, 83 | onError: (e) => { 84 | toast.error(e.message) 85 | }, 86 | }) 87 | } 88 | 89 | const columns = useMemo( 90 | () => [ 91 | columnHelper.accessor("template.Name", { 92 | header: t("fields.template"), 93 | }), 94 | columnHelper.accessor("delays_iso", { 95 | header: t("reminder_schedules.delays"), 96 | cell: ({ getValue }) => { 97 | const delays_iso = getValue() 98 | if (!delays_iso || delays_iso.length === 0) return '-' 99 | 100 | return ( 101 |
102 | {delays_iso.sort((a, b) => Temporal.Duration.compare(a, b, { relativeTo: Temporal.Now.plainDateTimeISO() })).map((duration, index) => ( 103 | 104 | {formatter.format(Temporal.Duration.from(duration))} 105 | 106 | ))} 107 |
108 | ) 109 | } 110 | }), 111 | columnHelper.accessor("enabled", { 112 | header: t("fields.status"), 113 | cell: ({ getValue }) => { 114 | const enabled = getValue() 115 | return ( 116 | 117 | {enabled ? t("statuses.enabled") : t("statuses.disabled")} 118 | 119 | ) 120 | }, 121 | }), 122 | columnHelper.accessor("notify_existing", { 123 | header: t("reminder_schedules.notify_existing"), 124 | cell: ({ getValue }) => { 125 | const notifyExisting = getValue() 126 | return ( 127 | 128 | {notifyExisting ? t("filters.radio.yes") : t("filters.radio.no")} 129 | 130 | ) 131 | }, 132 | }), 133 | columnHelper.accessor("reset_on_cart_update", { 134 | header: t("reminder_schedules.reset_on_cart_update"), 135 | cell: ({ getValue }) => { 136 | const resetOnUpdate = getValue() 137 | return ( 138 | 139 | {resetOnUpdate ? t("filters.radio.yes") : t("filters.radio.no")} 140 | 141 | ) 142 | }, 143 | }), 144 | columnHelper.action({ 145 | actions: [ 146 | { 147 | label: t("actions.edit"), 148 | icon: , 149 | onClick: (ctx) => { 150 | navigate(`edit/${ctx.row.original.id}`) 151 | }, 152 | }, 153 | { 154 | label: t("actions.delete"), 155 | icon: , 156 | onClick: async (ctx) => { 157 | await handleDelete(ctx.row.original) 158 | }, 159 | }, 160 | ], 161 | }), 162 | ], 163 | [t, navigate] 164 | ) 165 | return columns 166 | } 167 | -------------------------------------------------------------------------------- /src/admin/components/general/duration-input.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input, Label, Select } from "@medusajs/ui" 2 | import { Plus, Trash } from "@medusajs/icons" 3 | import { useTranslation } from "react-i18next" 4 | import { forwardRef, useMemo, useCallback } from "react" 5 | import { Temporal } from "temporal-polyfill" 6 | import { DurationFormat } from "@formatjs/intl-durationformat" 7 | 8 | interface DurationInputProps { 9 | value?: string[] 10 | onChange?: (value: string[]) => void 11 | } 12 | 13 | const UNITS = ["months", "weeks", "days", "hours", "minutes"] as const 14 | 15 | export const DurationInput = forwardRef( 16 | ({ value = [], onChange }, ref) => { 17 | const { t, i18n: { language = "en" } } = useTranslation("postmark") 18 | const formatter = useMemo(() => new DurationFormat(language, { style: "long" }), [language]) 19 | 20 | const durations = useMemo(() => 21 | value.map(iso => { try { return Temporal.Duration.from(iso) } catch { return null } }) 22 | .filter((d): d is Temporal.Duration => d !== null), 23 | [value] 24 | ) 25 | 26 | const formatUnit = useCallback((unit: string, val: number) => { 27 | try { 28 | return formatter.format(Temporal.Duration.from({ [unit]: val })) 29 | .replace(/[\d\s]/g, "").trim() 30 | } catch { 31 | return unit 32 | } 33 | }, [formatter]) 34 | 35 | const update = useCallback((updated: Temporal.Duration[]) => 36 | onChange?.(updated.map(d => d.toString())), [onChange]) 37 | 38 | const set = useCallback((i: number, unit: string, val: number) => { 39 | const updated = [...durations] 40 | updated[i] = Temporal.Duration.from({ [unit]: val }) 41 | update(updated) 42 | }, [durations, update]) 43 | 44 | const getUnitAndValue = useCallback((d: Temporal.Duration) => { 45 | for (const unit of UNITS) { 46 | if (d[unit]) return { unit, value: d[unit] } 47 | } 48 | return { unit: "hours", value: 1 } 49 | }, []) 50 | 51 | return ( 52 |
53 | {durations.length > 0 && ( 54 |
55 |
56 | 57 | 58 |
59 | 62 |
63 | )} 64 | 65 | {durations.map((duration, i) => { 66 | const { unit, value: val } = getUnitAndValue(duration) 67 | 68 | return ( 69 |
70 | {i > 0 &&
} 71 |
72 |
73 | { 78 | const num = parseInt(e.target.value) 79 | if (!isNaN(num) && num > 0) set(i, unit, num) 80 | }} 81 | /> 82 | 92 |
93 | 102 |
103 |
104 | ) 105 | })} 106 | 107 | 117 |
118 | ) 119 | } 120 | ) 121 | 122 | DurationInput.displayName = "DurationInput" 123 | -------------------------------------------------------------------------------- /src/admin/components/form/form.tsx: -------------------------------------------------------------------------------- 1 | import { InformationCircleSolid } from "@medusajs/icons" 2 | import { 3 | Hint as HintComponent, 4 | Label as LabelComponent, 5 | Text, 6 | Tooltip, 7 | clx, 8 | } from "@medusajs/ui" 9 | import { Label as RadixLabel, Slot } from "radix-ui" 10 | import React, { 11 | ReactNode, 12 | createContext, 13 | forwardRef, 14 | useContext, 15 | useId, 16 | } from "react" 17 | import { 18 | Controller, 19 | ControllerProps, 20 | FieldPath, 21 | FieldValues, 22 | FormProvider, 23 | useFormContext, 24 | useFormState, 25 | } from "react-hook-form" 26 | import { useTranslation } from "react-i18next" 27 | 28 | const Provider = FormProvider 29 | 30 | type FormFieldContextValue< 31 | TFieldValues extends FieldValues = FieldValues, 32 | TName extends FieldPath = FieldPath 33 | > = { 34 | name: TName 35 | } 36 | 37 | const FormFieldContext = createContext( 38 | {} as FormFieldContextValue 39 | ) 40 | 41 | const Field = < 42 | TFieldValues extends FieldValues = FieldValues, 43 | TName extends FieldPath = FieldPath 44 | >({ 45 | ...props 46 | }: ControllerProps) => { 47 | return ( 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | type FormItemContextValue = { 55 | id: string 56 | } 57 | 58 | const FormItemContext = createContext( 59 | {} as FormItemContextValue 60 | ) 61 | 62 | const useFormField = () => { 63 | const fieldContext = useContext(FormFieldContext) 64 | const itemContext = useContext(FormItemContext) 65 | const { getFieldState } = useFormContext() 66 | 67 | const formState = useFormState({ name: fieldContext.name }) 68 | const fieldState = getFieldState(fieldContext.name, formState) 69 | 70 | if (!fieldContext) { 71 | throw new Error("useFormField should be used within a FormField") 72 | } 73 | 74 | const { id } = itemContext 75 | 76 | return { 77 | id, 78 | name: fieldContext.name, 79 | formItemId: `${id}-form-item`, 80 | formLabelId: `${id}-form-item-label`, 81 | formDescriptionId: `${id}-form-item-description`, 82 | formErrorMessageId: `${id}-form-item-message`, 83 | ...fieldState, 84 | } 85 | } 86 | 87 | const Item = forwardRef>( 88 | ({ className, ...props }, ref) => { 89 | const id = useId() 90 | 91 | return ( 92 | 93 |
98 | 99 | ) 100 | } 101 | ) 102 | Item.displayName = "Form.Item" 103 | 104 | const Label = forwardRef< 105 | React.ElementRef, 106 | React.ComponentPropsWithoutRef & { 107 | optional?: boolean 108 | tooltip?: ReactNode 109 | icon?: ReactNode 110 | } 111 | >(({ className, optional = false, tooltip, icon, ...props }, ref) => { 112 | const { formLabelId, formItemId } = useFormField() 113 | const { t } = useTranslation() 114 | 115 | return ( 116 |
117 | 126 | {tooltip && ( 127 | 128 | 129 | 130 | )} 131 | {icon} 132 | {optional && ( 133 | 134 | ({t("fields.optional")}) 135 | 136 | )} 137 |
138 | ) 139 | }) 140 | Label.displayName = "Form.Label" 141 | 142 | const Control = forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef 145 | >(({ ...props }, ref) => { 146 | const { 147 | error, 148 | formItemId, 149 | formDescriptionId, 150 | formErrorMessageId, 151 | formLabelId, 152 | } = useFormField() 153 | 154 | return ( 155 | 167 | ) 168 | }) 169 | Control.displayName = "Form.Control" 170 | 171 | const Hint = forwardRef< 172 | HTMLParagraphElement, 173 | React.HTMLAttributes 174 | >(({ className, ...props }, ref) => { 175 | const { formDescriptionId } = useFormField() 176 | 177 | return ( 178 | 184 | ) 185 | }) 186 | Hint.displayName = "Form.Hint" 187 | 188 | const ErrorMessage = forwardRef< 189 | HTMLParagraphElement, 190 | React.HTMLAttributes 191 | >(({ className, children, ...props }, ref) => { 192 | const { error, formErrorMessageId } = useFormField() 193 | const msg = error ? String(error?.message) : children 194 | 195 | if (!msg || msg === "undefined") { 196 | return null 197 | } 198 | 199 | return ( 200 | 207 | {msg} 208 | 209 | ) 210 | }) 211 | ErrorMessage.displayName = "Form.ErrorMessage" 212 | 213 | const Form = Object.assign(Provider, { 214 | Item, 215 | Label, 216 | Control, 217 | Hint, 218 | ErrorMessage, 219 | Field, 220 | }) 221 | 222 | export { Form } 223 | -------------------------------------------------------------------------------- /src/admin/i18n/$schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "additionalProperties": false, 5 | "required": [ 6 | "$schema", 7 | "templates", 8 | "layouts", 9 | "reminder_schedules", 10 | "validate_schedules", 11 | "fields" 12 | ], 13 | "properties": { 14 | "$schema": { 15 | "type": "string" 16 | }, 17 | "templates": { 18 | "type": "object", 19 | "additionalProperties": false, 20 | "required": [ 21 | "title" 22 | ], 23 | "properties": { 24 | "title": { 25 | "type": "string" 26 | } 27 | } 28 | }, 29 | "layouts": { 30 | "type": "object", 31 | "additionalProperties": false, 32 | "required": [ 33 | "title" 34 | ], 35 | "properties": { 36 | "title": { 37 | "type": "string" 38 | } 39 | } 40 | }, 41 | "reminder_schedules": { 42 | "type": "object", 43 | "additionalProperties": false, 44 | "required": [ 45 | "title", 46 | "delays", 47 | "notify_existing", 48 | "notify_existing_hint", 49 | "reset_on_cart_update", 50 | "reset_on_cart_update_hint", 51 | "notify_existing_warning_title", 52 | "notify_existing_warning_desc", 53 | "create_title", 54 | "create_description", 55 | "edit_title", 56 | "edit_description", 57 | "delete" 58 | ], 59 | "properties": { 60 | "title": { 61 | "type": "string" 62 | }, 63 | "delays": { 64 | "type": "string" 65 | }, 66 | "notify_existing": { 67 | "type": "string" 68 | }, 69 | "notify_existing_hint": { 70 | "type": "string" 71 | }, 72 | "reset_on_cart_update": { 73 | "type": "string" 74 | }, 75 | "reset_on_cart_update_hint": { 76 | "type": "string" 77 | }, 78 | "notify_existing_warning_title": { 79 | "type": "string" 80 | }, 81 | "notify_existing_warning_desc": { 82 | "type": "string" 83 | }, 84 | "create_title": { 85 | "type": "string" 86 | }, 87 | "create_description": { 88 | "type": "string" 89 | }, 90 | "edit_title": { 91 | "type": "string" 92 | }, 93 | "edit_description": { 94 | "type": "string" 95 | }, 96 | "delete": { 97 | "type": "object", 98 | "additionalProperties": false, 99 | "required": [ 100 | "confirmation", 101 | "successToast" 102 | ], 103 | "properties": { 104 | "confirmation": { 105 | "type": "string" 106 | }, 107 | "successToast": { 108 | "type": "string" 109 | } 110 | } 111 | } 112 | } 113 | }, 114 | "validate_schedules": { 115 | "type": "object", 116 | "additionalProperties": false, 117 | "required": [ 118 | "title", 119 | "error_title", 120 | "all_valid", 121 | "missing_data", 122 | "validating", 123 | "validate_button", 124 | "unknown_template", 125 | "missing_variables", 126 | "view_provided_data" 127 | ], 128 | "properties": { 129 | "title": { 130 | "type": "string" 131 | }, 132 | "error_title": { 133 | "type": "string" 134 | }, 135 | "all_valid": { 136 | "type": "string" 137 | }, 138 | "missing_data": { 139 | "type": "string" 140 | }, 141 | "validating": { 142 | "type": "string" 143 | }, 144 | "validate_button": { 145 | "type": "string" 146 | }, 147 | "unknown_template": { 148 | "type": "string" 149 | }, 150 | "missing_variables": { 151 | "type": "string" 152 | }, 153 | "view_provided_data": { 154 | "type": "string" 155 | } 156 | } 157 | }, 158 | "fields": { 159 | "type": "object", 160 | "additionalProperties": false, 161 | "required": [ 162 | "unit", 163 | "template", 164 | "layout" 165 | ], 166 | "properties": { 167 | "unit": { 168 | "type": "string" 169 | }, 170 | "template": { 171 | "type": "string" 172 | }, 173 | "layout": { 174 | "type": "string" 175 | } 176 | } 177 | }, 178 | "menuLabels": { 179 | "type": "object", 180 | "additionalProperties": false, 181 | "required": [ 182 | "abandoned_carts", 183 | "postmark_templates" 184 | ], 185 | "properties": { 186 | "abandoned_carts": { 187 | "type": "string" 188 | }, 189 | "postmark_templates": { 190 | "type": "string" 191 | } 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/admin/components/general/chip-input.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkMini } from "@medusajs/icons" 2 | import { Badge, clx } from "@medusajs/ui" 3 | import { 4 | FocusEvent, 5 | KeyboardEvent, 6 | forwardRef, 7 | useImperativeHandle, 8 | useRef, 9 | useState, 10 | } from "react" 11 | 12 | type ChipInputProps = { 13 | value?: string[] 14 | onChange?: (value: string[]) => void 15 | onBlur?: () => void 16 | name?: string 17 | disabled?: boolean 18 | allowDuplicates?: boolean 19 | showRemove?: boolean 20 | variant?: "base" | "contrast" 21 | placeholder?: string 22 | className?: string 23 | } 24 | 25 | export const ChipInput = forwardRef( 26 | ( 27 | { 28 | value, 29 | onChange, 30 | onBlur, 31 | disabled, 32 | name, 33 | showRemove = true, 34 | variant = "base", 35 | allowDuplicates = false, 36 | placeholder, 37 | className, 38 | }, 39 | ref 40 | ) => { 41 | const innerRef = useRef(null) 42 | 43 | const isControlledRef = useRef(typeof value !== "undefined") 44 | const isControlled = isControlledRef.current 45 | 46 | const [uncontrolledValue, setUncontrolledValue] = useState([]) 47 | 48 | useImperativeHandle( 49 | ref, 50 | () => innerRef.current 51 | ) 52 | 53 | const [duplicateIndex, setDuplicateIndex] = useState(null) 54 | 55 | const chips = isControlled ? (value as string[]) : uncontrolledValue 56 | 57 | // Sort chips by numerical value 58 | const sortedChips = [...chips].sort((a, b) => { 59 | const numA = parseFloat(a) || 0 60 | const numB = parseFloat(b) || 0 61 | return numA - numB 62 | }) 63 | 64 | const handleAddChip = (chip: string) => { 65 | const cleanValue = chip.trim() 66 | 67 | if (!cleanValue) { 68 | return 69 | } 70 | 71 | if (!allowDuplicates && sortedChips.includes(cleanValue)) { 72 | setDuplicateIndex(sortedChips.indexOf(cleanValue)) 73 | 74 | setTimeout(() => { 75 | setDuplicateIndex(null) 76 | }, 300) 77 | 78 | return 79 | } 80 | 81 | const newChips = [...chips, cleanValue] 82 | const sortedNewChips = newChips.sort((a, b) => { 83 | const numA = parseFloat(a) || 0 84 | const numB = parseFloat(b) || 0 85 | return numA - numB 86 | }) 87 | 88 | onChange?.(sortedNewChips) 89 | 90 | if (!isControlled) { 91 | setUncontrolledValue(sortedNewChips) 92 | } 93 | } 94 | 95 | const handleRemoveChip = (chip: string) => { 96 | const filteredChips = chips.filter((v) => v !== chip) 97 | const sortedFilteredChips = filteredChips.sort((a, b) => { 98 | const numA = parseFloat(a) || 0 99 | const numB = parseFloat(b) || 0 100 | return numA - numB 101 | }) 102 | 103 | onChange?.(sortedFilteredChips) 104 | 105 | if (!isControlled) { 106 | setUncontrolledValue(sortedFilteredChips) 107 | } 108 | } 109 | 110 | const handleBlur = (e: FocusEvent) => { 111 | onBlur?.() 112 | 113 | if (e.target.value) { 114 | handleAddChip(e.target.value) 115 | e.target.value = "" 116 | } 117 | } 118 | 119 | const handleKeyDown = (e: KeyboardEvent) => { 120 | if (e.key === "Enter" || e.key === ",") { 121 | e.preventDefault() 122 | 123 | if (!innerRef.current?.value) { 124 | return 125 | } 126 | 127 | handleAddChip(innerRef.current?.value ?? "") 128 | innerRef.current.value = "" 129 | innerRef.current?.focus() 130 | } 131 | 132 | if (e.key === "Backspace" && innerRef.current?.value === "") { 133 | handleRemoveChip(sortedChips[sortedChips.length - 1]) 134 | } 135 | } 136 | 137 | return ( 138 |
innerRef.current?.focus()} 152 | > 153 | {sortedChips.map((v, index) => { 154 | return ( 155 | 163 | {v} 164 | {showRemove && ( 165 | 175 | )} 176 | 177 | ) 178 | })} 179 | 194 |
195 | ) 196 | } 197 | ) 198 | 199 | ChipInput.displayName = "ChipInput" 200 | -------------------------------------------------------------------------------- /src/api/admin/postmark/abandoned-carts/reminders/validate/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" 2 | import { notificationDataWorkflow } from "../../../../../../workflows/notification-data" 3 | import { ABANDONED_CART_MODULE } from "../../../../../../modules/abandoned-cart" 4 | import { ValidationResponse, ValidationResult } from "../../../../../../types/validation" 5 | import { MedusaError } from "@medusajs/framework/utils" 6 | import { Temporal } from "temporal-polyfill" 7 | import { CartDTO, CustomerDTO } from "@medusajs/framework/types" 8 | 9 | export async function POST(req: MedusaRequest, res: MedusaResponse) { 10 | const logger = req.scope.resolve("logger") 11 | const abandonedCartModuleService = req.scope.resolve(ABANDONED_CART_MODULE) 12 | const postmarkModuleService = req.scope.resolve("postmarkModuleService") 13 | 14 | try { 15 | // Get all reminder schedules 16 | const schedules = await abandonedCartModuleService.listReminderSchedules() 17 | 18 | if (!schedules?.length) { 19 | return res.json({ 20 | success: true, 21 | message: "No reminder schedules found to validate", 22 | results: [] 23 | }) 24 | } 25 | 26 | const { Templates: templates } = await postmarkModuleService.getTemplates({ count: 500 }) 27 | 28 | // Create mock notification data for each schedule 29 | const carts = [{ 30 | cart: mockCart, 31 | reminders: schedules.flatMap((schedule) => ( 32 | { 33 | delay: Temporal.Duration.from(schedule.delays_iso[0]), 34 | delayIso: schedule.delays_iso[0], 35 | template: schedule.template_id, 36 | schedule 37 | } 38 | ) 39 | ) 40 | }] 41 | 42 | // Run the notification data workflow with mock data 43 | const { result } = await notificationDataWorkflow(req.scope).run({ 44 | input: { carts } 45 | }) 46 | 47 | const notificationData = result.notificationData 48 | 49 | // Validate each notification against its template 50 | const validationResults: ValidationResult[] = [] 51 | 52 | for (const notification of notificationData) { 53 | const template = templates.find( 54 | (t) => t.Alias === notification.template || t.TemplateId.toString() === notification.template 55 | ) 56 | 57 | if (!template) { 58 | throw new MedusaError(MedusaError.Types.INVALID_DATA, `Template with ID or Alias '${notification.template}' not found in Postmark`) 59 | } 60 | 61 | // Use Postmark's validation endpoint 62 | // Pass an empty object to get ALL required variables in SuggestedTemplateModel 63 | const templateDetails = await postmarkModuleService.getTemplate(template.TemplateId) 64 | const { SuggestedTemplateModel } = await postmarkModuleService.validateTemplate({ 65 | Subject: templateDetails.Subject || "", 66 | HtmlBody: templateDetails.HtmlBody || "", 67 | TextBody: templateDetails.TextBody || "", 68 | TestRenderModel: {} 69 | }) 70 | 71 | const missingVariables = filterMissing(SuggestedTemplateModel, notification.data) 72 | 73 | if (Object.keys(missingVariables).length > 0) 74 | validationResults.push({ 75 | templateId: template.TemplateId.toString(), 76 | templateName: template.Name, 77 | missingVariables, 78 | providedData: notification.data, 79 | }) 80 | } 81 | 82 | const allValid = validationResults.length === 0 83 | 84 | res.json({ 85 | success: allValid, 86 | message: allValid 87 | ? "All templates have the required data from the notification workflow" 88 | : "Some templates are missing required data", 89 | results: validationResults, 90 | }) 91 | 92 | } catch (error) { 93 | logger.error("medusa-plugin-postmark: Error validating templates:", error) 94 | throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `Failed to validate templates: ${error instanceof Error ? error.message : "Unknown error"}`) 95 | } 96 | } 97 | 98 | /** 99 | * Recursively compares a suggested template model with provided data 100 | * Returns only the keys that are missing or have missing nested values 101 | */ 102 | const filterMissing = (suggested: Record, provided: Record | undefined | null): Record => { 103 | const missing: Record = {} 104 | 105 | // If provided data is not an object, all suggested keys are missing 106 | if (!provided || typeof provided !== 'object' || Array.isArray(provided)) { 107 | const result = Object.keys(suggested).reduce((acc, key) => ({ ...acc, [key]: null }), {}) 108 | return result 109 | } 110 | 111 | for (const key in suggested) { 112 | const suggestedVal = suggested[key] 113 | const providedVal = provided[key] 114 | // Check if the key exists in provided data (undefined or null means missing) 115 | const keyIsMissing = providedVal === undefined || providedVal === null 116 | 117 | if (Array.isArray(suggestedVal)) { 118 | // Handle arrays 119 | if (keyIsMissing || !Array.isArray(providedVal) || providedVal.length === 0) { 120 | missing[key] = null 121 | } else if (suggestedVal.length > 0 && typeof suggestedVal[0] === 'object' && !Array.isArray(suggestedVal[0])) { 122 | // Check nested object in array 123 | const nestedMissing = filterMissing(suggestedVal[0], providedVal[0]) 124 | if (Object.keys(nestedMissing).length > 0) { 125 | missing[key] = [nestedMissing] 126 | } 127 | } 128 | } else if (suggestedVal && typeof suggestedVal === 'object') { 129 | // Handle nested objects 130 | if (keyIsMissing) { 131 | // When parent is missing, recurse with null to get full nested structure 132 | const nestedMissing = filterMissing(suggestedVal, null) 133 | missing[key] = nestedMissing 134 | } else if (typeof providedVal !== 'object' || Array.isArray(providedVal)) { 135 | // Provided value is not an object when we expect one 136 | const nestedMissing = filterMissing(suggestedVal, null) 137 | missing[key] = nestedMissing 138 | } else { 139 | // Recursively check nested properties 140 | const nestedMissing = filterMissing(suggestedVal, providedVal) 141 | if (Object.keys(nestedMissing).length > 0) { 142 | missing[key] = nestedMissing 143 | } 144 | } 145 | } else { 146 | // Handle primitive values 147 | if (keyIsMissing) { 148 | missing[key] = null 149 | } 150 | } 151 | } 152 | 153 | return missing 154 | } 155 | 156 | const mockCart = { 157 | email: "customer@example.com", 158 | created_at: new Date().toISOString(), 159 | updated_at: new Date().toISOString(), 160 | customer: { 161 | id: "cus_mock_123", 162 | email: "customer@example.com", 163 | first_name: "John", 164 | last_name: "Doe" 165 | }, 166 | shipping_address: { 167 | first_name: "John", 168 | last_name: "Doe", 169 | address_1: "123 Main St", 170 | city: "New York", 171 | country_code: "US", 172 | postal_code: "10001" 173 | }, 174 | items: [ 175 | { 176 | id: "item_1", 177 | title: "Sample Product", 178 | quantity: 2, 179 | unit_price: 1999, 180 | thumbnail: "https://via.placeholder.com/150" 181 | }, 182 | { 183 | id: "item_2", 184 | title: "Another Product", 185 | quantity: 1, 186 | unit_price: 2999, 187 | thumbnail: "https://via.placeholder.com/150" 188 | } 189 | ] 190 | } as unknown as CartDTO & { customer: CustomerDTO } 191 | -------------------------------------------------------------------------------- /src/workflows/steps/fetch-abandoned-carts.ts: -------------------------------------------------------------------------------- 1 | import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" 2 | import { ReminderSchedule } from "../../types/reminder-schedules" 3 | import { CartDTO, CustomerDTO } from "@medusajs/framework/types" 4 | import { Temporal } from "temporal-polyfill" 5 | import { 6 | computeCartReferenceTimestamp, 7 | getSentNotification, 8 | } from "../../types/abandoned-cart-tracking" 9 | 10 | type FetchAbandonedCartsStepInput = { 11 | reminderSchedules: Array 12 | pagination: { limit: number, offset: number } 13 | } 14 | 15 | /** 16 | * Represents a reminder with computed delay 17 | */ 18 | interface ProcessedReminder { 19 | delay: Temporal.Duration 20 | delayIso: string // original ISO duration 21 | template: string 22 | schedule: ReminderSchedule 23 | } 24 | 25 | /** 26 | * Check if a cart should receive a specific reminder 27 | */ 28 | function shouldSendReminder( 29 | cart: CartDTO, 30 | reminder: ProcessedReminder, 31 | currentReferenceInstant: Temporal.Instant, 32 | now: Temporal.Instant 33 | ): boolean { 34 | const { schedule, delayIso, delay } = reminder 35 | 36 | // Calculate time elapsed since cart's reference timestamp 37 | const elapsed = now.since(currentReferenceInstant) 38 | const currentReferenceDate = Temporal.ZonedDateTime.from(currentReferenceInstant.toZonedDateTimeISO("UTC")) 39 | 40 | // Check if enough time has passed for this delay 41 | if (Temporal.Duration.compare(elapsed, delay, { relativeTo: currentReferenceDate }) < 0) { 42 | return false 43 | } 44 | 45 | // Check if this notification was already sent 46 | const sentNotification = getSentNotification(cart, schedule.id, delayIso) 47 | 48 | if (!sentNotification) { 49 | // If reset_on_cart_update is true and the cart has been updated since any previous send, allow sending all reminders again (including missed ones) 50 | if (schedule.reset_on_cart_update) { 51 | // Check if any notification for this schedule was sent before 52 | const tracking = (typeof cart.metadata?.abandoned_cart_tracking === 'object' && cart.metadata?.abandoned_cart_tracking !== null) 53 | ? ((cart.metadata.abandoned_cart_tracking as any).sent_notifications || {}) 54 | : {} 55 | const schedulePrefix = `${schedule.id}:` 56 | let anySent = false 57 | let lastCartReferenceAtSend = null 58 | for (const key of Object.keys(tracking)) { 59 | if (key.startsWith(schedulePrefix)) { 60 | anySent = true 61 | const notif = tracking[key] 62 | if (!lastCartReferenceAtSend || notif.cart_reference_at_send > lastCartReferenceAtSend) { 63 | lastCartReferenceAtSend = notif.cart_reference_at_send 64 | } 65 | } 66 | } 67 | if (anySent && lastCartReferenceAtSend) { 68 | // If cart has been updated since last send, allow sending all reminders again 69 | const cartReferenceAtSendInstant = Temporal.Instant.from(lastCartReferenceAtSend) 70 | if (Temporal.Instant.compare(currentReferenceInstant, cartReferenceAtSendInstant) > 0) { 71 | return true 72 | } 73 | } 74 | } 75 | // Never sent before - this could be a newly added delay 76 | 77 | // Prevent sending a notification for a smaller delay if a bigger one was already sent for the same schedule 78 | // Find all sent notifications for this schedule 79 | const tracking = (typeof cart.metadata?.abandoned_cart_tracking === 'object' && cart.metadata?.abandoned_cart_tracking !== null) 80 | ? ((cart.metadata.abandoned_cart_tracking as any).sent_notifications || {}) 81 | : {} 82 | const schedulePrefix = `${schedule.id}:` 83 | let biggerDelaySent = false 84 | for (const key of Object.keys(tracking)) { 85 | if (key.startsWith(schedulePrefix)) { 86 | const sentDelayIso = key.slice(schedulePrefix.length) 87 | try { 88 | const sentDelay = Temporal.Duration.from(sentDelayIso) 89 | if (Temporal.Duration.compare(sentDelay, delay, { relativeTo: currentReferenceDate }) > 0) { 90 | biggerDelaySent = true 91 | break 92 | } 93 | } catch { } 94 | } 95 | } 96 | if (biggerDelaySent) { 97 | return false 98 | } 99 | 100 | if (schedule.updated_at) { 101 | const scheduleUpdatedInstant = Temporal.Instant.from(new Date(schedule.updated_at).toISOString()) 102 | const cartCreatedInstant = Temporal.Instant.from(new Date(cart.created_at!).toISOString()) 103 | 104 | // If notify_existing is false, only carts created after schedule update are eligible 105 | if (!schedule.notify_existing) { 106 | if (Temporal.Instant.compare(cartCreatedInstant, scheduleUpdatedInstant) < 0) { 107 | return false 108 | } 109 | // For newly added delays: cart reference must also be after schedule update 110 | if (Temporal.Instant.compare(currentReferenceInstant, scheduleUpdatedInstant) < 0) { 111 | return false 112 | } 113 | } else { 114 | // If notify_existing is true, allow carts created before schedule update 115 | // But for newly added delays, only allow if the delay existed at the time the cart became eligible 116 | // So, skip the reference check for notify_existing=true 117 | } 118 | } 119 | return true 120 | } 121 | 122 | // Already sent once - check reset_on_cart_update behavior 123 | if (schedule.reset_on_cart_update) { 124 | // Reset mode: resend if cart was updated after last send 125 | const cartReferenceAtSendInstant = Temporal.Instant.from(sentNotification.cart_reference_at_send) 126 | const cartChangedSinceLastSend = Temporal.Instant.compare( 127 | currentReferenceInstant, 128 | cartReferenceAtSendInstant 129 | ) > 0 130 | 131 | if (cartChangedSinceLastSend) { 132 | // Cart was updated, check if enough time has passed since the update 133 | return Temporal.Duration.compare(elapsed, delay, { relativeTo: currentReferenceDate }) >= 0 134 | } 135 | } 136 | 137 | // Either reset is disabled or cart hasn't changed - don't resend 138 | return false 139 | } 140 | 141 | export const fetchAbandonedCarts = createStep( 142 | "fetch-abandoned-carts", 143 | async ({ reminderSchedules, pagination }: FetchAbandonedCartsStepInput, { container }) => { 144 | const query = container.resolve("query") 145 | 146 | // Filter out disabled schedules 147 | const enabledSchedules = reminderSchedules.filter(s => s.enabled !== false) 148 | // Transform enabled reminder schedules into a flat array of reminders with delay and template 149 | const reminders: ProcessedReminder[] = enabledSchedules.flatMap(schedule => 150 | schedule.delays_iso.map(delayIso => ({ 151 | delay: Temporal.Duration.from(delayIso), 152 | delayIso, 153 | template: schedule.template_id, 154 | schedule: schedule 155 | })) 156 | ).sort((a, b) => Temporal.Duration.compare(a.delay, b.delay, { relativeTo: Temporal.Now.plainDateTimeISO() })) 157 | 158 | if (!reminders.length) { 159 | return new StepResponse({ carts: [], totalCount: 0 }) 160 | } 161 | 162 | // Use the shortest delay to filter carts - we need carts old enough for at least one delay 163 | const shortestDelay = reminders[0].delay 164 | const now = Temporal.Now.instant() 165 | const cutoffInstant = now.subtract(shortestDelay) 166 | 167 | const { 168 | data: abandonedCarts, 169 | metadata, 170 | } = await query.graph({ 171 | entity: "cart", 172 | fields: [ 173 | "id", 174 | "email", 175 | "items.*", 176 | "metadata", 177 | "updated_at", 178 | "created_at", 179 | "customer.*", 180 | ], 181 | filters: { 182 | email: { 183 | $ne: null, 184 | }, 185 | completed_at: null, 186 | }, 187 | pagination: { 188 | skip: pagination.offset, 189 | take: pagination.limit, 190 | }, 191 | }) 192 | 193 | // Build an array of { cart, reminders: ProcessedReminder[] } for all eligible reminders for each cart 194 | const eligible: Array<{ cart: CartDTO & { customer: CustomerDTO }, reminders: ProcessedReminder[] }> = [] 195 | 196 | for (const cart of abandonedCarts) { 197 | // Skip carts without items 198 | if (!cart.items?.length) { 199 | continue 200 | } 201 | 202 | // Compute the cart's reference timestamp (most recent update) 203 | const currentReferenceTimestamp = computeCartReferenceTimestamp(cart as unknown as CartDTO) 204 | const currentReferenceInstant = Temporal.Instant.fromEpochMilliseconds(currentReferenceTimestamp) 205 | 206 | // Check if cart is old enough for any reminder 207 | if (Temporal.Instant.compare(currentReferenceInstant, cutoffInstant) > 0) { 208 | continue 209 | } 210 | 211 | // For each reminder, check eligibility 212 | const eligibleReminders: ProcessedReminder[] = [] 213 | // Group reminders by schedule to select one per schedule 214 | const remindersBySchedule = new Map() 215 | for (const reminder of reminders) { 216 | const scheduleId = reminder.schedule.id 217 | if (!remindersBySchedule.has(scheduleId)) { 218 | remindersBySchedule.set(scheduleId, []) 219 | } 220 | remindersBySchedule.get(scheduleId)!.push(reminder) 221 | } 222 | for (const [scheduleId, scheduleReminders] of remindersBySchedule) { 223 | let selectedReminder: ProcessedReminder | null = null 224 | for (const reminder of scheduleReminders.toReversed()) { 225 | if (shouldSendReminder(cart as unknown as CartDTO, reminder, currentReferenceInstant, now)) { 226 | selectedReminder = reminder 227 | break 228 | } 229 | } 230 | if (selectedReminder) { 231 | eligibleReminders.push(selectedReminder) 232 | } 233 | } 234 | if (eligibleReminders.length > 0) { 235 | eligible.push({ cart: cart as unknown as CartDTO & { customer: CustomerDTO }, reminders: eligibleReminders }) 236 | } 237 | } 238 | 239 | const totalCount = metadata?.count ?? 0 240 | return new StepResponse({ 241 | carts: eligible, 242 | totalCount 243 | }) 244 | } 245 | ) 246 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/@edit/[id]/edit-form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod" 2 | import { 3 | Alert, 4 | Button, 5 | Switch, 6 | } from "@medusajs/ui" 7 | import { useForm } from "react-hook-form" 8 | import { usePrompt } from "@medusajs/ui" 9 | import { useTranslation } from "react-i18next" 10 | import { Form } from "../../../../../components/form/form" 11 | import { KeyboundForm } from "../../../../../components/modals/utilities/keybound-form" 12 | import { FetchError } from "@medusajs/js-sdk" 13 | import { useRouteModal } from "../../../../../components/modals/route-modal-provider/use-route-modal" 14 | import { RouteDrawer } from "../../../../../components/modals/route-drawer" 15 | import { ReminderSchedule, UpdateReminderSchedule, UpdateReminderScheduleSchema } from "../../../../../../types/reminder-schedules" 16 | import { useUpdateReminderSchedules } from "../../../../../hooks/use-reminder-schedules" 17 | import { DurationInput } from "../../../../../components/general/duration-input" 18 | import { useComboboxData } from "../../../../../hooks/use-combobox-data" 19 | import { sdk } from "../../../../../lib/sdk" 20 | import { Combobox } from "../../../../../components/general/combobox" 21 | const isFetchError = (error: any): error is FetchError => { 22 | return error instanceof FetchError 23 | } 24 | 25 | export const EditReminderScheduleForm = ({ schedule }: { schedule: ReminderSchedule }) => { 26 | const { t } = useTranslation("postmark") 27 | const prompt = usePrompt() 28 | const { handleSuccess } = useRouteModal() 29 | 30 | const form = useForm({ 31 | defaultValues: schedule, 32 | resolver: zodResolver(UpdateReminderScheduleSchema), 33 | }) 34 | 35 | const { mutateAsync, isPending } = useUpdateReminderSchedules(schedule.id) 36 | 37 | const handleSubmit = form.handleSubmit(async (values) => { 38 | await mutateAsync(values, { 39 | onSuccess: () => { 40 | handleSuccess() 41 | }, 42 | onError: error => { 43 | if (isFetchError(error) && error.status === 400) 44 | form.setError("root", { 45 | type: "manual", 46 | message: error.message, 47 | }) 48 | 49 | } 50 | }) 51 | }) 52 | 53 | const templateComboOptions = useComboboxData({ 54 | queryKey: ["postmark", "templates"], 55 | queryFn: (queryParams) => sdk.admin.postmark.templates.list(queryParams), 56 | getOptions: (data) => 57 | data.Templates.map((template) => ({ 58 | label: template.Name, 59 | value: template.TemplateId.toString(), 60 | })), 61 | }) 62 | 63 | return ( 64 | 65 | 69 | 70 | {form.formState.errors.root && ( 71 | 76 | {form.formState.errors.root.message} 77 | 78 | )} 79 | 80 |
81 | { 85 | return ( 86 | 87 | 88 | {t("fields.template")} 89 | 90 | 91 | 100 | 101 | 102 | 103 | ) 104 | }} 105 | /> 106 | 107 | { 111 | return ( 112 | 113 | 114 | {t("reminder_schedules.delays")} 115 | 116 | 117 | 120 | 121 | 122 | 123 | ) 124 | }} 125 | /> 126 | 127 | { 131 | return ( 132 | 133 |
134 | 135 | 140 | 141 | 142 | {t("statuses.enabled")} 143 | 144 |
145 | 146 |
147 | ) 148 | }} 149 | /> 150 | 151 | { 155 | const handleCheckedChange = async (checked: boolean) => { 156 | if (checked) { 157 | const confirmed = await prompt({ 158 | title: t("reminder_schedules.notify_existing_warning_title"), 159 | description: t("reminder_schedules.notify_existing_warning_desc"), 160 | variant: "confirmation", 161 | }) 162 | if (!confirmed) return 163 | } 164 | onChange(checked) 165 | } 166 | return ( 167 | 168 |
169 | 170 | 175 | 176 | 177 | {t("reminder_schedules.notify_existing")} 178 | 179 |
180 | 181 | {t("reminder_schedules.notify_existing_hint")} 182 | 183 | 184 |
185 | ) 186 | }} 187 | /> 188 | 189 | { 193 | return ( 194 | 195 |
196 | 197 | 202 | 203 | 204 | {t("reminder_schedules.reset_on_cart_update")} 205 | 206 |
207 | 208 | {t("reminder_schedules.reset_on_cart_update_hint")} 209 | 210 | 211 |
212 | ) 213 | }} 214 | /> 215 |
216 |
217 | 218 |
219 | 220 | 223 | 224 | 232 |
233 |
234 |
235 |
236 | ) 237 | } 238 | -------------------------------------------------------------------------------- /src/admin/routes/postmark/abandonded-carts/@create/create-form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod" 2 | import { 3 | Alert, 4 | Button, 5 | Switch, 6 | } from "@medusajs/ui" 7 | import { useForm } from "react-hook-form" 8 | import { usePrompt } from "@medusajs/ui" 9 | import { useTranslation } from "react-i18next" 10 | import { Form } from "../../../../components/form/form" 11 | import { KeyboundForm } from "../../../../components/modals/utilities/keybound-form" 12 | import { FetchError } from "@medusajs/js-sdk" 13 | import { useRouteModal } from "../../../../components/modals/route-modal-provider/use-route-modal" 14 | import { RouteDrawer } from "../../../../components/modals/route-drawer" 15 | import { CreateReminderSchedule, CreateReminderScheduleSchema } from "../../../../../types/reminder-schedules" 16 | import { useCreateReminderSchedules } from "../../../../hooks/use-reminder-schedules" 17 | import { DurationInput } from "../../../../components/general/duration-input" 18 | import { useComboboxData } from "../../../../hooks/use-combobox-data" 19 | import { sdk } from "../../../../lib/sdk" 20 | import { Combobox } from "../../../../components/general/combobox" 21 | const isFetchError = (error: any): error is FetchError => { 22 | return error instanceof FetchError 23 | } 24 | 25 | export const CreateReminderScheduleForm = () => { 26 | const { t } = useTranslation("postmark") 27 | const prompt = usePrompt() 28 | const { handleSuccess } = useRouteModal() 29 | 30 | const form = useForm({ 31 | resolver: zodResolver(CreateReminderScheduleSchema), 32 | defaultValues: { 33 | enabled: false, 34 | notify_existing: false, 35 | reset_on_cart_update: true, 36 | }, 37 | }) 38 | 39 | const { mutateAsync, isPending } = useCreateReminderSchedules() 40 | 41 | const handleSubmit = form.handleSubmit(async (values) => { 42 | await mutateAsync(values as any, { 43 | onSuccess: () => { 44 | handleSuccess() 45 | }, 46 | onError: error => { 47 | if (isFetchError(error) && error.status === 400) 48 | form.setError("root", { 49 | type: "manual", 50 | message: error.message, 51 | }) 52 | 53 | } 54 | }) 55 | }) 56 | 57 | const templateComboOptions = useComboboxData({ 58 | queryKey: ["postmark", "templates"], 59 | queryFn: (queryParams) => sdk.admin.postmark.templates.list(queryParams), 60 | getOptions: (data) => 61 | data.Templates.map((template) => ({ 62 | label: template.Name, 63 | value: template.TemplateId.toString(), 64 | })), 65 | }) 66 | 67 | return ( 68 | 69 | 73 | 74 | {form.formState.errors.root && ( 75 | 80 | {form.formState.errors.root.message} 81 | 82 | )} 83 | 84 |
85 | { 89 | return ( 90 | 91 | 92 | {t("fields.template")} 93 | 94 | 95 | 104 | 105 | 106 | 107 | ) 108 | }} 109 | /> 110 | 111 | { 115 | return ( 116 | 117 | 118 | {t("reminder_schedules.delays")} 119 | 120 | 121 | 124 | 125 | 126 | 127 | ) 128 | }} 129 | /> 130 | 131 | { 135 | return ( 136 | 137 |
138 | 139 | 144 | 145 | 146 | {t("statuses.enabled")} 147 | 148 |
149 | 150 |
151 | ) 152 | }} 153 | /> 154 | 155 | { 159 | const handleCheckedChange = async (checked: boolean) => { 160 | if (checked) { 161 | const confirmed = await prompt({ 162 | title: t("reminder_schedules.notify_existing_warning_title"), 163 | description: t("reminder_schedules.notify_existing_warning_desc"), 164 | variant: "confirmation", 165 | }) 166 | if (!confirmed) return 167 | } 168 | onChange(checked) 169 | } 170 | return ( 171 | 172 |
173 | 174 | 179 | 180 | 181 | {t("reminder_schedules.notify_existing")} 182 | 183 |
184 | 185 | {t("reminder_schedules.notify_existing_hint")} 186 | 187 | 188 |
189 | ) 190 | }} 191 | /> 192 | 193 | { 197 | return ( 198 | 199 |
200 | 201 | 206 | 207 | 208 | {t("reminder_schedules.reset_on_cart_update")} 209 | 210 |
211 | 212 | {t("reminder_schedules.reset_on_cart_update_hint")} 213 | 214 | 215 |
216 | ) 217 | }} 218 | /> 219 |
220 |
221 | 222 |
223 | 224 | 227 | 228 | 236 |
237 |
238 |
239 |
240 | ) 241 | } 242 | --------------------------------------------------------------------------------