From 726eb469230222e80c669c34740a7fda86884325 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 15 Sep 2025 14:32:16 +0300 Subject: [PATCH 1/7] Add shopInfo.tags that will return existing product tags along with defaults #441 --- changelog.md | 1 + examples/kitchensink-express/.env.defaults | 1 + examples/kitchensink/.env.defaults | 1 + packages/api/src/resolvers/type/shop-types.ts | 15 +++++++++++++++ packages/api/src/schema/types/shop.ts | 1 + .../src/module/configureProductsModule.ts | 4 ++++ 6 files changed, 23 insertions(+) diff --git a/changelog.md b/changelog.md index 945dc4e090..205e14c952 100644 --- a/changelog.md +++ b/changelog.md @@ -36,6 +36,7 @@ registerProductDiscoverabilityFilter({ hiddenTagValue: 'hidden' }); - Add filters to `Query.orders` to filter by payment and delivery providers - Allow to call `Query.deliveryInterfaces` without a type - Add support for SMS providers BudgetSMS and Bulkgate next to Twilio on our privacy-focused mission to always support European alternatives. +- Add `tags` field in `shopInfo` that will return existing tags used for products and can also be customized to include default tags using `UNCHAINED_DEFAULT_PRODUCT_TAGS` by default it used **new, featured & bestseller** ## Patch - Update to ESlint 9 diff --git a/examples/kitchensink-express/.env.defaults b/examples/kitchensink-express/.env.defaults index af9f0a30ae..06d3326701 100644 --- a/examples/kitchensink-express/.env.defaults +++ b/examples/kitchensink-express/.env.defaults @@ -9,3 +9,4 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none +UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller \ No newline at end of file diff --git a/examples/kitchensink/.env.defaults b/examples/kitchensink/.env.defaults index af9f0a30ae..6b7957e71b 100644 --- a/examples/kitchensink/.env.defaults +++ b/examples/kitchensink/.env.defaults @@ -9,3 +9,4 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none +UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller diff --git a/packages/api/src/resolvers/type/shop-types.ts b/packages/api/src/resolvers/type/shop-types.ts index 7b430b5abd..5df0da4d1d 100644 --- a/packages/api/src/resolvers/type/shop-types.ts +++ b/packages/api/src/resolvers/type/shop-types.ts @@ -5,12 +5,14 @@ import { checkAction } from '../../acl.js'; import { allRoles, actions } from '../../roles/index.js'; type HelperType = (root: never, params: never, context: Context) => Promise; +const { UNCHAINED_DEFAULT_PRODUCT_TAGS = 'featured,new,bestseller' } = process.env; export interface ShopHelperTypes { _id: () => string; country: HelperType; language: HelperType; userRoles: HelperType; + tags: HelperType; } export const Shop: ShopHelperTypes = { @@ -31,4 +33,17 @@ export const Shop: ShopHelperTypes = { .map(({ name }) => name) .filter((name) => name.substring(0, 2) !== '__'); }, + tags: async (root, _, { modules }: Context) => { + const existingProductTags = await modules.products.existingTags(); + const normalizedTags = Array.from( + new Set( + (UNCHAINED_DEFAULT_PRODUCT_TAGS || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + .concat(existingProductTags), + ), + ); + return normalizedTags; + }, }; diff --git a/packages/api/src/schema/types/shop.ts b/packages/api/src/schema/types/shop.ts index 78c46e78c5..ff8afe6f67 100644 --- a/packages/api/src/schema/types/shop.ts +++ b/packages/api/src/schema/types/shop.ts @@ -35,6 +35,7 @@ export default [ userRoles: [String!]! adminUiConfig: AdminUiConfig! vapidPublicKey: String + tags: [String!]! } `, ]; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index e86df8ec43..06ae71fc64 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -600,6 +600,10 @@ export const configureProductsModule = async ({ }, texts: productTexts, + existingTags: async (): Promise => { + const tags = await Products.distinct('tags', { tags: { $exists: true } }); + return tags.sort(); + }, }; }; From 9efbaefff0d578caa0cecc611ae8ff02b49cdb92 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 22 Sep 2025 10:34:51 +0300 Subject: [PATCH 2/7] Add assortmentTags aggregate field in shopInfo --- examples/kitchensink-express/.env.defaults | 3 ++- examples/kitchensink/.env.defaults | 1 + packages/api/src/resolvers/type/shop-types.ts | 21 ++++++++++++++++--- packages/api/src/schema/types/shop.ts | 3 ++- .../src/assortments-settings.ts | 4 ++++ .../src/module/configureAssortmentsModule.ts | 4 ++++ .../src/module/configureProductsModule.ts | 2 +- .../core-products/src/products-settings.ts | 5 ++++- packages/core/src/modules.ts | 1 + 9 files changed, 37 insertions(+), 7 deletions(-) diff --git a/examples/kitchensink-express/.env.defaults b/examples/kitchensink-express/.env.defaults index 06d3326701..1a97643f8b 100644 --- a/examples/kitchensink-express/.env.defaults +++ b/examples/kitchensink-express/.env.defaults @@ -9,4 +9,5 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none -UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller \ No newline at end of file +UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller +UNCHAINED_DEFAULT_ASSORTMENT_TAGS= \ No newline at end of file diff --git a/examples/kitchensink/.env.defaults b/examples/kitchensink/.env.defaults index 6b7957e71b..0ebc92f373 100644 --- a/examples/kitchensink/.env.defaults +++ b/examples/kitchensink/.env.defaults @@ -10,3 +10,4 @@ UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller +UNCHAINED_DEFAULT_ASSORTMENT_TAGS= diff --git a/packages/api/src/resolvers/type/shop-types.ts b/packages/api/src/resolvers/type/shop-types.ts index 5df0da4d1d..11f420c244 100644 --- a/packages/api/src/resolvers/type/shop-types.ts +++ b/packages/api/src/resolvers/type/shop-types.ts @@ -5,14 +5,16 @@ import { checkAction } from '../../acl.js'; import { allRoles, actions } from '../../roles/index.js'; type HelperType = (root: never, params: never, context: Context) => Promise; -const { UNCHAINED_DEFAULT_PRODUCT_TAGS = 'featured,new,bestseller' } = process.env; +const { UNCHAINED_DEFAULT_PRODUCT_TAGS = 'featured,new,bestseller', UNCHAINED_DEFAULT_ASSORTMENT_TAGS } = + process.env; export interface ShopHelperTypes { _id: () => string; country: HelperType; language: HelperType; userRoles: HelperType; - tags: HelperType; + productTags: HelperType; + assortmentTags: HelperType; } export const Shop: ShopHelperTypes = { @@ -33,7 +35,7 @@ export const Shop: ShopHelperTypes = { .map(({ name }) => name) .filter((name) => name.substring(0, 2) !== '__'); }, - tags: async (root, _, { modules }: Context) => { + productTags: async (root, _, { modules }: Context) => { const existingProductTags = await modules.products.existingTags(); const normalizedTags = Array.from( new Set( @@ -46,4 +48,17 @@ export const Shop: ShopHelperTypes = { ); return normalizedTags; }, + assortmentTags: async (root, _, { modules }: Context) => { + const existingProductTags = await modules.assortments.existingTags(); + const normalizedTags = Array.from( + new Set( + (UNCHAINED_DEFAULT_ASSORTMENT_TAGS || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + .concat(existingProductTags), + ), + ); + return normalizedTags; + }, }; diff --git a/packages/api/src/schema/types/shop.ts b/packages/api/src/schema/types/shop.ts index ff8afe6f67..4bbd9f0f32 100644 --- a/packages/api/src/schema/types/shop.ts +++ b/packages/api/src/schema/types/shop.ts @@ -35,7 +35,8 @@ export default [ userRoles: [String!]! adminUiConfig: AdminUiConfig! vapidPublicKey: String - tags: [String!]! + productTags: [String!]! + assortmentTags: [String!]! } `, ]; diff --git a/packages/core-assortments/src/assortments-settings.ts b/packages/core-assortments/src/assortments-settings.ts index e0d093da43..c7eff3fddc 100644 --- a/packages/core-assortments/src/assortments-settings.ts +++ b/packages/core-assortments/src/assortments-settings.ts @@ -12,6 +12,7 @@ export interface AssortmentsSettingsOptions { slugify?: (title: string) => string; setCachedProductIds?: (assortmentId: string, productIds: string[]) => Promise; getCachedProductIds?: (assortmentId: string) => Promise; + defaultTags?: string[]; } export interface AssortmentsSettings { @@ -19,6 +20,7 @@ export interface AssortmentsSettings { slugify?: (title: string) => string; setCachedProductIds?: (assortmentId: string, productIds: string[]) => Promise; getCachedProductIds?: (assortmentId: string) => Promise; + defaultTags?: string[]; configureSettings: (options: AssortmentsSettingsOptions, db: mongodb.Db) => void; } @@ -33,10 +35,12 @@ export const assortmentsSettings: AssortmentsSettings = { getCachedProductIds, zipTree = zipTreeByDeepness, slugify = defaultSlugify, + defaultTags, }: AssortmentsSettingsOptions, db, ) => { const defaultCache = await makeMongoDBCache(db); + assortmentsSettings.defaultTags = (defaultTags ?? []).filter(Boolean); assortmentsSettings.zipTree = zipTree; assortmentsSettings.slugify = slugify; assortmentsSettings.setCachedProductIds = setCachedProductIds || defaultCache.setCachedProductIds; diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index eea96ec234..4d68a55d6b 100644 --- a/packages/core-assortments/src/module/configureAssortmentsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentsModule.ts @@ -514,6 +514,10 @@ export const configureAssortmentsModule = async ({ links: assortmentLinks, products: assortmentProducts, texts: assortmentTexts, + existingTags: async (): Promise => { + const tags = await Assortments.distinct('tags', { tags: { $exists: true } }); + return assortmentsSettings.defaultTags.concat(tags.sort()); + }, }; }; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index 06ae71fc64..626899ccf6 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -602,7 +602,7 @@ export const configureProductsModule = async ({ texts: productTexts, existingTags: async (): Promise => { const tags = await Products.distinct('tags', { tags: { $exists: true } }); - return tags.sort(); + return productsSettings.defaultTags.concat(tags.sort()); }, }; }; diff --git a/packages/core-products/src/products-settings.ts b/packages/core-products/src/products-settings.ts index b2ad1c7574..2b8a73ebe1 100644 --- a/packages/core-products/src/products-settings.ts +++ b/packages/core-products/src/products-settings.ts @@ -2,17 +2,20 @@ import { slugify as defaultSlugify } from '@unchainedshop/utils'; export interface ProductsSettingsOptions { slugify?: (title: string) => string; + defaultTags?: string[]; } export interface ProductsSettings { slugify?: (title: string) => string; configureSettings: (options?: ProductsSettingsOptions) => void; + defaultTags?: string[]; } export const productsSettings: ProductsSettings = { slugify: null, - configureSettings: async ({ slugify = defaultSlugify }: ProductsSettingsOptions) => { + configureSettings: async ({ slugify = defaultSlugify, defaultTags }: ProductsSettingsOptions) => { productsSettings.slugify = slugify; + productsSettings.defaultTags = (defaultTags ?? []).filter(Boolean); }, }; diff --git a/packages/core/src/modules.ts b/packages/core/src/modules.ts index 076c998cbb..f72efe0939 100644 --- a/packages/core/src/modules.ts +++ b/packages/core/src/modules.ts @@ -154,6 +154,7 @@ const initModules = async ( const products = await configureProductsModule({ db, migrationRepository, + options: options.products, }); const quotations = await configureQuotationsModule({ db, From 54a65e9a55ed93f113a86fdeb7262dd7f0292d61 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 22 Sep 2025 10:55:37 +0300 Subject: [PATCH 3/7] Extend adminUiConfig to include default assortment and product tags --- packages/api/src/context.ts | 70 ++++++++++--------- packages/api/src/resolvers/type/shop-types.ts | 26 +++---- .../src/assortments-settings.ts | 4 -- .../src/module/configureAssortmentsModule.ts | 2 +- .../src/module/configureProductsModule.ts | 6 +- .../core-products/src/products-settings.ts | 5 +- 6 files changed, 55 insertions(+), 58 deletions(-) diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 83960d5f29..0d8a9c5443 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -29,6 +29,8 @@ export interface CustomAdminUiProperties { export interface AdminUiConfig { customProperties?: CustomAdminUiProperties[]; singleSignOnURL?: string; + defaultProductTags?: string[]; + defaultAssortmentTags?: string[]; } export interface UnchainedHTTPServerContext { @@ -71,42 +73,42 @@ export const createContextResolver = unchainedAPI: UnchainedCore, unchainedConfig: Pick, ): UnchainedContextResolver => - async ({ - getHeader, - setHeader, - remoteAddress, - remotePort, - userId, - impersonatorId, - accessToken, - login, - logout, - }) => { - const abstractHttpServerContext = { remoteAddress, remotePort, getHeader, setHeader }; - const loaders = instantiateLoaders(unchainedAPI); - const localeContext = await getLocaleContext(abstractHttpServerContext, unchainedAPI); + async ({ + getHeader, + setHeader, + remoteAddress, + remotePort, + userId, + impersonatorId, + accessToken, + login, + logout, + }) => { + const abstractHttpServerContext = { remoteAddress, remotePort, getHeader, setHeader }; + const loaders = instantiateLoaders(unchainedAPI); + const localeContext = await getLocaleContext(abstractHttpServerContext, unchainedAPI); - const userContext: UnchainedUserContext = { login, logout, impersonatorId }; + const userContext: UnchainedUserContext = { login, logout, impersonatorId }; - if (accessToken) { - const accessTokenUser = await unchainedAPI.modules.users.findUserByToken(accessToken); - if (accessTokenUser) { - userContext.user = accessTokenUser; - userContext.userId = accessTokenUser._id; + if (accessToken) { + const accessTokenUser = await unchainedAPI.modules.users.findUserByToken(accessToken); + if (accessTokenUser) { + userContext.user = accessTokenUser; + userContext.userId = accessTokenUser._id; + } + } + if (userId && !userContext.userId) { + userContext.user = await unchainedAPI.modules.users.findUserById(userId); + userContext.userId = userId; } - } - if (userId && !userContext.userId) { - userContext.user = await unchainedAPI.modules.users.findUserById(userId); - userContext.userId = userId; - } - return { - ...unchainedAPI, - ...unchainedConfig, - ...localeContext, - ...userContext, - ...abstractHttpServerContext, - loaders, - version: UNCHAINED_API_VERSION, + return { + ...unchainedAPI, + ...unchainedConfig, + ...localeContext, + ...userContext, + ...abstractHttpServerContext, + loaders, + version: UNCHAINED_API_VERSION, + }; }; - }; diff --git a/packages/api/src/resolvers/type/shop-types.ts b/packages/api/src/resolvers/type/shop-types.ts index 11f420c244..17aed27f9d 100644 --- a/packages/api/src/resolvers/type/shop-types.ts +++ b/packages/api/src/resolvers/type/shop-types.ts @@ -35,28 +35,30 @@ export const Shop: ShopHelperTypes = { .map(({ name }) => name) .filter((name) => name.substring(0, 2) !== '__'); }, - productTags: async (root, _, { modules }: Context) => { + productTags: async (root, _, { modules, adminUiConfig }: Context) => { const existingProductTags = await modules.products.existingTags(); + const envTags = (UNCHAINED_DEFAULT_PRODUCT_TAGS || '') + .split(',') + .map((t) => t.trim()).filter(Boolean) + const normalizedDefaultTags = envTags?.length ? envTags : (adminUiConfig?.defaultProductTags || []).filter(Boolean) const normalizedTags = Array.from( new Set( - (UNCHAINED_DEFAULT_PRODUCT_TAGS || '') - .split(',') - .map((t) => t.trim()) - .filter(Boolean) + normalizedDefaultTags .concat(existingProductTags), ), ); return normalizedTags; }, - assortmentTags: async (root, _, { modules }: Context) => { - const existingProductTags = await modules.assortments.existingTags(); + assortmentTags: async (root, _, { modules, adminUiConfig }: Context) => { + const existingAssortmentTags = await modules.assortments.existingTags(); + const envTags = (UNCHAINED_DEFAULT_ASSORTMENT_TAGS || '') + .split(',') + .map((t) => t.trim()).filter(Boolean) + const normalizedDefaultTags = envTags?.length ? envTags : (adminUiConfig?.defaultAssortmentTags || []).filter(Boolean) const normalizedTags = Array.from( new Set( - (UNCHAINED_DEFAULT_ASSORTMENT_TAGS || '') - .split(',') - .map((t) => t.trim()) - .filter(Boolean) - .concat(existingProductTags), + normalizedDefaultTags + .concat(existingAssortmentTags), ), ); return normalizedTags; diff --git a/packages/core-assortments/src/assortments-settings.ts b/packages/core-assortments/src/assortments-settings.ts index c7eff3fddc..e0d093da43 100644 --- a/packages/core-assortments/src/assortments-settings.ts +++ b/packages/core-assortments/src/assortments-settings.ts @@ -12,7 +12,6 @@ export interface AssortmentsSettingsOptions { slugify?: (title: string) => string; setCachedProductIds?: (assortmentId: string, productIds: string[]) => Promise; getCachedProductIds?: (assortmentId: string) => Promise; - defaultTags?: string[]; } export interface AssortmentsSettings { @@ -20,7 +19,6 @@ export interface AssortmentsSettings { slugify?: (title: string) => string; setCachedProductIds?: (assortmentId: string, productIds: string[]) => Promise; getCachedProductIds?: (assortmentId: string) => Promise; - defaultTags?: string[]; configureSettings: (options: AssortmentsSettingsOptions, db: mongodb.Db) => void; } @@ -35,12 +33,10 @@ export const assortmentsSettings: AssortmentsSettings = { getCachedProductIds, zipTree = zipTreeByDeepness, slugify = defaultSlugify, - defaultTags, }: AssortmentsSettingsOptions, db, ) => { const defaultCache = await makeMongoDBCache(db); - assortmentsSettings.defaultTags = (defaultTags ?? []).filter(Boolean); assortmentsSettings.zipTree = zipTree; assortmentsSettings.slugify = slugify; assortmentsSettings.setCachedProductIds = setCachedProductIds || defaultCache.setCachedProductIds; diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index 4d68a55d6b..dc2a695e29 100644 --- a/packages/core-assortments/src/module/configureAssortmentsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentsModule.ts @@ -516,7 +516,7 @@ export const configureAssortmentsModule = async ({ texts: assortmentTexts, existingTags: async (): Promise => { const tags = await Assortments.distinct('tags', { tags: { $exists: true } }); - return assortmentsSettings.defaultTags.concat(tags.sort()); + return tags.sort(); }, }; }; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index 626899ccf6..7ac4eacb3a 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -96,8 +96,8 @@ export const buildFindSelector = ({ selector.status = !includeDrafts ? { $eq: ProductStatus.ACTIVE } : { - $in: [ProductStatus.ACTIVE, InternalProductStatus.DRAFT], - }; + $in: [ProductStatus.ACTIVE, InternalProductStatus.DRAFT], + }; } return selector; @@ -602,7 +602,7 @@ export const configureProductsModule = async ({ texts: productTexts, existingTags: async (): Promise => { const tags = await Products.distinct('tags', { tags: { $exists: true } }); - return productsSettings.defaultTags.concat(tags.sort()); + return tags.sort(); }, }; }; diff --git a/packages/core-products/src/products-settings.ts b/packages/core-products/src/products-settings.ts index 2b8a73ebe1..b2ad1c7574 100644 --- a/packages/core-products/src/products-settings.ts +++ b/packages/core-products/src/products-settings.ts @@ -2,20 +2,17 @@ import { slugify as defaultSlugify } from '@unchainedshop/utils'; export interface ProductsSettingsOptions { slugify?: (title: string) => string; - defaultTags?: string[]; } export interface ProductsSettings { slugify?: (title: string) => string; configureSettings: (options?: ProductsSettingsOptions) => void; - defaultTags?: string[]; } export const productsSettings: ProductsSettings = { slugify: null, - configureSettings: async ({ slugify = defaultSlugify, defaultTags }: ProductsSettingsOptions) => { + configureSettings: async ({ slugify = defaultSlugify }: ProductsSettingsOptions) => { productsSettings.slugify = slugify; - productsSettings.defaultTags = (defaultTags ?? []).filter(Boolean); }, }; From c313d7172405cff665b0499691c7aee877141306 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 22 Sep 2025 13:54:14 +0300 Subject: [PATCH 4/7] Enable configuring admin-ui through envs in addition to options --- examples/kitchensink-express/.env.defaults | 4 +- examples/kitchensink/.env.defaults | 2 + packages/api/src/context.ts | 68 +++++++++---------- .../api/src/resolvers/queries/shopInfo.ts | 39 ++++++++++- packages/api/src/resolvers/type/shop-types.ts | 33 --------- packages/api/src/schema/types/shop.ts | 4 +- .../src/module/configureProductsModule.ts | 4 +- 7 files changed, 79 insertions(+), 75 deletions(-) diff --git a/examples/kitchensink-express/.env.defaults b/examples/kitchensink-express/.env.defaults index 1a97643f8b..bd76574292 100644 --- a/examples/kitchensink-express/.env.defaults +++ b/examples/kitchensink-express/.env.defaults @@ -10,4 +10,6 @@ UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller -UNCHAINED_DEFAULT_ASSORTMENT_TAGS= \ No newline at end of file +UNCHAINED_DEFAULT_ASSORTMENT_TAGS= +UNCHAINED_SINGLE_SIGN_ON_URL= +UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES= \ No newline at end of file diff --git a/examples/kitchensink/.env.defaults b/examples/kitchensink/.env.defaults index 0ebc92f373..90a3ae89c6 100644 --- a/examples/kitchensink/.env.defaults +++ b/examples/kitchensink/.env.defaults @@ -11,3 +11,5 @@ UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller UNCHAINED_DEFAULT_ASSORTMENT_TAGS= +UNCHAINED_SINGLE_SIGN_ON_URL= +UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES=./fragments.json \ No newline at end of file diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts index 0d8a9c5443..8b3f93dfaa 100644 --- a/packages/api/src/context.ts +++ b/packages/api/src/context.ts @@ -73,42 +73,42 @@ export const createContextResolver = unchainedAPI: UnchainedCore, unchainedConfig: Pick, ): UnchainedContextResolver => - async ({ - getHeader, - setHeader, - remoteAddress, - remotePort, - userId, - impersonatorId, - accessToken, - login, - logout, - }) => { - const abstractHttpServerContext = { remoteAddress, remotePort, getHeader, setHeader }; - const loaders = instantiateLoaders(unchainedAPI); - const localeContext = await getLocaleContext(abstractHttpServerContext, unchainedAPI); + async ({ + getHeader, + setHeader, + remoteAddress, + remotePort, + userId, + impersonatorId, + accessToken, + login, + logout, + }) => { + const abstractHttpServerContext = { remoteAddress, remotePort, getHeader, setHeader }; + const loaders = instantiateLoaders(unchainedAPI); + const localeContext = await getLocaleContext(abstractHttpServerContext, unchainedAPI); - const userContext: UnchainedUserContext = { login, logout, impersonatorId }; + const userContext: UnchainedUserContext = { login, logout, impersonatorId }; - if (accessToken) { - const accessTokenUser = await unchainedAPI.modules.users.findUserByToken(accessToken); - if (accessTokenUser) { - userContext.user = accessTokenUser; - userContext.userId = accessTokenUser._id; - } - } - if (userId && !userContext.userId) { - userContext.user = await unchainedAPI.modules.users.findUserById(userId); - userContext.userId = userId; + if (accessToken) { + const accessTokenUser = await unchainedAPI.modules.users.findUserByToken(accessToken); + if (accessTokenUser) { + userContext.user = accessTokenUser; + userContext.userId = accessTokenUser._id; } + } + if (userId && !userContext.userId) { + userContext.user = await unchainedAPI.modules.users.findUserById(userId); + userContext.userId = userId; + } - return { - ...unchainedAPI, - ...unchainedConfig, - ...localeContext, - ...userContext, - ...abstractHttpServerContext, - loaders, - version: UNCHAINED_API_VERSION, - }; + return { + ...unchainedAPI, + ...unchainedConfig, + ...localeContext, + ...userContext, + ...abstractHttpServerContext, + loaders, + version: UNCHAINED_API_VERSION, }; + }; diff --git a/packages/api/src/resolvers/queries/shopInfo.ts b/packages/api/src/resolvers/queries/shopInfo.ts index 110d8b1004..2adee25c04 100644 --- a/packages/api/src/resolvers/queries/shopInfo.ts +++ b/packages/api/src/resolvers/queries/shopInfo.ts @@ -1,3 +1,4 @@ +import { readFile } from 'fs/promises'; import { Context } from '../../context.js'; import { log } from '@unchainedshop/logger'; @@ -10,14 +11,22 @@ export default function shopInfo( adminUiConfig?: Record; vapidPublicKey?: string; } { - const { adminUiConfig } = context; + const { adminUiConfig, modules } = context; log('query shopInfo', { userId: context.userId }); return { version: context.version, adminUiConfig: { - customProperties: adminUiConfig?.customProperties ?? [], - singleSignOnURL: adminUiConfig?.singleSignOnURL, + customProperties: async () => { + try { + const raw = await readFile(process.env.UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES, 'utf-8'); + const parsed = JSON.parse(raw); + return parsed; + } catch { + return adminUiConfig?.customProperties ?? []; + } + }, + singleSignOnURL: process.env.UNCHAINED_SINGLE_SIGN_ON_URL || adminUiConfig?.singleSignOnURL, externalLinks: () => { try { const parsed = JSON.parse(process.env.EXTERNAL_LINKS); @@ -26,6 +35,30 @@ export default function shopInfo( return []; } }, + productTags: async () => { + const existingProductTags = await modules.products.existingTags(); + const envTags = (process.env.UNCHAINED_DEFAULT_PRODUCT_TAGS || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + const normalizedDefaultTags = envTags?.length + ? envTags + : (adminUiConfig?.defaultProductTags || []).filter(Boolean); + const normalizedTags = Array.from(new Set(normalizedDefaultTags.concat(existingProductTags))); + return normalizedTags; + }, + assortmentTags: async () => { + const existingAssortmentTags = await modules.assortments.existingTags(); + const envTags = (process.env.UNCHAINED_DEFAULT_ASSORTMENT_TAGS || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + const normalizedDefaultTags = envTags?.length + ? envTags + : (adminUiConfig?.defaultAssortmentTags || []).filter(Boolean); + const normalizedTags = Array.from(new Set(normalizedDefaultTags.concat(existingAssortmentTags))); + return normalizedTags; + }, }, vapidPublicKey: process.env?.PUSH_NOTIFICATION_PUBLIC_KEY, }; diff --git a/packages/api/src/resolvers/type/shop-types.ts b/packages/api/src/resolvers/type/shop-types.ts index 17aed27f9d..75e778e747 100644 --- a/packages/api/src/resolvers/type/shop-types.ts +++ b/packages/api/src/resolvers/type/shop-types.ts @@ -5,16 +5,11 @@ import { checkAction } from '../../acl.js'; import { allRoles, actions } from '../../roles/index.js'; type HelperType = (root: never, params: never, context: Context) => Promise; -const { UNCHAINED_DEFAULT_PRODUCT_TAGS = 'featured,new,bestseller', UNCHAINED_DEFAULT_ASSORTMENT_TAGS } = - process.env; - export interface ShopHelperTypes { _id: () => string; country: HelperType; language: HelperType; userRoles: HelperType; - productTags: HelperType; - assortmentTags: HelperType; } export const Shop: ShopHelperTypes = { @@ -35,32 +30,4 @@ export const Shop: ShopHelperTypes = { .map(({ name }) => name) .filter((name) => name.substring(0, 2) !== '__'); }, - productTags: async (root, _, { modules, adminUiConfig }: Context) => { - const existingProductTags = await modules.products.existingTags(); - const envTags = (UNCHAINED_DEFAULT_PRODUCT_TAGS || '') - .split(',') - .map((t) => t.trim()).filter(Boolean) - const normalizedDefaultTags = envTags?.length ? envTags : (adminUiConfig?.defaultProductTags || []).filter(Boolean) - const normalizedTags = Array.from( - new Set( - normalizedDefaultTags - .concat(existingProductTags), - ), - ); - return normalizedTags; - }, - assortmentTags: async (root, _, { modules, adminUiConfig }: Context) => { - const existingAssortmentTags = await modules.assortments.existingTags(); - const envTags = (UNCHAINED_DEFAULT_ASSORTMENT_TAGS || '') - .split(',') - .map((t) => t.trim()).filter(Boolean) - const normalizedDefaultTags = envTags?.length ? envTags : (adminUiConfig?.defaultAssortmentTags || []).filter(Boolean) - const normalizedTags = Array.from( - new Set( - normalizedDefaultTags - .concat(existingAssortmentTags), - ), - ); - return normalizedTags; - }, }; diff --git a/packages/api/src/schema/types/shop.ts b/packages/api/src/schema/types/shop.ts index 4bbd9f0f32..fd73fdffe2 100644 --- a/packages/api/src/schema/types/shop.ts +++ b/packages/api/src/schema/types/shop.ts @@ -25,6 +25,8 @@ export default [ customProperties: [AdminUiConfigCustomEntityInterface!]! externalLinks: [AdminUiLink!]! singleSignOnURL: String + productTags: [String!]! + assortmentTags: [String!]! } type Shop @cacheControl(maxAge: 180) { @@ -35,8 +37,6 @@ export default [ userRoles: [String!]! adminUiConfig: AdminUiConfig! vapidPublicKey: String - productTags: [String!]! - assortmentTags: [String!]! } `, ]; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index 7ac4eacb3a..06ae71fc64 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -96,8 +96,8 @@ export const buildFindSelector = ({ selector.status = !includeDrafts ? { $eq: ProductStatus.ACTIVE } : { - $in: [ProductStatus.ACTIVE, InternalProductStatus.DRAFT], - }; + $in: [ProductStatus.ACTIVE, InternalProductStatus.DRAFT], + }; } return selector; From e08f8f6660156cb9842f2d08410846c385b35dbb Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 22 Sep 2025 13:58:07 +0300 Subject: [PATCH 5/7] Update changelog.md --- changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 205e14c952..1f7d865e7a 100644 --- a/changelog.md +++ b/changelog.md @@ -36,7 +36,7 @@ registerProductDiscoverabilityFilter({ hiddenTagValue: 'hidden' }); - Add filters to `Query.orders` to filter by payment and delivery providers - Allow to call `Query.deliveryInterfaces` without a type - Add support for SMS providers BudgetSMS and Bulkgate next to Twilio on our privacy-focused mission to always support European alternatives. -- Add `tags` field in `shopInfo` that will return existing tags used for products and can also be customized to include default tags using `UNCHAINED_DEFAULT_PRODUCT_TAGS` by default it used **new, featured & bestseller** +- Add `productTags` & `assortmentTags` field in `shopInfo.adminUiConfig` that will return existing tags used for products and assortments and can also be customized to include default tags using `UNCHAINED_DEFAULT_PRODUCT_TAGS` and/or `UNCHAINED_DEFAULT_ASSORTMENT_TAGS` ## Patch - Update to ESlint 9 From 64b311125ba296f9a8afddc09c53d42f6b66c3d8 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 22 Sep 2025 15:37:47 +0300 Subject: [PATCH 6/7] Update changelog.md --- changelog.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.md b/changelog.md index 1f7d865e7a..cf302ef20c 100644 --- a/changelog.md +++ b/changelog.md @@ -36,7 +36,9 @@ registerProductDiscoverabilityFilter({ hiddenTagValue: 'hidden' }); - Add filters to `Query.orders` to filter by payment and delivery providers - Allow to call `Query.deliveryInterfaces` without a type - Add support for SMS providers BudgetSMS and Bulkgate next to Twilio on our privacy-focused mission to always support European alternatives. -- Add `productTags` & `assortmentTags` field in `shopInfo.adminUiConfig` that will return existing tags used for products and assortments and can also be customized to include default tags using `UNCHAINED_DEFAULT_PRODUCT_TAGS` and/or `UNCHAINED_DEFAULT_ASSORTMENT_TAGS` +- Add `productTags` & `assortmentTags` field in `shopInfo.adminUiConfig` that will return existing tags used for products and assortments and can also be customized to include default tags using `UNCHAINED_DEFAULT_PRODUCT_TAGS` and/or `UNCHAINED_DEFAULT_ASSORTMENT_TAGS`. +- Add option to configure `adminUiConfig.customProperties` through `UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES` env in addition to platform configuration option that accepts a json file +- Add option to configure `adminUiConfig.singleSignOnURL` through `UNCHAINED_SINGLE_SIGN_ON_URL` env in addition to platform configuration option ## Patch - Update to ESlint 9 From 4f0a7ddd2815b289e1a1950a89ec1b918e8dd413 Mon Sep 17 00:00:00 2001 From: Mikael Araya Date: Mon, 22 Sep 2025 17:55:36 +0300 Subject: [PATCH 7/7] Use uniform env name prefix for adminUIConfig environment variables --- changelog.md | 4 ++-- examples/kitchensink-express/.env.defaults | 6 +++--- examples/kitchensink/.env.defaults | 6 +++--- packages/api/src/resolvers/queries/shopInfo.ts | 7 ++++--- .../src/module/configureAssortmentsModule.ts | 5 ++++- .../core-products/src/module/configureProductsModule.ts | 5 ++++- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/changelog.md b/changelog.md index cf302ef20c..1275bd3d5f 100644 --- a/changelog.md +++ b/changelog.md @@ -36,9 +36,9 @@ registerProductDiscoverabilityFilter({ hiddenTagValue: 'hidden' }); - Add filters to `Query.orders` to filter by payment and delivery providers - Allow to call `Query.deliveryInterfaces` without a type - Add support for SMS providers BudgetSMS and Bulkgate next to Twilio on our privacy-focused mission to always support European alternatives. -- Add `productTags` & `assortmentTags` field in `shopInfo.adminUiConfig` that will return existing tags used for products and assortments and can also be customized to include default tags using `UNCHAINED_DEFAULT_PRODUCT_TAGS` and/or `UNCHAINED_DEFAULT_ASSORTMENT_TAGS`. +- Add `productTags` & `assortmentTags` field in `shopInfo.adminUiConfig` that will return existing tags used for products and assortments and can also be customized to include default tags using `UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS` and/or `UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS`. - Add option to configure `adminUiConfig.customProperties` through `UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES` env in addition to platform configuration option that accepts a json file -- Add option to configure `adminUiConfig.singleSignOnURL` through `UNCHAINED_SINGLE_SIGN_ON_URL` env in addition to platform configuration option +- Add option to configure `adminUiConfig.singleSignOnURL` through `UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL` env in addition to platform configuration option ## Patch - Update to ESlint 9 diff --git a/examples/kitchensink-express/.env.defaults b/examples/kitchensink-express/.env.defaults index bd76574292..61caf78b2d 100644 --- a/examples/kitchensink-express/.env.defaults +++ b/examples/kitchensink-express/.env.defaults @@ -9,7 +9,7 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none -UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller -UNCHAINED_DEFAULT_ASSORTMENT_TAGS= -UNCHAINED_SINGLE_SIGN_ON_URL= +UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS=new,featured,bestseller +UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS= +UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL= UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES= \ No newline at end of file diff --git a/examples/kitchensink/.env.defaults b/examples/kitchensink/.env.defaults index 90a3ae89c6..d3c5cd9ab2 100644 --- a/examples/kitchensink/.env.defaults +++ b/examples/kitchensink/.env.defaults @@ -9,7 +9,7 @@ UNCHAINED_SECRET=secret UNCHAINED_GRIDFS_PUT_UPLOAD_SECRET=secret UNCHAINED_TOKEN_SECRET=random-token-that-is-not-secret-at-all UNCHAINED_COOKIE_SAMESITE=none -UNCHAINED_DEFAULT_PRODUCT_TAGS=new,featured,bestseller -UNCHAINED_DEFAULT_ASSORTMENT_TAGS= -UNCHAINED_SINGLE_SIGN_ON_URL= +UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS=new,featured,bestseller +UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS= +UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL= UNCHAINED_ADMIN_UI_CUSTOM_PROPERTIES=./fragments.json \ No newline at end of file diff --git a/packages/api/src/resolvers/queries/shopInfo.ts b/packages/api/src/resolvers/queries/shopInfo.ts index 2adee25c04..2dbd987b6a 100644 --- a/packages/api/src/resolvers/queries/shopInfo.ts +++ b/packages/api/src/resolvers/queries/shopInfo.ts @@ -26,7 +26,8 @@ export default function shopInfo( return adminUiConfig?.customProperties ?? []; } }, - singleSignOnURL: process.env.UNCHAINED_SINGLE_SIGN_ON_URL || adminUiConfig?.singleSignOnURL, + singleSignOnURL: + process.env.UNCHAINED_ADMIN_UI_SINGLE_SIGN_ON_URL || adminUiConfig?.singleSignOnURL, externalLinks: () => { try { const parsed = JSON.parse(process.env.EXTERNAL_LINKS); @@ -37,7 +38,7 @@ export default function shopInfo( }, productTags: async () => { const existingProductTags = await modules.products.existingTags(); - const envTags = (process.env.UNCHAINED_DEFAULT_PRODUCT_TAGS || '') + const envTags = (process.env.UNCHAINED_ADMIN_UI_DEFAULT_PRODUCT_TAGS || '') .split(',') .map((t) => t.trim()) .filter(Boolean); @@ -49,7 +50,7 @@ export default function shopInfo( }, assortmentTags: async () => { const existingAssortmentTags = await modules.assortments.existingTags(); - const envTags = (process.env.UNCHAINED_DEFAULT_ASSORTMENT_TAGS || '') + const envTags = (process.env.UNCHAINED_ADMIN_UI_DEFAULT_ASSORTMENT_TAGS || '') .split(',') .map((t) => t.trim()) .filter(Boolean); diff --git a/packages/core-assortments/src/module/configureAssortmentsModule.ts b/packages/core-assortments/src/module/configureAssortmentsModule.ts index dc2a695e29..3229247bae 100644 --- a/packages/core-assortments/src/module/configureAssortmentsModule.ts +++ b/packages/core-assortments/src/module/configureAssortmentsModule.ts @@ -515,7 +515,10 @@ export const configureAssortmentsModule = async ({ products: assortmentProducts, texts: assortmentTexts, existingTags: async (): Promise => { - const tags = await Assortments.distinct('tags', { tags: { $exists: true } }); + const tags = await Assortments.distinct('tags', { + tags: { $exists: true }, + deleted: { $exists: false }, + }); return tags.sort(); }, }; diff --git a/packages/core-products/src/module/configureProductsModule.ts b/packages/core-products/src/module/configureProductsModule.ts index 06ae71fc64..5716728b99 100644 --- a/packages/core-products/src/module/configureProductsModule.ts +++ b/packages/core-products/src/module/configureProductsModule.ts @@ -601,7 +601,10 @@ export const configureProductsModule = async ({ texts: productTexts, existingTags: async (): Promise => { - const tags = await Products.distinct('tags', { tags: { $exists: true } }); + const tags = await Products.distinct('tags', { + tags: { $exists: true }, + status: { $ne: ProductStatus.DELETED }, + }); return tags.sort(); }, };