diff --git a/.cursor/rules/medusa-development.mdc b/.cursor/rules/medusa-development.mdc index 1273a8c5f..89afe0612 100644 --- a/.cursor/rules/medusa-development.mdc +++ b/.cursor/rules/medusa-development.mdc @@ -26,6 +26,7 @@ You are an expert in Medusa v2, TypeScript, Node.js, PostgreSQL, and modern e-co ### Framework Structure - **Modules**: Self-contained packages with domain-specific functionality +- **Service Factory**: More detail into services - **Services**: Business logic layer with dependency injection - **API Routes**: RESTful endpoints in `/api/admin` and `/api/store` - **Workflows**: Multi-step business processes with error handling @@ -55,6 +56,61 @@ class MyService extends MedusaService({ } ``` +### Service Factory + +The Service Factory (`MedusaService`) is a powerful utility that automatically generates a set of standard CRUD methods for your service. When you extend `MedusaService`, your service automatically gets the following methods for each model you register: + +```typescript +// Service pattern with Service Factory +import { MedusaService } from "@medusajs/framework/utils" +import { Menu } from "./models/menu" + +class MenuModuleService extends MedusaService({ + Menu +}) { + // The service automatically gets these methods: + // - createMenus(data: CreateMenuInput): Promise + // - retrieveMenu(id: string): Promise + // - listMenus(filters?: FilterableMenuProps): Promise + // - listAndCountMenus(filters?: FilterableMenuProps): Promise<[Menu[], number]> + // - updateMenus(data: UpdateMenuInput): Promise + // - deleteMenus(id: string): Promise + // - softDeleteMenus(id: string): Promise + // - restoreMenus(id: string): Promise +} +``` + +The generated method names follow the pattern `{operationName}_{dataModelName}`, where: +- `{operationName}` is the operation (create, retrieve, list, etc.) +- `{dataModelName}` is the pascal-case version of the model key, pluralized for all operations except `retrieve` + +For example, if you register a model named `Post`, you get methods like: +- `createPosts` +- `retrievePost` +- `listPosts` +- `listAndCountPosts` +- `updatePosts` +- `deletePosts` +- `softDeletePosts` +- `restorePosts` + +You can still add custom methods to your service while having access to these generated methods: + +```typescript +class MenuModuleService extends MedusaService({ + Menu +}) { + // Custom method + async getActiveMenus(): Promise { + return await this.listMenus({ + status: "active" + }) + } +} +``` + +The Service Factory provides a consistent interface for data operations while allowing you to extend functionality as needed. + ### API Route Patterns ```typescript // Admin API route diff --git a/apps/medusa/medusa-config.ts b/apps/medusa/medusa-config.ts index 5b83b3fda..1f6ffa9a0 100644 --- a/apps/medusa/medusa-config.ts +++ b/apps/medusa/medusa-config.ts @@ -6,6 +6,17 @@ const REDIS_URL = process.env.REDIS_URL; const STRIPE_API_KEY = process.env.STRIPE_API_KEY; const IS_TEST = process.env.NODE_ENV === 'test'; +const customModules = [ + { + resolve: './src/modules/menu', + options: {}, + }, + { + resolve: './src/modules/chef-event', + options: {}, + }, +] + const cacheModule = IS_TEST ? { resolve: '@medusajs/medusa/cache-inmemory' } : { @@ -35,6 +46,23 @@ const workflowEngineModule = IS_TEST }, }; +const notificationModule = { + resolve: "@medusajs/medusa/notification", + options: { + providers: [ + { + resolve: "./src/modules/resend", + id: "resend", + options: { + channels: ["email"], + api_key: process.env.RESEND_API_KEY, + from: process.env.RESEND_FROM_EMAIL, + }, + }, + ], + }, + }; + module.exports = defineConfig({ projectConfig: { databaseUrl: process.env.DATABASE_URL, @@ -42,8 +70,9 @@ module.exports = defineConfig({ ssl: false, }, redisUrl: REDIS_URL, - redisPrefix: process.env.REDIS_PREFIX, + // ADD WORKER MODE CONFIGURATION + workerMode: process.env.MEDUSA_WORKER_MODE as "shared" | "worker" | "server", http: { storeCors: process.env.STORE_CORS || '', adminCors: process.env.ADMIN_CORS || '', @@ -59,6 +88,7 @@ module.exports = defineConfig({ }, ], modules: [ + ...customModules, { resolve: '@medusajs/medusa/payment', options: { @@ -76,8 +106,11 @@ module.exports = defineConfig({ cacheModule, eventBusModule, workflowEngineModule, + notificationModule, ], admin: { + // ADD ADMIN DISABLE CONFIGURATION + disable: process.env.DISABLE_MEDUSA_ADMIN === "true", backendUrl: process.env.ADMIN_BACKEND_URL, vite: () => { return { @@ -88,3 +121,6 @@ module.exports = defineConfig({ }, }, }); + + + diff --git a/apps/medusa/package.json b/apps/medusa/package.json index 457575b47..baa0f85f6 100644 --- a/apps/medusa/package.json +++ b/apps/medusa/package.json @@ -16,14 +16,17 @@ "nukedb": "docker compose down -v && docker compose up -d", "build": "medusa build", "seed": "medusa exec ./src/scripts/seed.ts", + "seed:menus": "medusa exec ./src/scripts/seed/menus.ts", "start": "medusa start", "dev": "medusa develop", + "dev:email": "email dev --dir ./src/modules/resend/emails", "sync": "medusa db:sync-links", "migrate:prod": "medusa db:migrate", "start:prod": "medusa start", "seed:prod": "medusa exec ./src/scripts/seed.js && yarn add-user:prod", "add-user:prod": "medusa user --email admin@medusa-test.com --password supersecret && medusa user --email admin@lambdacurry.dev --password password", "migrate": "medusa db:migrate", + "predeploy": "medusa db:migrate", "test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit", "test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit", "test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit", @@ -43,8 +46,12 @@ "@mikro-orm/knex": "6.4.3", "@mikro-orm/migrations": "6.4.3", "@mikro-orm/postgresql": "6.4.3", + "@react-email/components": "^0.3.2", + "@types/luxon": "^3.6.2", "awilix": "^8.0.1", - "pg": "^8.13.0" + "date-fns": "^4.1.0", + "pg": "^8.13.0", + "resend": "^4.7.0" }, "devDependencies": { "@medusajs/test-utils": "2.7.0", @@ -52,6 +59,7 @@ "@mikro-orm/core": "6.4.3", "@mikro-orm/migrations": "6.4.3", "@mikro-orm/postgresql": "6.4.3", + "@react-email/preview-server": "4.2.4", "@stdlib/number-float64-base-normalize": "0.0.8", "@swc/core": "1.5.7", "@swc/jest": "^0.2.36", @@ -62,6 +70,7 @@ "@types/react": "^18.3.2", "jest": "^29.7.0", "prop-types": "^15.8.1", + "react-email": "^4.2.4", "ts-node": "^10.9.2", "typescript": "^5.7.3", "yalc": "^1.0.0-pre.53" diff --git a/apps/medusa/src/admin/hooks/chef-events.ts b/apps/medusa/src/admin/hooks/chef-events.ts new file mode 100644 index 000000000..ea98f12ea --- /dev/null +++ b/apps/medusa/src/admin/hooks/chef-events.ts @@ -0,0 +1,121 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { sdk } from '../../sdk' +import type { + AdminListChefEventsQuery, + AdminCreateChefEventDTO, + AdminUpdateChefEventDTO, + AdminChefEventDTO, + AdminChefEventsResponse, + AdminAcceptChefEventDTO, + AdminRejectChefEventDTO, + AdminResendEventEmailDTO +} from '../../sdk/admin/admin-chef-events' + +const QUERY_KEY = ['chef-events'] + +export const useAdminListChefEvents = (query: AdminListChefEventsQuery = {}) => { + return useQuery({ + queryKey: [...QUERY_KEY, query], + placeholderData: (previousData) => previousData, + queryFn: async () => { + return sdk.admin.chefEvents.list(query) + }, + }) +} + +export const useAdminRetrieveChefEvent = (id: string) => { + return useQuery({ + queryKey: [...QUERY_KEY, id], + queryFn: async () => { + return sdk.admin.chefEvents.retrieve(id) + }, + }) +} + +export const useAdminCreateChefEventMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (data: AdminCreateChefEventDTO) => { + return await sdk.admin.chefEvents.create(data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + }, + }) +} + +export const useAdminUpdateChefEventMutation = (id: string) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (data: AdminUpdateChefEventDTO) => { + return await sdk.admin.chefEvents.update(id, data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, id] }) + }, + }) +} + +export const useAdminDeleteChefEventMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (id: string) => { + return await sdk.admin.chefEvents.delete(id) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + }, + }) +} + +export const useAdminAcceptChefEventMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ id, data }: { id: string; data?: AdminAcceptChefEventDTO }) => { + return await sdk.admin.chefEvents.accept(id, data || {}) + }, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, variables.id] }) + }, + }) +} + +export const useAdminRejectChefEventMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ id, data }: { id: string; data: AdminRejectChefEventDTO }) => { + return await sdk.admin.chefEvents.reject(id, data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + }, + }) +} + +/** + * Hook for resending event emails + */ +export const useAdminResendEventEmailMutation = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ chefEventId, ...data }: { chefEventId: string } & AdminResendEventEmailDTO) => { + return await sdk.admin.chefEvents.resendEmail(chefEventId, data) + }, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, variables.chefEventId] }) + }, + }) +} + +export const useAdminGetMenuProducts = () => { + return useQuery({ + queryKey: [...QUERY_KEY, 'menu-products'], + queryFn: async () => { + return sdk.admin.chefEvents.getMenuProducts() + }, + }) +} \ No newline at end of file diff --git a/apps/medusa/src/admin/hooks/menus.ts b/apps/medusa/src/admin/hooks/menus.ts new file mode 100644 index 000000000..b53f14ff8 --- /dev/null +++ b/apps/medusa/src/admin/hooks/menus.ts @@ -0,0 +1,78 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { sdk } from '../../sdk' +import type { + AdminListMenusQuery, + AdminCreateMenuDTO, + AdminUpdateMenuDTO, + AdminMenuDTO, + AdminMenusResponse +} from '../../sdk/admin/admin-menus' + +const QUERY_KEY = ['menus'] + +export const useAdminListMenus = (query: AdminListMenusQuery = {}) => { + console.log("🔍 useAdminListMenus called with query:", query) + + return useQuery({ + queryKey: [...QUERY_KEY, query], + placeholderData: (previousData) => previousData, + queryFn: async () => { + console.log("🚀 Making API call to list menus with query:", query) + try { + const result = await sdk.admin.menus.list(query) + console.log("✅ API call successful, result:", result) + return result + } catch (error) { + console.error("❌ API call failed:", error) + throw error + } + }, + }) +} + +export const useAdminRetrieveMenu = (id: string, options?: { enabled?: boolean }) => { + return useQuery({ + queryKey: [...QUERY_KEY, id], + enabled: options?.enabled !== false && !!id, + queryFn: async () => { + return sdk.admin.menus.retrieve(id) + }, + }) +} + +export const useAdminCreateMenuMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (data: AdminCreateMenuDTO) => { + return await sdk.admin.menus.create(data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + }, + }) +} + +export const useAdminUpdateMenuMutation = (id: string) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (data: AdminUpdateMenuDTO) => { + return await sdk.admin.menus.update(id, data) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, id] }) + }, + }) +} + +export const useAdminDeleteMenuMutation = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (id: string) => { + return await sdk.admin.menus.delete(id) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }) + }, + }) +} \ No newline at end of file diff --git a/apps/medusa/src/admin/providers.tsx b/apps/medusa/src/admin/providers.tsx new file mode 100644 index 000000000..7ad5807c3 --- /dev/null +++ b/apps/medusa/src/admin/providers.tsx @@ -0,0 +1,23 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactNode } from "react" + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30, // 30 minutes + }, + }, +}) + +interface ProvidersProps { + children: ReactNode +} + +export const Providers = ({ children }: ProvidersProps) => { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/apps/medusa/src/admin/root.tsx b/apps/medusa/src/admin/root.tsx new file mode 100644 index 000000000..166cb04c5 --- /dev/null +++ b/apps/medusa/src/admin/root.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "@remix-run/react" +import { Providers } from "./providers.js" + +export default function Root() { + return ( + + + + ) +} \ No newline at end of file diff --git a/apps/medusa/src/admin/routes/chef-events/[id]/page.tsx b/apps/medusa/src/admin/routes/chef-events/[id]/page.tsx new file mode 100644 index 000000000..5466680ba --- /dev/null +++ b/apps/medusa/src/admin/routes/chef-events/[id]/page.tsx @@ -0,0 +1,273 @@ +import { defineRouteConfig } from "@medusajs/admin-sdk" +import { Container, Heading, toast, Button, FocusModal, Textarea, Label, Checkbox } from "@medusajs/ui" +import { useParams } from "react-router-dom" +import { useState } from "react" +import { ChefEventForm } from "../components/chef-event-form" +import { MenuDetails } from "../components/menu-details" +import { EmailManagementSection } from "../components/EmailManagementSection" +import { useAdminRetrieveChefEvent, useAdminUpdateChefEventMutation, useAdminAcceptChefEventMutation, useAdminRejectChefEventMutation } from "../../../hooks/chef-events" + +const ChefEventDetailPage = () => { + const { id } = useParams<{ id: string }>() + const { data: chefEvent, isLoading } = useAdminRetrieveChefEvent(id!) + const updateChefEvent = useAdminUpdateChefEventMutation(id!) + const acceptChefEvent = useAdminAcceptChefEventMutation() + const rejectChefEvent = useAdminRejectChefEventMutation() + + const [showAcceptModal, setShowAcceptModal] = useState(false) + const [showRejectModal, setShowRejectModal] = useState(false) + const [chefNotes, setChefNotes] = useState("") + const [rejectionReason, setRejectionReason] = useState("") + const [sendAcceptanceEmail, setSendAcceptanceEmail] = useState(true) + + const handleUpdateChefEvent = async (data: any) => { + try { + await updateChefEvent.mutateAsync(data) + toast.success("Chef Event Updated", { + description: "The chef event has been updated successfully.", + duration: 3000, + }) + } catch (error) { + console.error("Error updating chef event:", error) + toast.error("Update Failed", { + description: "There was an error updating the chef event. Please try again.", + duration: 5000, + }) + } + } + + const handleAcceptEvent = async () => { + try { + await acceptChefEvent.mutateAsync({ + id: id!, + data: { + chefNotes: chefNotes || undefined, + sendAcceptanceEmail: sendAcceptanceEmail + } + }) + toast.success("Event Accepted", { + description: "The event has been accepted and a product has been created for ticket sales.", + duration: 5000, + }) + setShowAcceptModal(false) + setChefNotes("") + setSendAcceptanceEmail(true) + } catch (error) { + console.error("Error accepting chef event:", error) + toast.error("Acceptance Failed", { + description: "There was an error accepting the chef event. Please try again.", + duration: 5000, + }) + } + } + + const handleRejectEvent = async () => { + if (!rejectionReason.trim()) { + toast.error("Rejection Reason Required", { + description: "Please provide a reason for rejecting this event.", + duration: 3000, + }) + return + } + + try { + await rejectChefEvent.mutateAsync({ + id: id!, + data: { + rejectionReason: rejectionReason.trim(), + chefNotes: chefNotes || undefined + } + }) + toast.success("Event Rejected", { + description: "The event has been rejected and the customer has been notified.", + duration: 5000, + }) + setShowRejectModal(false) + setRejectionReason("") + setChefNotes("") + } catch (error) { + console.error("Error rejecting chef event:", error) + toast.error("Rejection Failed", { + description: "There was an error rejecting the chef event. Please try again.", + duration: 5000, + }) + } + } + + if (isLoading) { + return ( + +
Loading...
+
+ ) + } + + if (!chefEvent) { + return ( + +
Chef event not found
+
+ ) + } + + const isPending = chefEvent.status === 'pending' + const isConfirmed = chefEvent.status === 'confirmed' + + return ( + +
+ + Edit Chef Event - {(chefEvent as any).firstName} {(chefEvent as any).lastName} + + + {isPending && ( +
+ + +
+ )} + + {isConfirmed && chefEvent.productId && ( + + )} +
+ +
+ window.history.back()} + /> + + {/* Email Management Section for confirmed events */} + {isConfirmed && ( + { + // Refresh event data to show updated email history + // refetch() - will be available once we update the hooks + toast.success("Email Sent", { + description: `Event details sent successfully`, + duration: 3000, + }) + }} + /> + )} + + +
+ + {/* Accept Event Modal */} + {showAcceptModal && ( + + + + Accept Event + + +
+

This will accept the event and create a product for ticket sales.

+ + {/* Email Notification Control */} +
+ + +
+ +
+ +