Repository: openops-cloud/openops
Pull request #502: Replace upsert config with save config
Author: login: bigfluffycookie
Created: 2025-04-25T10:25:58Z | Merged: 2025-04-29T10:07:48Z

Fixes OPS-1631


Diff:
diff --git a/packages/server/api/src/app/ai/config/ai-config.controller.ts b/packages/server/api/src/app/ai/config/ai-config.controller.ts
index c2d5807d41..66facf4fd6 100644
--- a/packages/server/api/src/app/ai/config/ai-config.controller.ts
+++ b/packages/server/api/src/app/ai/config/ai-config.controller.ts
@@ -20,7 +20,7 @@ export const aiConfigController: FastifyPluginAsyncTypebox = async (app) => {
         return reply.status(StatusCodes.BAD_REQUEST).send(error);
       }
 
-      const aiConfig = await aiConfigService.upsert({
+      const aiConfig = await aiConfigService.save({
         projectId: request.principal.projectId,
         request: request.body,
       });
diff --git a/packages/server/api/src/app/ai/config/ai-config.service.ts b/packages/server/api/src/app/ai/config/ai-config.service.ts
index 7e245c77c9..55485bd78a 100644
--- a/packages/server/api/src/app/ai/config/ai-config.service.ts
+++ b/packages/server/api/src/app/ai/config/ai-config.service.ts
@@ -26,43 +26,31 @@ function redactApiKey(config: AiConfig): AiConfigRedacted {
 }
 
 export const aiConfigService = {
-  async upsert(params: {
+  async save(params: {
     projectId: string;
     request: SaveAiConfigRequest;
   }): Promise<AiConfigRedacted> {
     const { projectId, request } = params;
-    let existing: AiConfig | null = null;
 
-    if (request.id) {
-      existing = await repo().findOneBy({
-        id: request.id,
-        projectId,
-      });
-    }
+    const existing = request.id
+      ? await repo().findOneBy({ id: request.id, projectId })
+      : null;
+
+    const encryptedApiKey =
+      request.apiKey !== AiApiKeyRedactionMessage
+        ? JSON.stringify(encryptUtils.encryptString(request.apiKey))
+        : existing?.apiKey;
 
     const aiConfig: Partial<AiConfig> = {
       ...request,
+      id: request?.id ?? openOpsId(),
       projectId,
-      id: existing?.id ?? openOpsId(),
       created: existing?.created ?? new Date().toISOString(),
       updated: new Date().toISOString(),
+      apiKey: encryptedApiKey,
     };
 
-    if (request.apiKey !== AiApiKeyRedactionMessage) {
-      aiConfig.apiKey = JSON.stringify(
-        encryptUtils.encryptString(request.apiKey),
-      );
-    } else {
-      aiConfig.apiKey = existing?.apiKey;
-    }
-
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    await repo().upsert(aiConfig as any, ['id']);
-
-    const config = await repo().findOneByOrFail({
-      projectId,
-      provider: request.provider,
-    });
+    const config = await repo().save(aiConfig);
 
     return redactApiKey(config);
   },
diff --git a/packages/server/api/test/unit/ai/ai-config.service.test.ts b/packages/server/api/test/unit/ai/ai-config.service.test.ts
index a1efe77d0d..733e78c612 100644
--- a/packages/server/api/test/unit/ai/ai-config.service.test.ts
+++ b/packages/server/api/test/unit/ai/ai-config.service.test.ts
@@ -6,7 +6,7 @@ jest.mock('@openops/shared', () => ({
 }));
 
 const findOneByMock = jest.fn();
-const upsertMock = jest.fn();
+const saveMock = jest.fn();
 const findOneByOrFailMock = jest.fn();
 const findByMock = jest.fn();
 const deleteMock = jest.fn();
@@ -15,7 +15,7 @@ jest.mock('../../../src/app/core/db/repo-factory', () => ({
   ...jest.requireActual('../../../src/app/core/db/repo-factory'),
   repoFactory: () => () => ({
     findOneBy: findOneByMock,
-    upsert: upsertMock,
+    save: saveMock,
     findOneByOrFail: findOneByOrFailMock,
     findBy: findByMock,
     delete: deleteMock,
@@ -35,7 +35,7 @@ import { AiProviderEnum, SaveAiConfigRequest } from '@openops/shared';
 import { AiApiKeyRedactionMessage } from '../../../src/app/ai/config/ai-config.entity';
 import { aiConfigService } from '../../../src/app/ai/config/ai-config.service';
 
-describe('aiConfigService.upsert', () => {
+describe('aiConfigService.save', () => {
   const baseRequest: SaveAiConfigRequest = {
     provider: AiProviderEnum.OPENAI,
     apiKey: 'test-key',
@@ -51,87 +51,81 @@ describe('aiConfigService.upsert', () => {
   });
 
   test('should insert a new ai config when one does not exist', async () => {
-    findOneByOrFailMock.mockResolvedValue({
+    findOneByMock.mockResolvedValue(null);
+    saveMock.mockResolvedValue({
       ...baseRequest,
-      projectId,
       id: 'mocked-id',
+      projectId,
+      apiKey: 'test-encrypt',
     });
 
-    const result = await aiConfigService.upsert({
+    const result = await aiConfigService.save({
       projectId,
       request: baseRequest,
     });
 
     expect(findOneByMock).not.toHaveBeenCalled();
-    expect(upsertMock).toHaveBeenCalledWith(
-      {
-        ...baseRequest,
-        projectId,
-        apiKey: JSON.stringify('test-encrypt'),
-        created: expect.any(String),
-        updated: expect.any(String),
-        id: 'mocked-id',
-      },
-      ['id'],
-    );
-    expect(findOneByOrFailMock).toHaveBeenCalledWith({
+    expect(saveMock).toHaveBeenCalledWith({
+      ...baseRequest,
+      id: 'mocked-id',
       projectId,
-      provider: baseRequest.provider,
+      apiKey: JSON.stringify('test-encrypt'),
+      created: expect.any(String),
+      updated: expect.any(String),
     });
+    expect(encryptStringMock).toHaveBeenCalledWith(baseRequest.apiKey);
     expect(result).toMatchObject({
       ...baseRequest,
+      id: 'mocked-id',
       projectId,
       apiKey: '**REDACTED**',
-      id: 'mocked-id',
     });
-    expect(encryptStringMock).toHaveBeenCalledWith(baseRequest.apiKey);
   });
 
   test('should update existing ai config if it exists', async () => {
     const existingId = 'existing-id';
-    findOneByMock.mockResolvedValue({ id: existingId });
-    const fakeTimestamp = '2025-04-22T12:00:00Z';
-    findOneByOrFailMock.mockResolvedValue({
+    findOneByMock.mockResolvedValue({
+      id: existingId,
+      created: '2025-04-22T12:00:00Z',
+    });
+    saveMock.mockResolvedValue({
       ...baseRequest,
       id: existingId,
       projectId,
-      created: fakeTimestamp,
-      updated: fakeTimestamp,
+      apiKey: 'test-encrypt',
     });
 
-    const result = await aiConfigService.upsert({
+    const result = await aiConfigService.save({
       projectId,
       request: { id: existingId, ...baseRequest },
     });
 
-    expect(upsertMock).toHaveBeenCalledWith(
-      {
-        ...baseRequest,
-        id: existingId,
-        projectId,
-        apiKey: JSON.stringify('test-encrypt'),
-        created: expect.any(String),
-        updated: expect.any(String),
-      },
-      ['id'],
-    );
-    expect(encryptStringMock).toHaveBeenCalledWith(baseRequest.apiKey);
+    expect(findOneByMock).toHaveBeenCalledWith({ id: existingId, projectId });
+    expect(saveMock).toHaveBeenCalledWith({
+      ...baseRequest,
+      id: existingId,
+      projectId,
+      created: '2025-04-22T12:00:00Z',
+      updated: expect.any(String),
+      apiKey: JSON.stringify('test-encrypt'),
+    });
     expect(result).toMatchObject({
       ...baseRequest,
       id: existingId,
       projectId,
       apiKey: '**REDACTED**',
-      created: expect.any(String),
-      updated: expect.any(String),
     });
   });
 
   test('should not overwrite apiKey if redacted message is received', async () => {
     const existingId = 'existing-id';
     const existingApiKey = 'already-encrypted-key';
-
-    findOneByMock.mockResolvedValue({ id: existingId, apiKey: existingApiKey });
-    findOneByOrFailMock.mockResolvedValue({
+    findOneByMock.mockResolvedValue({
+      id: existingId,
+      apiKey: existingApiKey,
+      created: '2025-04-22T12:00:00Z',
+    });
+    saveMock.mockResolvedValue({
       ...baseRequest,
       id: existingId,
       projectId,
@@ -144,24 +138,21 @@ describe('aiConfigService.upsert', () => {
       apiKey: AiApiKeyRedactionMessage,
     };
 
-    const result = await aiConfigService.upsert({
+    const result = await aiConfigService.save({
       projectId,
       request: redactedRequest,
     });
 
-    expect(upsertMock).toHaveBeenCalledWith(
-      {
-        ...baseRequest,
-        id: existingId,
-        apiKey: existingApiKey,
-        projectId,
-        created: expect.any(String),
-        updated: expect.any(String),
-      },
-      ['id'],
-    );
-
     expect(encryptStringMock).not.toHaveBeenCalled();
+    expect(saveMock).toHaveBeenCalledWith({
+      ...baseRequest,
+      id: existingId,
+      projectId,
+      created: '2025-04-22T12:00:00Z',
+      updated: expect.any(String),
+      apiKey: existingApiKey,
+    });
+
     expect(result).toMatchObject({
       ...baseRequest,
       apiKey: '**REDACTED**',


Reviews:
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:42:41Z
user: login: MarceloRGonc
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:42:47Z
user: login: bigfluffycookie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:07:46Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T11:29:32Z
user: login: bigfluffycookie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T11:48:39Z

Review comments:
diff_hunk: @@ -26,43 +26,31 @@ function redactApiKey(config: AiConfig): AiConfigRedacted {
 }
 
 export const aiConfigService = {
-  async upsert(params: {
+  async save(params: {
path: packages/server/api/src/app/ai/config/ai-config.service.ts
user: login: MarceloRGonc
body: I think upsert makes more sense. You are either inserting or updating.
created_at: 2025-04-29T09:42:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,43 +26,31 @@ function redactApiKey(config: AiConfig): AiConfigRedacted {
 }
 
 export const aiConfigService = {
-  async upsert(params: {
+  async save(params: {
path: packages/server/api/src/app/ai/config/ai-config.service.ts
user: login: bigfluffycookie
body: yes but right now save makes more sense, it will override the existing one without having to give it conditions.
created_at: 2025-04-29T10:07:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,43 +26,31 @@ function redactApiKey(config: AiConfig): AiConfigRedacted {
 }
 
 export const aiConfigService = {
-  async upsert(params: {
+  async save(params: {
path: packages/server/api/src/app/ai/config/ai-config.service.ts
user: login: MarceloRGonc
body: The creation date will always be the first.
created_at: 2025-04-29T11:29:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,43 +26,31 @@ function redactApiKey(config: AiConfig): AiConfigRedacted {
 }
 
 export const aiConfigService = {
-  async upsert(params: {
+  async save(params: {
path: packages/server/api/src/app/ai/config/ai-config.service.ts
user: login: bigfluffycookie
body: Yes, it was the same before. Unless you delete the entity it stays the same. 
created_at: 2025-04-29T11:48:39Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-25T10:26:01Z
body: <p><a href="https://linear.app/openops/issue/OPS-1631/change-upsert-to-save-in-config">OPS-1631 Change upsert to save in /config</a></p>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-29T07:22:03Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=502) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=502&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=502&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=openops-cloud_openops&pullRequest=502&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=502&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=502&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=502)


author_association: NONE