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
This commit is contained in:
Sebastian Rindom authored and GitHub committed 2025-08-15 10:59:54 +02:00
1 parent 9b37a2c9f4
commit f7fc05307f
28 files changed
+1736

No files matched your search

+17
View File
@@ -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
}
```
@@ -0,0 +1,423 @@
import { Modules } from "@medusajs/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import { SettingsTypes } from "@medusajs/types"
jest.setTimeout(30000)
moduleIntegrationTestRunner<SettingsTypes.ISettingsModuleService>({
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("")
})
})
})
},
})
+10
View File
@@ -0,0 +1,10 @@
const defineJestConfig = require("../../../define_jest_config")
module.exports = defineJestConfig({
moduleNameMapper: {
"^@models": "<rootDir>/src/models",
"^@services": "<rootDir>/src/services",
"^@repositories": "<rootDir>/src/repositories",
"^@types": "<rootDir>/src/types",
"^@utils": "<rootDir>/src/utils",
},
})
@@ -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),
})
+59
View File
@@ -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"
}
}
+7
View File
@@ -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,
})
@@ -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": {}
}
@@ -0,0 +1,19 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20250717162007 extends Migration {
override async up(): Promise<void> {
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;`);
}
}
@@ -0,0 +1,2 @@
export { ViewConfiguration } from "./view-configuration"
export { UserPreference } from "./user-preference"
@@ -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"],
},
])
@@ -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"],
},
])
@@ -0,0 +1 @@
export { default as SettingsModuleService } from "./settings-module-service"
@@ -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<any>
userPreferenceService: ModulesSdkTypes.IMedusaInternalService<any>
}
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<typeof ViewConfiguration>
>
protected readonly userPreferenceService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof UserPreference>
>
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<SettingsTypes.UserPreferenceDTO | null> {
const prefs = await this.userPreferenceService_.list(
{ user_id: userId, key },
{},
sharedContext
)
if (prefs.length === 0) {
return null
}
return await this.baseRepository_.serialize<SettingsTypes.UserPreferenceDTO>(
prefs[0],
{ populate: true }
)
}
@InjectTransactionManager()
async setUserPreference(
userId: string,
key: string,
value: any,
@MedusaContext() sharedContext: Context = {}
): Promise<SettingsTypes.UserPreferenceDTO> {
const existing = await this.userPreferenceService_.list(
{ user_id: userId, key },
{ select: ["id"] },
sharedContext
)
let result: InferEntityType<typeof UserPreference>
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<SettingsTypes.UserPreferenceDTO>(
result,
{ populate: true }
)
}
@InjectManager()
async getActiveViewConfiguration(
entity: string,
userId: string,
@MedusaContext() sharedContext: Context = {}
): Promise<SettingsTypes.ViewConfigurationDTO | null> {
// 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<void> {
// 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<SettingsTypes.ViewConfigurationDTO | null> {
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<void> {
// 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
)
}
}
@@ -0,0 +1,5 @@
import { Logger } from "@medusajs/framework/types"
export type InitializeModuleInjectableDependencies = {
logger?: Logger
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../../_tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/models": [
"./src/models"
],
"@/services": [
"./src/services"
],
"@/repositories": [
"./src/repositories"
],
"@/types": [
"./src/types"
]
}
}
}