From f7fc05307f9292c22f3f1ac986108c88271c8867 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Fri, 15 Aug 2025 10:59:54 +0200 Subject: [PATCH] feat: add settings module (#13175) * feat: add view_configurations feature flag - Add feature flag provider and hooks to admin dashboard - Add backend API endpoint for feature flags - Create view_configurations feature flag (disabled by default) - Update order list table to use legacy version when flag is disabled - Can be enabled with MEDUSA_FF_VIEW_CONFIGURATIONS=true env var * fix: naming * fix: feature flags unauthenticated * fix: add test * feat: add settings module * fix: deps * fix: cleanup * fix: add more tetsts * fix: rm changelog * fix: deps * fix: add settings module to default modules list * fix: pr comments * fix: deps,build * fix: alias * fix: tests * fix: update snapshots --- packages/core/modules-sdk/src/definitions.ts | 11 + packages/core/types/src/bundles.ts | 1 + packages/core/types/src/index.ts | 1 + packages/core/types/src/settings/common.ts | 161 +++++++ packages/core/types/src/settings/index.ts | 3 + packages/core/types/src/settings/mutations.ts | 134 ++++++ packages/core/types/src/settings/service.ts | 117 +++++ .../common/__tests__/define-config.spec.ts | 30 ++ .../core/utils/src/common/define-config.ts | 1 + .../core/utils/src/modules-sdk/definition.ts | 2 + packages/medusa/package.json | 1 + packages/medusa/src/modules/settings.ts | 6 + packages/modules/settings/README.md | 17 + .../__tests__/settings-module.spec.ts | 423 ++++++++++++++++++ packages/modules/settings/jest.config.js | 10 + .../modules/settings/mikro-orm.config.dev.ts | 6 + packages/modules/settings/package.json | 59 +++ packages/modules/settings/src/index.ts | 7 + .../migrations/.snapshot-medusa-settings.json | 269 +++++++++++ .../src/migrations/Migration20250717162007.ts | 19 + packages/modules/settings/src/models/index.ts | 2 + .../settings/src/models/user-preference.ts | 18 + .../settings/src/models/view-configuration.ts | 22 + .../modules/settings/src/services/index.ts | 1 + .../src/services/settings-module-service.ts | 365 +++++++++++++++ packages/modules/settings/src/types/index.ts | 5 + packages/modules/settings/tsconfig.json | 19 + yarn.lock | 26 ++ 28 files changed, 1736 insertions(+) create mode 100644 packages/core/types/src/settings/common.ts create mode 100644 packages/core/types/src/settings/index.ts create mode 100644 packages/core/types/src/settings/mutations.ts create mode 100644 packages/core/types/src/settings/service.ts create mode 100644 packages/medusa/src/modules/settings.ts create mode 100644 packages/modules/settings/README.md create mode 100644 packages/modules/settings/integration-tests/__tests__/settings-module.spec.ts create mode 100644 packages/modules/settings/jest.config.js create mode 100644 packages/modules/settings/mikro-orm.config.dev.ts create mode 100644 packages/modules/settings/package.json create mode 100644 packages/modules/settings/src/index.ts create mode 100644 packages/modules/settings/src/migrations/.snapshot-medusa-settings.json create mode 100644 packages/modules/settings/src/migrations/Migration20250717162007.ts create mode 100644 packages/modules/settings/src/models/index.ts create mode 100644 packages/modules/settings/src/models/user-preference.ts create mode 100644 packages/modules/settings/src/models/view-configuration.ts create mode 100644 packages/modules/settings/src/services/index.ts create mode 100644 packages/modules/settings/src/services/settings-module-service.ts create mode 100644 packages/modules/settings/src/types/index.ts create mode 100644 packages/modules/settings/tsconfig.json diff --git a/packages/core/modules-sdk/src/definitions.ts b/packages/core/modules-sdk/src/definitions.ts index 2193a23167..5fda55999e 100644 --- a/packages/core/modules-sdk/src/definitions.ts +++ b/packages/core/modules-sdk/src/definitions.ts @@ -219,6 +219,17 @@ export const ModulesDefinition: { scope: MODULE_SCOPE.INTERNAL, }, }, + [Modules.SETTINGS]: { + key: Modules.SETTINGS, + defaultPackage: false, + label: upperCaseFirst(Modules.SETTINGS), + isRequired: false, + isQueryable: true, + dependencies: [ContainerRegistrationKeys.LOGGER], + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + }, + }, [Modules.STORE]: { key: Modules.STORE, defaultPackage: false, diff --git a/packages/core/types/src/bundles.ts b/packages/core/types/src/bundles.ts index a628ab6127..ebc17f67b2 100644 --- a/packages/core/types/src/bundles.ts +++ b/packages/core/types/src/bundles.ts @@ -26,6 +26,7 @@ export * as PromotionTypes from "./promotion" export * as RegionTypes from "./region" export * as SalesChannelTypes from "./sales-channel" export * as SearchTypes from "./search" +export * as SettingsTypes from "./settings" export * as StockLocationTypes from "./stock-location" export * as StoreTypes from "./store" export * as TaxTypes from "./tax" diff --git a/packages/core/types/src/index.ts b/packages/core/types/src/index.ts index e6905e70b4..ec4b483c91 100644 --- a/packages/core/types/src/index.ts +++ b/packages/core/types/src/index.ts @@ -34,6 +34,7 @@ export * from "./promotion" export * from "./region" export * from "./sales-channel" export * from "./search" +export * from "./settings" export * from "./shared-context" export * from "./stock-location" export * from "./store" diff --git a/packages/core/types/src/settings/common.ts b/packages/core/types/src/settings/common.ts new file mode 100644 index 0000000000..c7fa2caefb --- /dev/null +++ b/packages/core/types/src/settings/common.ts @@ -0,0 +1,161 @@ +import { BaseFilterable } from "../dal" + +/** + * The view configuration data model. + */ +export interface ViewConfigurationDTO { + /** + * The ID of the configuration. + */ + id: string + + /** + * The entity this configuration is for. + */ + entity: string + + /** + * The name of the configuration. + */ + name: string + + /** + * The user ID this configuration belongs to. + */ + user_id: string | null + + /** + * Whether this is a system default configuration. + */ + is_system_default: boolean + + /** + * The configuration data. + */ + configuration: { + /** + * The visible columns. + */ + visible_columns: string[] + + /** + * The column order. + */ + column_order: string[] + + /** + * The column widths. + */ + column_widths?: Record + + /** + * The filters to apply. + */ + filters?: Record + + /** + * The sorting configuration. + */ + sorting?: { id: string; desc: boolean } | null + + /** + * The search string. + */ + search?: string + } + + /** + * The date the configuration was created. + */ + created_at: Date + + /** + * The date the configuration was last updated. + */ + updated_at: Date +} + +/** + * The user preference data model. + */ +export interface UserPreferenceDTO { + /** + * The ID of the preference. + */ + id: string + + /** + * The user ID. + */ + user_id: string + + /** + * The preference key. + */ + key: string + + /** + * The preference value. + */ + value: any + + /** + * The date the preference was created. + */ + created_at: Date + + /** + * The date the preference was last updated. + */ + updated_at: Date +} + +/** + * The filters to apply on the retrieved view configurations. + */ +export interface FilterableViewConfigurationProps extends BaseFilterable { + /** + * The IDs to filter by. + */ + id?: string | string[] + + /** + * Filter by entity name. + */ + entity?: string | string[] + + /** + * Filter by user ID. + */ + user_id?: string | string[] | null + + /** + * Filter by system default flag. + */ + is_system_default?: boolean + + /** + * Filter by name. + */ + name?: string | string[] +} + +/** + * The filters to apply on the retrieved user preferences. + */ +export interface FilterableUserPreferenceProps extends BaseFilterable { + /** + * The IDs to filter by. + */ + id?: string | string[] + + /** + * Filter by user ID. + */ + user_id?: string | string[] + + /** + * Filter by preference key. + */ + key?: string | string[] +} \ No newline at end of file diff --git a/packages/core/types/src/settings/index.ts b/packages/core/types/src/settings/index.ts new file mode 100644 index 0000000000..fb6347e112 --- /dev/null +++ b/packages/core/types/src/settings/index.ts @@ -0,0 +1,3 @@ +export * from "./common" +export * from "./mutations" +export * from "./service" \ No newline at end of file diff --git a/packages/core/types/src/settings/mutations.ts b/packages/core/types/src/settings/mutations.ts new file mode 100644 index 0000000000..ed09ce19e2 --- /dev/null +++ b/packages/core/types/src/settings/mutations.ts @@ -0,0 +1,134 @@ +/** + * The view configuration to be created. + */ +export interface CreateViewConfigurationDTO { + /** + * The entity this configuration is for. + */ + entity: string + + /** + * The name of the configuration. + */ + name: string + + /** + * The user ID this configuration belongs to. + */ + user_id?: string + + /** + * Whether this is a system default configuration. + */ + is_system_default?: boolean + + /** + * The configuration data. + */ + configuration: { + /** + * The visible columns. + */ + visible_columns: string[] + + /** + * The column order. + */ + column_order: string[] + + /** + * The column widths. + */ + column_widths?: Record + + /** + * The filters to apply. + */ + filters?: Record + + /** + * The sorting configuration. + */ + sorting?: { id: string; desc: boolean } | null + + /** + * The search string. + */ + search?: string + } +} + +/** + * The attributes to update in the view configuration. + */ +export interface UpdateViewConfigurationDTO { + /** + * The name of the configuration. + */ + name?: string + + /** + * The configuration data. + */ + configuration?: { + /** + * The visible columns. + */ + visible_columns?: string[] + + /** + * The column order. + */ + column_order?: string[] + + /** + * The column widths. + */ + column_widths?: Record + + /** + * The filters to apply. + */ + filters?: Record + + /** + * The sorting configuration. + */ + sorting?: { id: string; desc: boolean } | null + + /** + * The search string. + */ + search?: string + } +} + +/** + * The user preference to be created. + */ +export interface CreateUserPreferenceDTO { + /** + * The user ID. + */ + user_id: string + + /** + * The preference key. + */ + key: string + + /** + * The preference value. + */ + value: any +} + +/** + * The attributes to update in the user preference. + */ +export interface UpdateUserPreferenceDTO { + /** + * The preference value. + */ + value: any +} \ No newline at end of file diff --git a/packages/core/types/src/settings/service.ts b/packages/core/types/src/settings/service.ts new file mode 100644 index 0000000000..3829f342e3 --- /dev/null +++ b/packages/core/types/src/settings/service.ts @@ -0,0 +1,117 @@ +import { Context } from "../shared-context" +import { FindConfig } from "../common" +import { IModuleService } from "../modules-sdk" +import { + ViewConfigurationDTO, + UserPreferenceDTO, + FilterableViewConfigurationProps, + FilterableUserPreferenceProps, +} from "./common" +import { + CreateViewConfigurationDTO, + UpdateViewConfigurationDTO, +} from "./mutations" + +export interface ISettingsModuleService extends IModuleService { + // View Configuration methods + retrieveViewConfiguration( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listViewConfigurations( + filters?: FilterableViewConfigurationProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountViewConfigurations( + filters?: FilterableViewConfigurationProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[ViewConfigurationDTO[], number]> + + createViewConfigurations( + data: CreateViewConfigurationDTO[], + sharedContext?: Context + ): Promise + + createViewConfigurations( + data: CreateViewConfigurationDTO, + sharedContext?: Context + ): Promise + + updateViewConfigurations( + idOrSelector: string, + data: UpdateViewConfigurationDTO, + sharedContext?: Context + ): Promise + + updateViewConfigurations( + idOrSelector: FilterableViewConfigurationProps, + data: UpdateViewConfigurationDTO, + sharedContext?: Context + ): Promise + + deleteViewConfigurations( + ids: string | string[], + sharedContext?: Context + ): Promise + + // User Preference methods + retrieveUserPreference( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listUserPreferences( + filters?: FilterableUserPreferenceProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + getUserPreference( + userId: string, + key: string, + sharedContext?: Context + ): Promise + + setUserPreference( + userId: string, + key: string, + value: any, + sharedContext?: Context + ): Promise + + deleteUserPreferences( + ids: string | string[], + sharedContext?: Context + ): Promise + + // Helper methods + getActiveViewConfiguration( + entity: string, + userId: string, + sharedContext?: Context + ): Promise + + setActiveViewConfiguration( + entity: string, + userId: string, + viewConfigurationId: string, + sharedContext?: Context + ): Promise + + getSystemDefaultViewConfiguration( + entity: string, + sharedContext?: Context + ): Promise + + clearActiveViewConfiguration( + entity: string, + userId: string, + sharedContext?: Context + ): Promise +} diff --git a/packages/core/utils/src/common/__tests__/define-config.spec.ts b/packages/core/utils/src/common/__tests__/define-config.spec.ts index a1e75fc58d..a071757105 100644 --- a/packages/core/utils/src/common/__tests__/define-config.spec.ts +++ b/packages/core/utils/src/common/__tests__/define-config.spec.ts @@ -106,6 +106,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -270,6 +273,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -442,6 +448,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -615,6 +624,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -776,6 +788,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -940,6 +955,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -1132,6 +1150,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -1331,6 +1352,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -1546,6 +1570,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, @@ -1733,6 +1760,9 @@ describe("defineConfig", function () { "sales_channel": { "resolve": "@medusajs/medusa/sales-channel", }, + "settings": { + "resolve": "@medusajs/medusa/settings", + }, "stock_location": { "resolve": "@medusajs/medusa/stock-location", }, diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index 9322afd964..1fd6f62938 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -152,6 +152,7 @@ function resolveModules( { resolve: MODULE_PACKAGE_NAMES[Modules.CURRENCY] }, { resolve: MODULE_PACKAGE_NAMES[Modules.PAYMENT] }, { resolve: MODULE_PACKAGE_NAMES[Modules.ORDER] }, + { resolve: MODULE_PACKAGE_NAMES[Modules.SETTINGS] }, { resolve: MODULE_PACKAGE_NAMES[Modules.AUTH], diff --git a/packages/core/utils/src/modules-sdk/definition.ts b/packages/core/utils/src/modules-sdk/definition.ts index 31223e6673..44f79d6b70 100644 --- a/packages/core/utils/src/modules-sdk/definition.ts +++ b/packages/core/utils/src/modules-sdk/definition.ts @@ -26,6 +26,7 @@ export const Modules = { NOTIFICATION: "notification", INDEX: "index", LOCKING: "locking", + SETTINGS: "settings", } as const export const MODULE_PACKAGE_NAMES = { @@ -56,6 +57,7 @@ export const MODULE_PACKAGE_NAMES = { [Modules.NOTIFICATION]: "@medusajs/medusa/notification", [Modules.INDEX]: "@medusajs/medusa/index-module", [Modules.LOCKING]: "@medusajs/medusa/locking", + [Modules.SETTINGS]: "@medusajs/medusa/settings", } export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries( diff --git a/packages/medusa/package.json b/packages/medusa/package.json index e9cb269dd7..5f85c4b114 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -108,6 +108,7 @@ "@medusajs/promotion": "2.9.0", "@medusajs/region": "2.9.0", "@medusajs/sales-channel": "2.9.0", + "@medusajs/settings": "2.9.0", "@medusajs/stock-location": "2.9.0", "@medusajs/store": "2.9.0", "@medusajs/tax": "2.9.0", diff --git a/packages/medusa/src/modules/settings.ts b/packages/medusa/src/modules/settings.ts new file mode 100644 index 0000000000..b4aae46af6 --- /dev/null +++ b/packages/medusa/src/modules/settings.ts @@ -0,0 +1,6 @@ +import SettingsModule from "@medusajs/settings" + +export * from "@medusajs/settings" + +export default SettingsModule +export const discoveryPath = require.resolve("@medusajs/settings") \ No newline at end of file diff --git a/packages/modules/settings/README.md b/packages/modules/settings/README.md new file mode 100644 index 0000000000..9f26a98e55 --- /dev/null +++ b/packages/modules/settings/README.md @@ -0,0 +1,17 @@ +# Settings Module + +The settings module provides functionality for managing user preferences and configurations in Medusa. + +## Features + +- **View Configurations**: Save and manage table view configurations (column visibility, order, widths) +- **User Preferences**: Store arbitrary user preferences in a key-value format +- **System Defaults**: Admin users can set default configurations for all users + +## Module Options + +```js +const settingsModuleOptions = { + // Module options can be added here if needed +} +``` \ No newline at end of file diff --git a/packages/modules/settings/integration-tests/__tests__/settings-module.spec.ts b/packages/modules/settings/integration-tests/__tests__/settings-module.spec.ts new file mode 100644 index 0000000000..a7fa363e60 --- /dev/null +++ b/packages/modules/settings/integration-tests/__tests__/settings-module.spec.ts @@ -0,0 +1,423 @@ +import { Modules } from "@medusajs/utils" +import { moduleIntegrationTestRunner } from "@medusajs/test-utils" +import { SettingsTypes } from "@medusajs/types" + +jest.setTimeout(30000) + +moduleIntegrationTestRunner({ + moduleName: Modules.SETTINGS, + testSuite: ({ service }) => { + describe("SettingsModuleService", function () { + describe("ViewConfiguration", function () { + it("should create a view configuration", async () => { + const viewConfig = await service.createViewConfigurations({ + entity: "orders", + name: "My Orders View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "status", "created_at"], + column_order: ["id", "status", "created_at"], + column_widths: { id: 100, status: 150 }, + filters: { status: ["pending", "completed"] }, + sorting: { id: "created_at", desc: true }, + search: "", + }, + }) + + expect(viewConfig).toEqual( + expect.objectContaining({ + id: expect.any(String), + entity: "orders", + name: "My Orders View", + user_id: "user_123", + }) + ) + }) + + it("should update a view configuration and remove filters", async () => { + // Create a view with filters + const viewConfig = await service.createViewConfigurations({ + entity: "products", + name: "Filtered Products View", + user_id: "user_456", + configuration: { + visible_columns: ["id", "title", "status"], + column_order: ["id", "title", "status"], + filters: { + status: ["draft", "published"], + collection_id: ["col_123", "col_456"], + }, + sorting: { id: "created_at", desc: true }, + }, + }) + + expect(viewConfig.configuration.filters).toEqual({ + status: ["draft", "published"], + collection_id: ["col_123", "col_456"], + }) + + // Update the view to remove filters + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: ["id", "title", "status"], + column_order: ["id", "title", "status"], + filters: {}, // Empty filters object + sorting: { id: "created_at", desc: true }, + }, + } + ) + + expect(updatedConfig.configuration.filters).toEqual({}) + + // Retrieve the view again to ensure filters were persisted as empty + const retrievedConfig = await service.retrieveViewConfiguration( + viewConfig.id + ) + + expect(retrievedConfig.configuration.filters).toEqual({}) + }) + + it("should update view configuration with partial configuration updates", async () => { + // Create a view with full configuration + const viewConfig = await service.createViewConfigurations({ + entity: "customers", + name: "Customer View", + user_id: "user_789", + configuration: { + visible_columns: ["id", "name", "email"], + column_order: ["id", "name", "email"], + filters: { + has_account: true, + groups: ["vip", "regular"], + }, + sorting: { id: "created_at", desc: false }, + search: "test search", + }, + }) + + // Update only filters (should preserve other configuration) + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: ["id", "name", "email"], + column_order: ["id", "name", "email"], + filters: { has_account: false }, // Changed filters + sorting: { id: "created_at", desc: false }, + search: "test search", + }, + } + ) + + expect(updatedConfig.configuration).toEqual({ + visible_columns: ["id", "name", "email"], + column_order: ["id", "name", "email"], + filters: { has_account: false }, + sorting: { id: "created_at", desc: false }, + search: "test search", + column_widths: {}, // Default value when not provided + }) + }) + + it("should update only the name field without affecting configuration", async () => { + // Create a view with full configuration + const viewConfig = await service.createViewConfigurations({ + entity: "orders", + name: "Original Name", + user_id: "user_123", + configuration: { + visible_columns: ["id", "status", "total"], + column_order: ["id", "status", "total"], + column_widths: { id: 100, status: 150, total: 200 }, + filters: { status: ["pending", "completed"] }, + sorting: { id: "created_at", desc: true }, + search: "test search", + }, + }) + + // Update only the name field + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { name: "Updated Name" } + ) + + expect(updatedConfig.name).toBe("Updated Name") + expect(updatedConfig.configuration).toEqual(viewConfig.configuration) + }) + + it("should completely replace filters when updating configuration", async () => { + // Create a view with complex filters + const viewConfig = await service.createViewConfigurations({ + entity: "products", + name: "Product View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "title"], + column_order: ["id", "title"], + filters: { + status: ["draft", "published"], + collection_id: ["col_123", "col_456"], + price_range: { min: 10, max: 100 }, + }, + sorting: { id: "created_at", desc: true }, + }, + }) + + // Update with new filters + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: ["id", "title"], + column_order: ["id", "title"], + filters: { category: ["electronics"] }, // Completely different filters + sorting: { id: "created_at", desc: true }, + }, + } + ) + + expect(updatedConfig.configuration.filters).toEqual({ + category: ["electronics"], + }) + // Verify old filters are gone + expect(updatedConfig.configuration.filters.status).toBeUndefined() + expect(updatedConfig.configuration.filters.collection_id).toBeUndefined() + expect(updatedConfig.configuration.filters.price_range).toBeUndefined() + }) + + it("should remove filters when explicitly set to empty object", async () => { + // Create a view with filters + const viewConfig = await service.createViewConfigurations({ + entity: "customers", + name: "Customer View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "name", "email"], + column_order: ["id", "name", "email"], + filters: { + has_account: true, + groups: ["vip", "regular"], + }, + sorting: { id: "created_at", desc: false }, + }, + }) + + // Update configuration with empty filters + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: ["id", "name", "email"], + column_order: ["id", "name", "email"], + filters: {}, // Empty filters + sorting: { id: "created_at", desc: false }, + }, + } + ) + + expect(updatedConfig.configuration.filters).toEqual({}) + }) + + it("should preserve other configuration properties when updating specific ones", async () => { + // Create a view with full configuration + const viewConfig = await service.createViewConfigurations({ + entity: "inventory", + name: "Inventory View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "sku", "quantity"], + column_order: ["id", "sku", "quantity"], + column_widths: { id: 80, sku: 120, quantity: 100 }, + filters: { location: ["warehouse_1"] }, + sorting: { id: "sku", desc: false }, + search: "original search", + }, + }) + + // Update only filters and sorting + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: viewConfig.configuration.visible_columns, + column_order: viewConfig.configuration.column_order, + filters: { location: ["warehouse_2"] }, + sorting: { id: "quantity", desc: true }, + // Note: not providing column_widths and search + }, + } + ) + + expect(updatedConfig.configuration.filters).toEqual({ + location: ["warehouse_2"], + }) + expect(updatedConfig.configuration.sorting).toEqual({ + id: "quantity", + desc: true, + }) + // These should be preserved + expect(updatedConfig.configuration.visible_columns).toEqual( + viewConfig.configuration.visible_columns + ) + expect(updatedConfig.configuration.column_order).toEqual( + viewConfig.configuration.column_order + ) + // These should have default values since not provided + expect(updatedConfig.configuration.column_widths).toEqual({}) + expect(updatedConfig.configuration.search).toBe("") + }) + + it("should handle missing configuration properties with defaults", async () => { + // Create a view with full configuration + const viewConfig = await service.createViewConfigurations({ + entity: "payments", + name: "Payment View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "amount", "status"], + column_order: ["id", "amount", "status"], + column_widths: { id: 100, amount: 150, status: 120 }, + filters: { status: ["completed"] }, + sorting: { id: "created_at", desc: true }, + search: "payment search", + }, + }) + + // Update with partial configuration (only visible_columns) + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: ["id", "amount"], + column_order: ["id", "amount"], + // Not providing filters, sorting, search, column_widths + }, + } + ) + + expect(updatedConfig.configuration).toEqual({ + visible_columns: ["id", "amount"], + column_order: ["id", "amount"], + column_widths: {}, // Default + filters: {}, // Default + sorting: null, // Default + search: "", // Default + }) + }) + + it("should update multiple view configurations when using selector", async () => { + // Create multiple views for the same entity + const view1 = await service.createViewConfigurations({ + entity: "orders", + name: "Orders View 1", + user_id: "user_123", + configuration: { + visible_columns: ["id", "status"], + column_order: ["id", "status"], + filters: { status: ["pending"] }, + }, + }) + + const view2 = await service.createViewConfigurations({ + entity: "orders", + name: "Orders View 2", + user_id: "user_456", + configuration: { + visible_columns: ["id", "total"], + column_order: ["id", "total"], + filters: { status: ["completed"] }, + }, + }) + + const view3 = await service.createViewConfigurations({ + entity: "products", // Different entity + name: "Products View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "title"], + column_order: ["id", "title"], + }, + }) + + // Update using selector for entity "orders" + const updatedConfigs = await service.updateViewConfigurations( + { entity: "orders" }, + { + configuration: { + visible_columns: ["id", "status", "total"], + column_order: ["id", "status", "total"], + filters: {}, + }, + } + ) + + // Should return an array + expect(Array.isArray(updatedConfigs)).toBe(true) + expect(updatedConfigs).toHaveLength(2) + + // Both orders views should be updated + const updatedIds = updatedConfigs.map((v) => v.id).sort() + expect(updatedIds).toEqual([view1.id, view2.id].sort()) + + // All should have the new configuration + updatedConfigs.forEach((config) => { + expect(config.configuration.visible_columns).toEqual([ + "id", + "status", + "total", + ]) + expect(config.configuration.filters).toEqual({}) + }) + + // Products view should not be affected + const productView = await service.retrieveViewConfiguration(view3.id) + expect(productView.configuration.visible_columns).toEqual(["id", "title"]) + }) + + it("should return empty array when no views match selector", async () => { + // Try to update with a selector that matches no views + const result = await service.updateViewConfigurations( + { entity: "non_existent_entity" }, + { name: "New Name" } + ) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(0) + }) + + it("should handle null values in configuration", async () => { + // Create a view with sorting + const viewConfig = await service.createViewConfigurations({ + entity: "shipping", + name: "Shipping View", + user_id: "user_123", + configuration: { + visible_columns: ["id", "carrier", "tracking"], + column_order: ["id", "carrier", "tracking"], + sorting: { id: "created_at", desc: true }, + search: "fedex", + }, + }) + + // Update with sorting: null + const updatedConfig = await service.updateViewConfigurations( + viewConfig.id, + { + configuration: { + visible_columns: viewConfig.configuration.visible_columns, + column_order: viewConfig.configuration.column_order, + sorting: null, + search: "", // Also test empty string + }, + } + ) + + expect(updatedConfig.configuration.sorting).toBeNull() + expect(updatedConfig.configuration.search).toBe("") + }) + }) + }) + }, +}) diff --git a/packages/modules/settings/jest.config.js b/packages/modules/settings/jest.config.js new file mode 100644 index 0000000000..e8c2a9cc22 --- /dev/null +++ b/packages/modules/settings/jest.config.js @@ -0,0 +1,10 @@ +const defineJestConfig = require("../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + "^@utils": "/src/utils", + }, +}) \ No newline at end of file diff --git a/packages/modules/settings/mikro-orm.config.dev.ts b/packages/modules/settings/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..8440969151 --- /dev/null +++ b/packages/modules/settings/mikro-orm.config.dev.ts @@ -0,0 +1,6 @@ +import * as entities from "./src/models" +import { defineMikroOrmCliConfig, Modules } from "@medusajs/framework/utils" + +export default defineMikroOrmCliConfig(Modules.SETTINGS, { + entities: Object.values(entities), +}) diff --git a/packages/modules/settings/package.json b/packages/modules/settings/package.json new file mode 100644 index 0000000000..ece884ed3f --- /dev/null +++ b/packages/modules/settings/package.json @@ -0,0 +1,59 @@ +{ + "name": "@medusajs/settings", + "version": "2.9.0", + "description": "Medusa Settings module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/settings" + }, + "publishConfig": { + "access": "public" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "watch": "tsc --build --watch", + "watch:test": "tsc --build tsconfig.spec.json --watch", + "resolve:aliases": "tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && tsc-alias -p tsconfig.resolved.json && rimraf tsconfig.resolved.json", + "build": "rimraf dist && tsc --build && npm run resolve:aliases", + "test": "jest --runInBand --passWithNoTests --bail --forceExit -- src", + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts", + "migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial", + "migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create", + "migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up", + "orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm cache:clear" + }, + "devDependencies": { + "@medusajs/framework": "2.9.0", + "@medusajs/test-utils": "2.9.0", + "@mikro-orm/cli": "6.4.3", + "@mikro-orm/core": "6.4.3", + "@mikro-orm/migrations": "6.4.3", + "@mikro-orm/postgresql": "6.4.3", + "@swc/core": "^1.7.28", + "@swc/jest": "^0.2.36", + "jest": "^29.7.0", + "rimraf": "^3.0.2", + "tsc-alias": "^1.8.6", + "typescript": "^5.6.2" + }, + "peerDependencies": { + "@medusajs/framework": "2.9.0", + "@mikro-orm/core": "6.4.3", + "@mikro-orm/migrations": "6.4.3", + "@mikro-orm/postgresql": "6.4.3", + "awilix": "^8.0.1" + } +} diff --git a/packages/modules/settings/src/index.ts b/packages/modules/settings/src/index.ts new file mode 100644 index 0000000000..9284bb670f --- /dev/null +++ b/packages/modules/settings/src/index.ts @@ -0,0 +1,7 @@ +import { SettingsModuleService } from "@/services" +import { Module } from "@medusajs/framework/utils" +import { Modules } from "@medusajs/utils" + +export default Module(Modules.SETTINGS, { + service: SettingsModuleService, +}) diff --git a/packages/modules/settings/src/migrations/.snapshot-medusa-settings.json b/packages/modules/settings/src/migrations/.snapshot-medusa-settings.json new file mode 100644 index 0000000000..44ee8fd4a5 --- /dev/null +++ b/packages/modules/settings/src/migrations/.snapshot-medusa-settings.json @@ -0,0 +1,269 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "user_id": { + "name": "user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "key": { + "name": "key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "value": { + "name": "value", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "user_preference", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_user_preference_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_user_preference_deleted_at\" ON \"user_preference\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_user_preference_user_id_key_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_user_preference_user_id_key_unique\" ON \"user_preference\" (user_id, key) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_user_preference_user_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_user_preference_user_id\" ON \"user_preference\" (user_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "user_preference_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "entity": { + "name": "entity", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "user_id": { + "name": "user_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "is_system_default": { + "name": "is_system_default", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "view_configuration", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_view_configuration_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_deleted_at\" ON \"view_configuration\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_view_configuration_entity_user_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_entity_user_id\" ON \"view_configuration\" (entity, user_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_view_configuration_entity_is_system_default", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_entity_is_system_default\" ON \"view_configuration\" (entity, is_system_default) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_view_configuration_user_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_view_configuration_user_id\" ON \"view_configuration\" (user_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "view_configuration_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + } + ], + "nativeEnums": {} +} diff --git a/packages/modules/settings/src/migrations/Migration20250717162007.ts b/packages/modules/settings/src/migrations/Migration20250717162007.ts new file mode 100644 index 0000000000..b8c85d5a04 --- /dev/null +++ b/packages/modules/settings/src/migrations/Migration20250717162007.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250717162007 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "user_preference" drop constraint if exists "user_preference_user_id_key_unique";`); + this.addSql(`create table if not exists "user_preference" ("id" text not null, "user_id" text not null, "key" text not null, "value" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "user_preference_pkey" primary key ("id"));`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_user_preference_deleted_at" ON "user_preference" (deleted_at) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_user_preference_user_id_key_unique" ON "user_preference" (user_id, key) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_user_preference_user_id" ON "user_preference" (user_id) WHERE deleted_at IS NULL;`); + + this.addSql(`create table if not exists "view_configuration" ("id" text not null, "entity" text not null, "name" text null, "user_id" text null, "is_system_default" boolean not null default false, "configuration" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "view_configuration_pkey" primary key ("id"));`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_deleted_at" ON "view_configuration" (deleted_at) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_entity_user_id" ON "view_configuration" (entity, user_id) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_entity_is_system_default" ON "view_configuration" (entity, is_system_default) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_view_configuration_user_id" ON "view_configuration" (user_id) WHERE deleted_at IS NULL;`); + } + +} diff --git a/packages/modules/settings/src/models/index.ts b/packages/modules/settings/src/models/index.ts new file mode 100644 index 0000000000..e0db5e0532 --- /dev/null +++ b/packages/modules/settings/src/models/index.ts @@ -0,0 +1,2 @@ +export { ViewConfiguration } from "./view-configuration" +export { UserPreference } from "./user-preference" \ No newline at end of file diff --git a/packages/modules/settings/src/models/user-preference.ts b/packages/modules/settings/src/models/user-preference.ts new file mode 100644 index 0000000000..cda6ebe59b --- /dev/null +++ b/packages/modules/settings/src/models/user-preference.ts @@ -0,0 +1,18 @@ +import { model } from "@medusajs/framework/utils" + +export const UserPreference = model + .define("user_preference", { + id: model.id({ prefix: "usrpref" }).primaryKey(), + user_id: model.text(), + key: model.text().searchable(), + value: model.json(), + }) + .indexes([ + { + on: ["user_id", "key"], + unique: true, + }, + { + on: ["user_id"], + }, + ]) diff --git a/packages/modules/settings/src/models/view-configuration.ts b/packages/modules/settings/src/models/view-configuration.ts new file mode 100644 index 0000000000..2893fdf2ec --- /dev/null +++ b/packages/modules/settings/src/models/view-configuration.ts @@ -0,0 +1,22 @@ +import { model } from "@medusajs/framework/utils" + +export const ViewConfiguration = model + .define("view_configuration", { + id: model.id({ prefix: "vconf" }).primaryKey(), + entity: model.text().searchable(), + name: model.text().searchable().nullable(), + user_id: model.text().nullable(), + is_system_default: model.boolean().default(false), + configuration: model.json(), + }) + .indexes([ + { + on: ["entity", "user_id"], + }, + { + on: ["entity", "is_system_default"], + }, + { + on: ["user_id"], + }, + ]) diff --git a/packages/modules/settings/src/services/index.ts b/packages/modules/settings/src/services/index.ts new file mode 100644 index 0000000000..0e75335f0d --- /dev/null +++ b/packages/modules/settings/src/services/index.ts @@ -0,0 +1 @@ +export { default as SettingsModuleService } from "./settings-module-service" \ No newline at end of file diff --git a/packages/modules/settings/src/services/settings-module-service.ts b/packages/modules/settings/src/services/settings-module-service.ts new file mode 100644 index 0000000000..6d11c68da0 --- /dev/null +++ b/packages/modules/settings/src/services/settings-module-service.ts @@ -0,0 +1,365 @@ +import { + Context, + DAL, + InferEntityType, + InternalModuleDeclaration, + ModulesSdkTypes, + SettingsTypes, +} from "@medusajs/framework/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + MedusaError, + MedusaService, +} from "@medusajs/framework/utils" +import { ViewConfiguration, UserPreference } from "@/models" + +type InjectedDependencies = { + baseRepository: DAL.RepositoryService + viewConfigurationService: ModulesSdkTypes.IMedusaInternalService + userPreferenceService: ModulesSdkTypes.IMedusaInternalService +} + +export default class SettingsModuleService + extends MedusaService<{ + ViewConfiguration: { dto: SettingsTypes.ViewConfigurationDTO } + UserPreference: { dto: SettingsTypes.UserPreferenceDTO } + }>({ ViewConfiguration, UserPreference }) + implements SettingsTypes.ISettingsModuleService +{ + protected baseRepository_: DAL.RepositoryService + protected readonly viewConfigurationService_: ModulesSdkTypes.IMedusaInternalService< + InferEntityType + > + protected readonly userPreferenceService_: ModulesSdkTypes.IMedusaInternalService< + InferEntityType + > + + constructor( + { + baseRepository, + viewConfigurationService, + userPreferenceService, + }: InjectedDependencies, + protected readonly moduleDeclaration: InternalModuleDeclaration + ) { + super(...arguments) + this.baseRepository_ = baseRepository + this.viewConfigurationService_ = viewConfigurationService + this.userPreferenceService_ = userPreferenceService + } + + @InjectTransactionManager() + // @ts-expect-error + async createViewConfigurations( + data: + | SettingsTypes.CreateViewConfigurationDTO + | SettingsTypes.CreateViewConfigurationDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise< + SettingsTypes.ViewConfigurationDTO | SettingsTypes.ViewConfigurationDTO[] + > { + // Convert to array for validation only + const isArrayInput = Array.isArray(data) + const dataArray = isArrayInput ? data : [data] + + // Validate system defaults + for (const config of dataArray) { + if (config.is_system_default && config.user_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "System default view configurations cannot have a user_id" + ) + } + + if (config.is_system_default) { + // Check if a system default already exists for this entity + const existingDefault = await this.viewConfigurationService_.list( + { + entity: config.entity, + is_system_default: true, + }, + { select: ["id"] }, + sharedContext + ) + + if (existingDefault.length > 0) { + throw new MedusaError( + MedusaError.Types.DUPLICATE_ERROR, + `A system default view configuration already exists for entity: ${config.entity}` + ) + } + } + } + + const result = await super.createViewConfigurations( + dataArray, + sharedContext + ) + return isArrayInput ? result : result[0] + } + + @InjectTransactionManager() + // @ts-expect-error + async updateViewConfigurations( + idOrSelector: string | SettingsTypes.FilterableViewConfigurationProps, + data: SettingsTypes.UpdateViewConfigurationDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise< + SettingsTypes.ViewConfigurationDTO | SettingsTypes.ViewConfigurationDTO[] + > { + let selector: SettingsTypes.FilterableViewConfigurationProps = {} + + if (typeof idOrSelector === "string") { + selector = { id: idOrSelector } + } else { + selector = idOrSelector + } + + // Special handling for configuration updates to ensure replacement instead of merge + if (data.configuration) { + // First, get the entities to update + const entities = await this.viewConfigurationService_.list( + selector, + {}, + sharedContext + ) + + if (entities.length === 0) { + return typeof idOrSelector === "string" ? [] : [] + } + + // Use upsertWithReplace to update the configuration field without merging + const updateDataArray = entities.map((entity) => ({ + id: entity.id, + ...data, + configuration: { + visible_columns: data.configuration?.visible_columns ?? [], + column_order: data.configuration?.column_order ?? [], + column_widths: + data.configuration?.column_widths !== undefined + ? data.configuration.column_widths + : {}, + filters: + data.configuration?.filters !== undefined + ? data.configuration.filters + : {}, + sorting: + data.configuration?.sorting !== undefined + ? data.configuration.sorting + : null, + search: + data.configuration?.search !== undefined + ? data.configuration.search + : "", + }, + })) + + // Use upsertWithReplace which uses nativeUpdateMany internally and doesn't merge JSON fields + const { entities: updatedEntities } = + await this.viewConfigurationService_.upsertWithReplace( + updateDataArray, + { relations: [] }, + sharedContext + ) + + const serialized = await this.baseRepository_.serialize< + SettingsTypes.ViewConfigurationDTO[] + >(updatedEntities, { populate: true }) + + return typeof idOrSelector === "string" ? serialized[0] : serialized + } + + // For non-configuration updates, use the standard update method + const updated = await this.viewConfigurationService_.update( + { selector, data }, + sharedContext + ) + + const serialized = await this.baseRepository_.serialize< + SettingsTypes.ViewConfigurationDTO[] + >(updated, { populate: true }) + + return typeof idOrSelector === "string" ? serialized[0] : serialized + } + + @InjectManager() + async getUserPreference( + userId: string, + key: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const prefs = await this.userPreferenceService_.list( + { user_id: userId, key }, + {}, + sharedContext + ) + + if (prefs.length === 0) { + return null + } + + return await this.baseRepository_.serialize( + prefs[0], + { populate: true } + ) + } + + @InjectTransactionManager() + async setUserPreference( + userId: string, + key: string, + value: any, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const existing = await this.userPreferenceService_.list( + { user_id: userId, key }, + { select: ["id"] }, + sharedContext + ) + + let result: InferEntityType + + if (existing.length > 0) { + const updated = await this.userPreferenceService_.update( + [{ id: existing[0].id, value }], + sharedContext + ) + result = updated[0] + } else { + const created = await this.userPreferenceService_.create( + { user_id: userId, key, value }, + sharedContext + ) + result = created[0] + } + + return await this.baseRepository_.serialize( + result, + { populate: true } + ) + } + + @InjectManager() + async getActiveViewConfiguration( + entity: string, + userId: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + // Check if user has an active view preference + const activeViewPref = await this.getUserPreference( + userId, + `active_view.${entity}`, + sharedContext + ) + + // Check if we have a preference with a view configuration ID (not explicitly null) + if ( + activeViewPref && + activeViewPref.value?.viewConfigurationId && + activeViewPref.value.viewConfigurationId !== null + ) { + try { + return await this.retrieveViewConfiguration( + activeViewPref.value.viewConfigurationId, + {}, + sharedContext + ) + } catch (error) { + // View configuration might have been deleted + } + } + + // If we have an explicit null preference, or no preference, or a deleted view + // We should check for defaults in this order: + + // Check if user has any personal views (only if no explicit null preference) + if (!activeViewPref || activeViewPref.value?.viewConfigurationId !== null) { + const [personalView] = await this.listViewConfigurations( + { entity, user_id: userId }, + { take: 1, order: { created_at: "ASC" } }, + sharedContext + ) + + if (personalView) { + return personalView + } + } + + // Fall back to system default + const systemDefaults = await this.listViewConfigurations( + { entity, is_system_default: true }, + {}, + sharedContext + ) + + return systemDefaults.length > 0 ? systemDefaults[0] : null + } + + @InjectTransactionManager() + async setActiveViewConfiguration( + entity: string, + userId: string, + viewConfigurationId: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + // Verify the view configuration exists and user has access + const viewConfig = await this.retrieveViewConfiguration( + viewConfigurationId, + {}, + sharedContext + ) + + if (viewConfig.entity !== entity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `View configuration ${viewConfigurationId} is not for entity ${entity}` + ) + } + + if (viewConfig.user_id && viewConfig.user_id !== userId) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `User ${userId} does not have access to view configuration ${viewConfigurationId}` + ) + } + + await this.setUserPreference( + userId, + `active_view.${entity}`, + { viewConfigurationId }, + sharedContext + ) + } + + @InjectManager() + async getSystemDefaultViewConfiguration( + entity: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const systemDefaults = await this.listViewConfigurations( + { entity, is_system_default: true }, + {}, + sharedContext + ) + + return systemDefaults.length > 0 ? systemDefaults[0] : null + } + + @InjectTransactionManager() + async clearActiveViewConfiguration( + entity: string, + userId: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + // Instead of deleting, set the preference to null + // This ensures we're using the same transaction pattern as setActiveViewConfiguration + await this.setUserPreference( + userId, + `active_view.${entity}`, + { viewConfigurationId: null }, + sharedContext + ) + } +} diff --git a/packages/modules/settings/src/types/index.ts b/packages/modules/settings/src/types/index.ts new file mode 100644 index 0000000000..e33cf96478 --- /dev/null +++ b/packages/modules/settings/src/types/index.ts @@ -0,0 +1,5 @@ +import { Logger } from "@medusajs/framework/types" + +export type InitializeModuleInjectableDependencies = { + logger?: Logger +} diff --git a/packages/modules/settings/tsconfig.json b/packages/modules/settings/tsconfig.json new file mode 100644 index 0000000000..a20a4267ab --- /dev/null +++ b/packages/modules/settings/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../_tsconfig.base.json", + "compilerOptions": { + "paths": { + "@/models": [ + "./src/models" + ], + "@/services": [ + "./src/services" + ], + "@/repositories": [ + "./src/repositories" + ], + "@/types": [ + "./src/types" + ] + } + } +} diff --git a/yarn.lock b/yarn.lock index 9e382b6655..b7f39d753b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6675,6 +6675,7 @@ __metadata: "@medusajs/promotion": 2.9.0 "@medusajs/region": 2.9.0 "@medusajs/sales-channel": 2.9.0 + "@medusajs/settings": 2.9.0 "@medusajs/stock-location": 2.9.0 "@medusajs/store": 2.9.0 "@medusajs/tax": 2.9.0 @@ -7066,6 +7067,31 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/settings@2.9.0, @medusajs/settings@workspace:packages/modules/settings": + version: 0.0.0-use.local + resolution: "@medusajs/settings@workspace:packages/modules/settings" + dependencies: + "@medusajs/framework": 2.9.0 + "@medusajs/test-utils": 2.9.0 + "@mikro-orm/cli": 6.4.3 + "@mikro-orm/core": 6.4.3 + "@mikro-orm/migrations": 6.4.3 + "@mikro-orm/postgresql": 6.4.3 + "@swc/core": ^1.7.28 + "@swc/jest": ^0.2.36 + jest: ^29.7.0 + rimraf: ^3.0.2 + tsc-alias: ^1.8.6 + typescript: ^5.6.2 + peerDependencies: + "@medusajs/framework": 2.9.0 + "@mikro-orm/core": 6.4.3 + "@mikro-orm/migrations": 6.4.3 + "@mikro-orm/postgresql": 6.4.3 + awilix: ^8.0.1 + languageName: unknown + linkType: soft + "@medusajs/stock-location@2.9.0, @medusajs/stock-location@workspace:^, @medusajs/stock-location@workspace:packages/modules/stock-location": version: 0.0.0-use.local resolution: "@medusajs/stock-location@workspace:packages/modules/stock-location"