Просмотр исходного кода

Merge branch 'master' into feat/enhanced-access-token

Shun Miyazawa 10 месяцев назад
Родитель
Сommit
658ab61065
21 измененных файлов с 701 добавлено и 108 удалено
  1. 9 0
      apps/app/bin/openapi/definition-apiv1.js
  2. 9 0
      apps/app/bin/openapi/definition-apiv3.js
  3. 1 1
      apps/app/bin/openapi/generate-spec-apiv3.sh
  4. 1 1
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  5. 0 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.module.scss
  6. 13 10
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx
  7. 25 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx
  8. 13 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx
  9. 57 23
      apps/app/src/features/openai/server/routes/edit/index.ts
  10. 79 32
      apps/app/src/server/routes/apiv3/app-settings.js
  11. 18 14
      apps/app/src/server/service/config-manager/config-definition.ts
  12. 87 0
      apps/app/src/server/service/config-manager/config-manager.integ.ts
  13. 104 4
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  14. 24 12
      apps/app/src/server/service/config-manager/config-manager.ts
  15. 8 5
      apps/app/src/server/service/file-uploader/aws/index.ts
  16. 4 3
      apps/app/src/server/service/file-uploader/azure.ts
  17. 3 2
      apps/app/src/server/service/file-uploader/gcs/index.ts
  18. 4 1
      packages/core/src/interfaces/config-manager.ts
  19. 1 0
      packages/core/src/interfaces/index.ts
  20. 164 0
      packages/core/src/interfaces/primitive/string.spec.ts
  21. 77 0
      packages/core/src/interfaces/primitive/string.ts

+ 9 - 0
apps/app/bin/openapi/definition-apiv1.js

@@ -7,6 +7,15 @@ module.exports = {
     version: pkg.version,
   },
   servers: [
+    {
+      url: '{server}/_api',
+      variables: {
+        server: {
+          default: 'https://demo.growi.org',
+          description: 'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
+        },
+      },
+    },
     {
       url: 'https://demo.growi.org/_api',
     },

+ 9 - 0
apps/app/bin/openapi/definition-apiv3.js

@@ -7,6 +7,15 @@ module.exports = {
     version: pkg.version,
   },
   servers: [
+    {
+      url: '{server}/_api/v3',
+      variables: {
+        server: {
+          default: 'https://demo.growi.org',
+          description: 'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
+        },
+      },
+    },
     {
       url: 'https://demo.growi.org/_api/v3',
     },

+ 1 - 1
apps/app/bin/openapi/generate-spec-apiv3.sh

@@ -19,6 +19,6 @@ swagger-jsdoc \
   "${APP_PATH}/src/server/models/openapi/**/*.{js,ts}"
 
 if [ $? -eq 0 ]; then
-  pnpm dlx tsx "${APP_PATH}/bin/openapi/generate-operation-ids/cli.ts" "${OUT}" --out "${OUT}" --overwrite-existing
+  npx tsx "${APP_PATH}/bin/openapi/generate-operation-ids/cli.ts" "${OUT}" --out "${OUT}" --overwrite-existing
   echo "OpenAPI spec generated and transformed: ${OUT}"
 fi

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -29,7 +29,7 @@ import {
 import { useAiAssistantSidebar } from '../../../stores/ai-assistant';
 import { useSWRxThreads } from '../../../stores/thread';
 
-import { MessageCard } from './MessageCard';
+import { MessageCard } from './MessageCard/MessageCard';
 import { ResizableTextarea } from './ResizableTextArea';
 
 import styles from './AiAssistantSidebar.module.scss';

+ 0 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.module.scss → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.module.scss


+ 13 - 10
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/MessageCard.tsx

@@ -1,10 +1,10 @@
 import { type JSX } from 'react';
 
-import type { LinkProps } from 'next/link';
 import { useTranslation } from 'react-i18next';
 import ReactMarkdown from 'react-markdown';
 
-import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+import { Header } from './ReactMarkdownComponents/Header';
+import { NextLinkWrapper } from './ReactMarkdownComponents/NextLinkWrapper';
 
 import styles from './MessageCard.module.scss';
 
@@ -24,13 +24,6 @@ const UserMessageCard = ({ children }: { children: string }): JSX.Element => (
 
 const assistantMessageCardModuleClass = styles['assistant-message-card'] ?? '';
 
-const NextLinkWrapper = (props: LinkProps & {children: string, href: string}): JSX.Element => {
-  return (
-    <NextLink href={props.href} className="link-primary">
-      {props.children}
-    </NextLink>
-  );
-};
 
 const AssistantMessageCard = ({
   children,
@@ -51,7 +44,17 @@ const AssistantMessageCard = ({
           { children.length > 0
             ? (
               <>
-                <ReactMarkdown components={{ a: NextLinkWrapper }}>{children}</ReactMarkdown>
+                <ReactMarkdown components={{
+                  a: NextLinkWrapper,
+                  h1: ({ children }) => <Header level={1}>{children}</Header>,
+                  h2: ({ children }) => <Header level={2}>{children}</Header>,
+                  h3: ({ children }) => <Header level={3}>{children}</Header>,
+                  h4: ({ children }) => <Header level={4}>{children}</Header>,
+                  h5: ({ children }) => <Header level={5}>{children}</Header>,
+                  h6: ({ children }) => <Header level={6}>{children}</Header>,
+                }}
+                >{children}
+                </ReactMarkdown>
                 { additionalItem }
               </>
             )

+ 25 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/Header.tsx

@@ -0,0 +1,25 @@
+type Level = 1 | 2 | 3 | 4 | 5 | 6;
+
+const fontSizes: Record<Level, string> = {
+  1: '1.5rem',
+  2: '1.25rem',
+  3: '1rem',
+  4: '0.875rem',
+  5: '0.75rem',
+  6: '0.625rem',
+};
+
+export const Header = ({ children, level }: { children: React.ReactNode, level: Level}): JSX.Element => {
+  const Tag = `h${level}` as keyof JSX.IntrinsicElements;
+
+  return (
+    <Tag
+      style={{
+        fontSize: fontSizes[level],
+        lineHeight: 1.4,
+      }}
+    >
+      {children}
+    </Tag>
+  );
+};

+ 13 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/MessageCard/ReactMarkdownComponents/NextLinkWrapper.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+
+import type { LinkProps } from 'next/link';
+
+import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+
+export const NextLinkWrapper = (props: LinkProps & {children: React.ReactNode, href: string}): JSX.Element => {
+  return (
+    <NextLink href={props.href} className="link-primary">
+      {props.children}
+    </NextLink>
+  );
+};

+ 57 - 23
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -70,29 +70,63 @@ const withMarkdownCaution = `# IMPORTANT:
 `;
 
 function instruction(withMarkdown: boolean): string {
-  return `# RESPONSE FORMAT:
-You must respond with a JSON object in the following format example:
-{
-  "contents": [
-    { "message": "Your brief message about the upcoming change or proposal.\n\n" },
-    { "replace": "New text 1" },
-    { "message": "Additional explanation if needed" },
-    { "replace": "New text 2" },
-    ...more items if needed
-    { "message": "Your friendly message explaining what changes were made or suggested." }
-  ]
-}
-
-The array should contain:
-- [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
-- Objects with a "message" key for explanatory text to the user if needed.
-- Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
-- [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
-
-${withMarkdown ? withMarkdownCaution : ''}
-
-# Multilingual Support:
-Always provide messages in the same language as the user's request.`;
+  return `
+  # USER INTENT DETECTION:
+  First, analyze the user's message to determine their intent:
+  - **Consultation Type**: Questions, discussions, explanations, or advice seeking WITHOUT explicit request to edit/modify/generate text
+  - **Edit Type**: Clear requests to edit, modify, fix, generate, create, or write content
+
+  ## EXAMPLES OF USER INTENT:
+  ### Consultation Type Examples:
+  - "What do you think about this code?"
+  - "Please give me advice on this text structure"
+  - "Why is this error occurring?"
+  - "Is there a better approach?"
+  - "Can you explain how this works?"
+  - "What are the pros and cons of this method?"
+  - "How should I organize this document?"
+
+  ### Edit Type Examples:
+  - "Please fix the following"
+  - "Add a function that..."
+  - "Rewrite this section to..."
+  - "Correct the errors in this code"
+  - "Generate a new paragraph about..."
+  - "Modify this to include..."
+  - "Create a template for..."
+
+  # RESPONSE FORMAT:
+  ## For Consultation Type (discussion/advice only):
+  Respond with a JSON object containing ONLY message objects:
+  {
+    "contents": [
+      { "message": "Your thoughtful response to the user's question or consultation.\n\nYou can use multiple paragraphs as needed." }
+    ]
+  }
+
+  ## For Edit Type (explicit editing request):
+  Respond with a JSON object in the following format:
+  {
+    "contents": [
+      { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+      { "replace": "New text 1" },
+      { "message": "Additional explanation if needed" },
+      { "replace": "New text 2" },
+      ...more items if needed
+      { "message": "Your friendly message explaining what changes were made or suggested." }
+    ]
+  }
+
+  The array should contain:
+  - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+  - Objects with a "message" key for explanatory text to the user if needed.
+  - Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
+  - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.
+
+  ${withMarkdown ? withMarkdownCaution : ''}
+
+  # Multilingual Support:
+  Always provide messages in the same language as the user's request.`;
 }
 /* eslint-disable max-len */
 

+ 79 - 32
apps/app/src/server/routes/apiv3/app-settings.js

@@ -1,4 +1,6 @@
-import { ConfigSource } from '@growi/core/dist/interfaces';
+import {
+  ConfigSource, toNonBlankString, toNonBlankStringOrUndefined,
+} from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 
@@ -368,6 +370,7 @@ module.exports = (crowi) => {
       body('gcsBucket').trim(),
       body('gcsUploadNamespace').trim(),
       body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+      body('s3Bucket').trim(),
       body('s3Region')
         .trim()
         .if(value => value !== '')
@@ -388,7 +391,6 @@ module.exports = (crowi) => {
           }
           return true;
         }),
-      body('s3Bucket').trim(),
       body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('s3SecretAccessKey').trim(),
       body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
@@ -897,42 +899,88 @@ module.exports = (crowi) => {
     addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
       const { fileUploadType } = req.body;
 
-      const requestParams = {
-        'app:fileUploadType': fileUploadType,
-      };
-
-      if (fileUploadType === 'gcs') {
-        requestParams['gcs:apiKeyJsonPath'] = req.body.gcsApiKeyJsonPath;
-        requestParams['gcs:bucket'] = req.body.gcsBucket;
-        requestParams['gcs:uploadNamespace'] = req.body.gcsUploadNamespace;
-        requestParams['gcs:referenceFileWithRelayMode'] = req.body.gcsReferenceFileWithRelayMode;
+      if (fileUploadType === 'aws') {
+        try {
+          try {
+            toNonBlankString(req.body.s3Bucket);
+          }
+          catch (err) {
+            throw new Error('S3 Bucket name is required');
+          }
+          try {
+            toNonBlankString(req.body.s3Region);
+          }
+          catch (err) {
+            throw new Error('S3 Region is required');
+          }
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'aws:s3Region': toNonBlankString(req.body.s3Region),
+            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'app:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
+            'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+            'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
+          },
+          {
+            skipPubsub: true,
+            removeIfUndefined: true,
+          });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
 
-      if (fileUploadType === 'aws') {
-        requestParams['aws:s3Region'] = req.body.s3Region;
-        requestParams['aws:s3CustomEndpoint'] = req.body.s3CustomEndpoint;
-        requestParams['aws:s3Bucket'] = req.body.s3Bucket;
-        requestParams['aws:s3AccessKeyId'] = req.body.s3AccessKeyId;
-        requestParams['aws:referenceFileWithRelayMode'] = req.body.s3ReferenceFileWithRelayMode;
+      if (fileUploadType === 'gcs') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
+            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
+          },
+          { skipPubsub: true, removeIfUndefined: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating GCS settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
 
       if (fileUploadType === 'azure') {
-        requestParams['azure:tenantId'] = req.body.azureTenantId;
-        requestParams['azure:clientId'] = req.body.azureClientId;
-        requestParams['azure:clientSecret'] = req.body.azureClientSecret;
-        requestParams['azure:storageAccountName'] = req.body.azureStorageAccountName;
-        requestParams['azure:storageContainerName'] = req.body.azureStorageContainerName;
-        requestParams['azure:referenceFileWithRelayMode'] = req.body.azureReferenceFileWithRelayMode;
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
+            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
+            'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
+            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
+            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
+          }, { skipPubsub: true, removeIfUndefined: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating Azure settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
 
       try {
-        await configManager.updateConfigs(requestParams, { skipPubsub: true });
-
-        const s3SecretAccessKey = req.body.s3SecretAccessKey;
-        if (fileUploadType === 'aws' && s3SecretAccessKey != null && s3SecretAccessKey.trim() !== '') {
-          await configManager.updateConfigs({ 'aws:s3SecretAccessKey': s3SecretAccessKey }, { skipPubsub: true });
-        }
-
         await crowi.setUpFileUpload(true);
         crowi.fileUploaderSwitchService.publishUpdatedMessage();
 
@@ -968,11 +1016,10 @@ module.exports = (crowi) => {
         return res.apiv3({ responseParams });
       }
       catch (err) {
-        const msg = 'Error occurred in updating fileUploadType';
+        const msg = 'Error occurred in retrieving file upload configurations';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
       }
-
     });
 
   /**

+ 18 - 14
apps/app/src/server/service/config-manager/config-definition.ts

@@ -1,6 +1,9 @@
 import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
-import type { ConfigDefinition, Lang } from '@growi/core/dist/interfaces';
-import { defineConfig } from '@growi/core/dist/interfaces';
+import type { ConfigDefinition, Lang, NonBlankString } from '@growi/core/dist/interfaces';
+import {
+  toNonBlankString,
+  defineConfig,
+} from '@growi/core/dist/interfaces';
 import type OpenAI from 'openai';
 
 import { ActionGroupSize } from '~/interfaces/activity';
@@ -821,28 +824,29 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'S3_OBJECT_ACL',
     defaultValue: undefined,
   }),
-  'aws:s3Bucket': defineConfig<string>({
-    defaultValue: 'growi',
+  'aws:s3Bucket': defineConfig<NonBlankString>({
+    defaultValue: toNonBlankString('growi'),
   }),
-  'aws:s3Region': defineConfig<string>({
-    defaultValue: 'ap-northeast-1',
+  'aws:s3Region': defineConfig<NonBlankString>({
+    defaultValue: toNonBlankString('ap-northeast-1'),
   }),
-  'aws:s3AccessKeyId': defineConfig<string | undefined>({
+  'aws:s3AccessKeyId': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
   }),
-  'aws:s3SecretAccessKey': defineConfig<string | undefined>({
+  'aws:s3SecretAccessKey': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
+    isSecret: true,
   }),
-  'aws:s3CustomEndpoint': defineConfig<string | undefined>({
+  'aws:s3CustomEndpoint': defineConfig<NonBlankString | undefined>({
     defaultValue: undefined,
   }),
 
   // GCS Settings
-  'gcs:apiKeyJsonPath': defineConfig<string | undefined>({
+  'gcs:apiKeyJsonPath': defineConfig<NonBlankString | undefined>({
     envVarName: 'GCS_API_KEY_JSON_PATH',
     defaultValue: undefined,
   }),
-  'gcs:bucket': defineConfig<string | undefined>({
+  'gcs:bucket': defineConfig<NonBlankString | undefined>({
     envVarName: 'GCS_BUCKET',
     defaultValue: undefined,
   }),
@@ -868,15 +872,15 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'AZURE_REFERENCE_FILE_WITH_RELAY_MODE',
     defaultValue: false,
   }),
-  'azure:tenantId': defineConfig<string | undefined>({
+  'azure:tenantId': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_TENANT_ID',
     defaultValue: undefined,
   }),
-  'azure:clientId': defineConfig<string | undefined>({
+  'azure:clientId': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_CLIENT_ID',
     defaultValue: undefined,
   }),
-  'azure:clientSecret': defineConfig<string | undefined>({
+  'azure:clientSecret': defineConfig<NonBlankString | undefined>({
     envVarName: 'AZURE_CLIENT_SECRET',
     defaultValue: undefined,
     isSecret: true,

+ 87 - 0
apps/app/src/server/service/config-manager/config-manager.integ.ts

@@ -107,6 +107,55 @@ describe('ConfigManager', () => {
     });
   });
 
+  describe('updateConfig', () => {
+    beforeEach(async() => {
+      await Config.deleteMany({ key: /app.*/ }).exec();
+      await Config.create({ key: 'app:siteUrl', value: JSON.stringify('initial value') });
+    });
+
+    test('updates a single config', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', 'updated value');
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig?.value).toEqual(JSON.stringify('updated value'));
+    });
+
+    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig).toBeNull(); // should be removed
+    });
+
+    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config?.value).toEqual(JSON.stringify('initial value'));
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined);
+
+      // assert
+      const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(updatedConfig?.value).toEqual(JSON.stringify('initial value')); // should remain unchanged
+    });
+  });
+
   describe('updateConfigs', () => {
     beforeEach(async() => {
       await Config.deleteMany({ key: /app.*/ }).exec();
@@ -133,6 +182,44 @@ describe('ConfigManager', () => {
       expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1'));
       expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
     });
+
+    test('removes config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config1?.value).toEqual(JSON.stringify('value1'));
+
+      // act
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:fileUploadType': 'aws',
+      }, { removeIfUndefined: true });
+
+      // assert
+      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      expect(updatedConfig1).toBeNull(); // should be removed
+      expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
+    });
+
+    test('does not update config when value is undefined and removeIfUndefined is false', async() => {
+      // arrange
+      await configManager.loadConfigs();
+      const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      expect(config1?.value).toEqual(JSON.stringify('value1'));
+
+      // act
+      await configManager.updateConfigs({
+        'app:siteUrl': undefined,
+        'app:fileUploadType': 'aws',
+      });
+
+      // assert
+      const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec();
+      const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec();
+      expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged
+      expect(updatedConfig2?.value).toEqual(JSON.stringify('aws'));
+    });
   });
 
   describe('removeConfigs', () => {

+ 104 - 4
apps/app/src/server/service/config-manager/config-manager.spec.ts

@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
   ConfigMock: {
     updateOne: vi.fn(),
     bulkWrite: vi.fn(),
+    deleteOne: vi.fn(),
   },
 }));
 vi.mock('../../models/config', () => ({
@@ -40,6 +41,9 @@ describe('ConfigManager test', () => {
     let loadConfigsSpy;
     beforeEach(async() => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
+      // Reset mocks
+      mocks.ConfigMock.updateOne.mockClear();
+      mocks.ConfigMock.deleteOne.mockClear();
     });
 
     test('invoke publishUpdateMessage()', async() => {
@@ -70,6 +74,42 @@ describe('ConfigManager test', () => {
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
 
+    test('remove config when value is undefined and removeIfUndefined is true', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true });
+
+      // assert
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ key: 'app:siteUrl' });
+      expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled();
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('update config with undefined value when removeIfUndefined is false', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfig('app:siteUrl', undefined);
+
+      // assert
+      expect(mocks.ConfigMock.updateOne).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.updateOne).toHaveBeenCalledWith(
+        { key: 'app:siteUrl' },
+        { value: JSON.stringify(undefined) },
+        { upsert: true },
+      );
+      expect(mocks.ConfigMock.deleteOne).not.toHaveBeenCalled();
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
   });
 
   describe('updateConfigs()', () => {
@@ -77,18 +117,20 @@ describe('ConfigManager test', () => {
     let loadConfigsSpy;
     beforeEach(async() => {
       loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs');
+      // Reset mocks
+      mocks.ConfigMock.bulkWrite.mockClear();
     });
 
     test('invoke publishUpdateMessage()', async() => {
-      // arrenge
+      // arrange
       configManager.publishUpdateMessage = vi.fn();
       vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
 
       // act
-      await configManager.updateConfig('app:siteUrl', '');
+      await configManager.updateConfigs({ 'app:siteUrl': 'https://example.com' });
 
       // assert
-      // expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
@@ -102,10 +144,68 @@ describe('ConfigManager test', () => {
       await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true });
 
       // assert
-      // expect(Config.bulkWrite).toHaveBeenCalledTimes(1);
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
       expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();
     });
+
+    test('remove configs when values are undefined and removeIfUndefined is true', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfigs(
+        { 'app:siteUrl': undefined, 'app:title': 'GROWI' },
+        { removeIfUndefined: true },
+      );
+
+      // assert
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
+      const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
+      expect(operations).toHaveLength(2);
+      expect(operations[0]).toEqual({ deleteOne: { filter: { key: 'app:siteUrl' } } });
+      expect(operations[1]).toEqual({
+        updateOne: {
+          filter: { key: 'app:title' },
+          update: { value: JSON.stringify('GROWI') },
+          upsert: true,
+        },
+      });
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
+    test('update configs including undefined values when removeIfUndefined is false', async() => {
+      // arrange
+      configManager.publishUpdateMessage = vi.fn();
+      vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn());
+
+      // act
+      await configManager.updateConfigs({ 'app:siteUrl': undefined, 'app:title': 'GROWI' });
+
+      // assert
+      expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1);
+      const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0];
+      expect(operations).toHaveLength(2); // both operations should be included
+      expect(operations[0]).toEqual({
+        updateOne: {
+          filter: { key: 'app:siteUrl' },
+          update: { value: JSON.stringify(undefined) },
+          upsert: true,
+        },
+      });
+      expect(operations[1]).toEqual({
+        updateOne: {
+          filter: { key: 'app:title' },
+          update: { value: JSON.stringify('GROWI') },
+          upsert: true,
+        },
+      });
+      expect(loadConfigsSpy).toHaveBeenCalledTimes(1);
+      expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
+    });
+
   });
 
   describe('getManagedEnvVars()', () => {

+ 24 - 12
apps/app/src/server/service/config-manager/config-manager.ts

@@ -111,11 +111,17 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
 
-    await Config.updateOne(
-      { key },
-      { value: JSON.stringify(value) },
-      { upsert: true },
-    );
+    if (options?.removeIfUndefined && value === undefined) {
+      // remove the config if the value is undefined and removeIfUndefined is true
+      await Config.deleteOne({ key });
+    }
+    else {
+      await Config.updateOne(
+        { key },
+        { value: JSON.stringify(value) },
+        { upsert: true },
+      );
+    }
 
     await this.loadConfigs({ source: 'db' });
 
@@ -128,13 +134,19 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable
     // Dynamic import to avoid loading database modules too early
     const { Config } = await import('../../models/config');
 
-    const operations = Object.entries(updates).map(([key, value]) => ({
-      updateOne: {
-        filter: { key },
-        update: { value: JSON.stringify(value) },
-        upsert: true,
-      },
-    }));
+    const operations = Object.entries(updates).map(([key, value]) => {
+      return (options?.removeIfUndefined && value === undefined)
+        // remove the config if the value is undefined
+        ? { deleteOne: { filter: { key } } }
+        // update
+        : {
+          updateOne: {
+            filter: { key },
+            update: { value: JSON.stringify(value) },
+            upsert: true,
+          },
+        };
+    });
 
     await Config.bulkWrite(operations);
     await this.loadConfigs({ source: 'db' });

+ 8 - 5
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -13,6 +13,8 @@ import {
   AbortMultipartUploadCommand,
 } from '@aws-sdk/client-s3';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import type { NonBlankString } from '@growi/core/dist/interfaces';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 import urljoin from 'url-join';
 
 import type Crowi from '~/server/crowi';
@@ -79,14 +81,15 @@ const getS3PutObjectCannedAcl = (): ObjectCannedACL | undefined => {
   return undefined;
 };
 
-const getS3Bucket = (): string | undefined => {
-  return configManager.getConfig('aws:s3Bucket') ?? undefined; // return undefined when getConfig() returns null
+const getS3Bucket = (): NonBlankString | undefined => {
+  return toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Bucket')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
 };
 
 const S3Factory = (): S3Client => {
   const accessKeyId = configManager.getConfig('aws:s3AccessKeyId');
   const secretAccessKey = configManager.getConfig('aws:s3SecretAccessKey');
-  const s3CustomEndpoint = configManager.getConfig('aws:s3CustomEndpoint') || undefined;
+  const s3Region = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Region')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
+  const s3CustomEndpoint = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3CustomEndpoint'));
 
   return new S3Client({
     credentials: accessKeyId != null && secretAccessKey != null
@@ -95,9 +98,9 @@ const S3Factory = (): S3Client => {
         secretAccessKey,
       }
       : undefined,
-    region: configManager.getConfig('aws:s3Region'),
+    region: s3Region,
     endpoint: s3CustomEndpoint,
-    forcePathStyle: !!s3CustomEndpoint, // s3ForcePathStyle renamed to forcePathStyle in v3
+    forcePathStyle: s3CustomEndpoint != null, // s3ForcePathStyle renamed to forcePathStyle in v3
   });
 };
 

+ 4 - 3
apps/app/src/server/service/file-uploader/azure.ts

@@ -17,6 +17,7 @@ import {
   type BlockBlobUploadResponse,
   type BlockBlobParallelUploadOptions,
 } from '@azure/storage-blob';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 
 import type Crowi from '~/server/crowi';
 import { FilePathOnStoragePrefix, ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
@@ -60,9 +61,9 @@ function getAzureConfig(): AzureConfig {
 }
 
 function getCredential(): TokenCredential {
-  const tenantId = configManager.getConfig('azure:tenantId');
-  const clientId = configManager.getConfig('azure:clientId');
-  const clientSecret = configManager.getConfig('azure:clientSecret');
+  const tenantId = toNonBlankStringOrUndefined(configManager.getConfig('azure:tenantId'));
+  const clientId = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientId'));
+  const clientSecret = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientSecret'));
 
   if (tenantId == null || clientId == null || clientSecret == null) {
     throw new Error(`Azure Blob Storage missing required configuration: tenantId=${tenantId}, clientId=${clientId}, clientSecret=${clientSecret}`);

+ 3 - 2
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -2,6 +2,7 @@ import type { Readable } from 'stream';
 import { pipeline } from 'stream/promises';
 
 import { Storage } from '@google-cloud/storage';
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
 import axios from 'axios';
 import urljoin from 'url-join';
 
@@ -24,7 +25,7 @@ const logger = loggerFactory('growi:service:fileUploaderGcs');
 
 
 function getGcsBucket(): string {
-  const gcsBucket = configManager.getConfig('gcs:bucket');
+  const gcsBucket = toNonBlankStringOrUndefined(configManager.getConfig('gcs:bucket')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
   if (gcsBucket == null) {
     throw new Error('GCS bucket is not configured.');
   }
@@ -34,7 +35,7 @@ function getGcsBucket(): string {
 let storage: Storage;
 function getGcsInstance() {
   if (storage == null) {
-    const keyFilename = configManager.getConfig('gcs:apiKeyJsonPath');
+    const keyFilename = toNonBlankStringOrUndefined(configManager.getConfig('gcs:apiKeyJsonPath')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
     // see https://googleapis.dev/nodejs/storage/latest/Storage.html
     storage = keyFilename != null
       ? new Storage({ keyFilename }) // Create a client with explicit credentials

+ 4 - 1
packages/core/src/interfaces/config-manager.ts

@@ -43,7 +43,10 @@ export type RawConfigData<K extends string, V extends Record<K, any>> = Record<K
   definition?: ConfigDefinition<V[K]>;
 }>;
 
-export type UpdateConfigOptions = { skipPubsub?: boolean };
+export type UpdateConfigOptions = {
+  skipPubsub?: boolean;
+  removeIfUndefined?: boolean;
+};
 
 /**
  * Interface for managing configuration values

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -1,3 +1,4 @@
+export * from './primitive/string';
 export * from './attachment';
 export * from './color-scheme';
 export * from './color-scheme';

+ 164 - 0
packages/core/src/interfaces/primitive/string.spec.ts

@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  isNonEmptyString,
+  toNonEmptyString,
+  toNonEmptyStringOrUndefined,
+  isNonBlankString,
+  toNonBlankString,
+  toNonBlankStringOrUndefined,
+} from './string';
+
+describe('isNonEmptyString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${true}       | ${'non-empty string'}
+    ${'world'}    | ${true}       | ${'non-empty string'}
+    ${'a'}        | ${true}       | ${'single character'}
+    ${'1'}        | ${true}       | ${'numeric string'}
+    ${' '}        | ${true}       | ${'space character'}
+    ${'   '}      | ${true}       | ${'multiple spaces'}
+    ${''}         | ${false}      | ${'empty string'}
+    ${null}       | ${false}      | ${'null'}
+    ${undefined}  | ${false}      | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(isNonEmptyString(input)).toBe(expected);
+  });
+});
+
+describe('isNonBlankString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${true}       | ${'non-blank string'}
+    ${'world'}    | ${true}       | ${'non-blank string'}
+    ${'a'}        | ${true}       | ${'single character'}
+    ${'1'}        | ${true}       | ${'numeric string'}
+    ${' '}        | ${false}      | ${'space character'}
+    ${'   '}      | ${false}      | ${'multiple spaces'}
+    ${'\t'}       | ${false}      | ${'tab character'}
+    ${'\n'}       | ${false}      | ${'newline character'}
+    ${''}         | ${false}      | ${'empty string'}
+    ${null}       | ${false}      | ${'null'}
+    ${undefined}  | ${false}      | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(isNonBlankString(input)).toBe(expected);
+  });
+});
+
+describe('toNonEmptyStringOrUndefined', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-empty string'}
+    ${'world'}    | ${'world'}    | ${'non-empty string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+    ${' '}        | ${' '}        | ${'space character'}
+    ${'   '}      | ${'   '}      | ${'multiple spaces'}
+    ${''}         | ${undefined}  | ${'empty string'}
+    ${null}       | ${undefined}  | ${'null'}
+    ${undefined}  | ${undefined}  | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonEmptyStringOrUndefined(input)).toBe(expected);
+  });
+});
+
+describe('toNonBlankStringOrUndefined', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-blank string'}
+    ${'world'}    | ${'world'}    | ${'non-blank string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+    ${' '}        | ${undefined}  | ${'space character'}
+    ${'   '}      | ${undefined}  | ${'multiple spaces'}
+    ${'\t'}       | ${undefined}  | ${'tab character'}
+    ${'\n'}       | ${undefined}  | ${'newline character'}
+    ${''}         | ${undefined}  | ${'empty string'}
+    ${null}       | ${undefined}  | ${'null'}
+    ${undefined}  | ${undefined}  | ${'undefined'}
+  `('should return $expected for $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonBlankStringOrUndefined(input)).toBe(expected);
+  });
+});
+
+describe('toNonEmptyString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-empty string'}
+    ${'world'}    | ${'world'}    | ${'non-empty string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+    ${' '}        | ${' '}        | ${'space character'}
+    ${'   '}      | ${'   '}      | ${'multiple spaces'}
+  `('should return $expected for valid $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonEmptyString(input)).toBe(expected);
+  });
+
+  /* eslint-disable indent */
+  it.each`
+    input         | description
+    ${''}         | ${'empty string'}
+    ${null}       | ${'null'}
+    ${undefined}  | ${'undefined'}
+  `('should throw error for invalid $description: $input', ({ input }) => {
+  /* eslint-enable indent */
+    expect(() => toNonEmptyString(input)).toThrow('Expected a non-empty string, but received:');
+  });
+});
+
+describe('toNonBlankString', () => {
+  /* eslint-disable indent */
+  it.each`
+    input         | expected      | description
+    ${'hello'}    | ${'hello'}    | ${'non-blank string'}
+    ${'world'}    | ${'world'}    | ${'non-blank string'}
+    ${'a'}        | ${'a'}        | ${'single character'}
+    ${'1'}        | ${'1'}        | ${'numeric string'}
+  `('should return $expected for valid $description: $input', ({ input, expected }) => {
+  /* eslint-enable indent */
+    expect(toNonBlankString(input)).toBe(expected);
+  });
+
+  /* eslint-disable indent */
+  it.each`
+    input         | description
+    ${' '}        | ${'space character'}
+    ${'   '}      | ${'multiple spaces'}
+    ${'\t'}       | ${'tab character'}
+    ${'\n'}       | ${'newline character'}
+    ${''}         | ${'empty string'}
+    ${null}       | ${'null'}
+    ${undefined}  | ${'undefined'}
+  `('should throw error for invalid $description: $input', ({ input }) => {
+  /* eslint-enable indent */
+    expect(() => toNonBlankString(input)).toThrow('Expected a non-blank string, but received:');
+  });
+});
+
+describe('type safety', () => {
+  it('should maintain type safety with branded types', () => {
+    const validString = 'test';
+
+    const nonEmptyResult = toNonEmptyStringOrUndefined(validString);
+    const nonBlankResult = toNonBlankStringOrUndefined(validString);
+
+    expect(nonEmptyResult).toBe(validString);
+    expect(nonBlankResult).toBe(validString);
+
+    // These types should be different at compile time
+    // but we can't easily test that in runtime
+    if (nonEmptyResult !== undefined && nonBlankResult !== undefined) {
+      expect(nonEmptyResult).toBe(nonBlankResult);
+    }
+  });
+});

+ 77 - 0
packages/core/src/interfaces/primitive/string.ts

@@ -0,0 +1,77 @@
+/**
+ * A branded type representing a string that is guaranteed to be non-empty (length > 0).
+ * This type allows distinguishing non-empty strings from regular strings at compile time.
+ */
+export type NonEmptyString = string & { readonly __brand: unique symbol };
+
+/**
+ * Checks if a value is a non-empty string.
+ * @param value - The value to check
+ * @returns True if the value is a string with length > 0, false otherwise
+ */
+export const isNonEmptyString = (value: string | null | undefined): value is NonEmptyString => {
+  return value != null && value.length > 0;
+};
+
+/**
+ * Converts a string to NonEmptyString type.
+ * @param value - The string to convert
+ * @returns The string as NonEmptyString type
+ * @throws Error if the value is null, undefined, or empty string
+ */
+export const toNonEmptyString = (value: string): NonEmptyString => {
+  // throw Error if the value is null, undefined or empty
+  if (!isNonEmptyString(value)) throw new Error(`Expected a non-empty string, but received: ${value}`);
+  return value;
+};
+
+/**
+ * Converts a string to NonEmptyString type or returns undefined.
+ * @param value - The string to convert
+ * @returns The string as NonEmptyString type, or undefined if the value is null, undefined, or empty
+ */
+export const toNonEmptyStringOrUndefined = (value: string | null | undefined): NonEmptyString | undefined => {
+  // return undefined if the value is null, undefined or empty
+  if (!isNonEmptyString(value)) return undefined;
+  return value;
+};
+
+/**
+ * A branded type representing a string that is guaranteed to be non-blank.
+ * A non-blank string contains at least one non-whitespace character.
+ * This type allows distinguishing non-blank strings from regular strings at compile time.
+ */
+export type NonBlankString = string & { readonly __brand: unique symbol };
+
+/**
+ * Checks if a value is a non-blank string.
+ * A non-blank string is a string that contains at least one non-whitespace character.
+ * @param value - The value to check
+ * @returns True if the value is a string with trimmed length > 0, false otherwise
+ */
+export const isNonBlankString = (value: string | null | undefined): value is NonBlankString => {
+  return value != null && value.trim().length > 0;
+};
+
+/**
+ * Converts a string to NonBlankString type.
+ * @param value - The string to convert
+ * @returns The string as NonBlankString type
+ * @throws Error if the value is null, undefined, empty string, or contains only whitespace characters
+ */
+export const toNonBlankString = (value: string): NonBlankString => {
+  // throw Error if the value is null, undefined or empty
+  if (!isNonBlankString(value)) throw new Error(`Expected a non-blank string, but received: ${value}`);
+  return value;
+};
+
+/**
+ * Converts a string to NonBlankString type or returns undefined.
+ * @param value - The string to convert
+ * @returns The string as NonBlankString type, or undefined if the value is null, undefined, empty, or contains only whitespace characters
+ */
+export const toNonBlankStringOrUndefined = (value: string | null | undefined): NonBlankString | undefined => {
+  // return undefined if the value is null, undefined or blank (empty or whitespace only)
+  if (!isNonBlankString(value)) return undefined;
+  return value;
+};