From 164b6cf70a25d643db88c338d5bb4e3a9fc5ae30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Uro=C5=A1=20Marolt?= Date: Fri, 2 Oct 2026 23:06:46 +0200 Subject: [PATCH] feat: add Octolens keyword-mapping DAL (CM-1834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Uroš Marolt --- services/libs/data-access-layer/src/index.ts | 1 + .../src/octolens/index.test.ts | 149 ++++++++++++++++++ .../data-access-layer/src/octolens/index.ts | 76 +++++++++ 3 files changed, 226 insertions(+) create mode 100644 services/libs/data-access-layer/src/octolens/index.test.ts create mode 100644 services/libs/data-access-layer/src/octolens/index.ts diff --git a/services/libs/data-access-layer/src/index.ts b/services/libs/data-access-layer/src/index.ts index bef883cd0f..97861e0b7a 100644 --- a/services/libs/data-access-layer/src/index.ts +++ b/services/libs/data-access-layer/src/index.ts @@ -19,6 +19,7 @@ export * from './segments' export * from './systemSettings' export * from './connectors' export * from './integrations' +export * from './octolens' export * from './auditLogs' export * from './maintainers' export * from './packages' diff --git a/services/libs/data-access-layer/src/octolens/index.test.ts b/services/libs/data-access-layer/src/octolens/index.test.ts new file mode 100644 index 0000000000..fc92f36398 --- /dev/null +++ b/services/libs/data-access-layer/src/octolens/index.test.ts @@ -0,0 +1,149 @@ +import { test as base, describe, expect } from 'vitest' + +import { DEFAULT_TENANT_ID, generateUUIDv1 } from '@crowd/common' +import type { QueryExecutor } from '@crowd/database' +import { withQx } from '@crowd/test-kit/db' + +import { + addOctolensKeywordMapping, + findOctolensKeywordMapping, + listOctolensKeywordMappings, + removeOctolensKeywordMapping, +} from './index' + +const test = withQx(base) + +async function createIntegration(qx: QueryExecutor): Promise { + const id = generateUUIDv1() + await qx.result( + ` + INSERT INTO public.integrations (id, platform, status, "tenantId", "createdAt", "updatedAt", "deletedAt") + VALUES ($(id), 'octolens', 'done', $(tenantId), NOW(), NOW(), NULL) + `, + { id, tenantId: DEFAULT_TENANT_ID }, + ) + return id +} + +async function createSegment(qx: QueryExecutor): Promise { + const id = generateUUIDv1() + const slug = `segment-${id}` + await qx.result( + ` + INSERT INTO public.segments (id, slug, name, "tenantId", "createdAt", "updatedAt") + VALUES ($(id), $(slug), $(slug), $(tenantId), NOW(), NOW()) + `, + { id, slug, tenantId: DEFAULT_TENANT_ID }, + ) + return id +} + +describe('octolens keyword mappings', () => { + test('addOctolensKeywordMapping + findOctolensKeywordMapping', async ({ qx }) => { + const integrationId = await createIntegration(qx) + const segmentId = await createSegment(qx) + + const id = await addOctolensKeywordMapping(qx, { + integrationId, + segmentId, + keywordId: 101, + keyword: 'crowd.dev', + }) + + const mapping = await findOctolensKeywordMapping(qx, integrationId, 101) + + expect(mapping).toMatchObject({ + id, + integrationId, + segmentId, + keywordId: 101, + keyword: 'crowd.dev', + }) + }) + + test('findOctolensKeywordMapping returns null when no mapping exists', async ({ qx }) => { + const integrationId = await createIntegration(qx) + + const mapping = await findOctolensKeywordMapping(qx, integrationId, 999) + + expect(mapping).toBeNull() + }) + + test('listOctolensKeywordMappings returns only mappings for the given integration', async ({ + qx, + }) => { + const integrationId = await createIntegration(qx) + const otherIntegrationId = await createIntegration(qx) + const segmentId = await createSegment(qx) + + await addOctolensKeywordMapping(qx, { + integrationId, + segmentId, + keywordId: 1, + keyword: 'alpha', + }) + await addOctolensKeywordMapping(qx, { + integrationId, + segmentId, + keywordId: 2, + keyword: 'beta', + }) + await addOctolensKeywordMapping(qx, { + integrationId: otherIntegrationId, + segmentId, + keywordId: 3, + keyword: 'gamma', + }) + + const mappings = await listOctolensKeywordMappings(qx, integrationId) + + expect(mappings).toHaveLength(2) + expect(mappings.map((m) => m.keyword).sort()).toEqual(['alpha', 'beta']) + }) + + test('addOctolensKeywordMapping rejects a duplicate (integrationId, keywordId) pair', async ({ + qx, + }) => { + const integrationId = await createIntegration(qx) + const segmentId = await createSegment(qx) + + await addOctolensKeywordMapping(qx, { + integrationId, + segmentId, + keywordId: 42, + keyword: 'first', + }) + + await expect( + addOctolensKeywordMapping(qx, { + integrationId, + segmentId, + keywordId: 42, + keyword: 'second', + }), + ).rejects.toThrow() + }) + + test('removeOctolensKeywordMapping deletes the mapping', async ({ qx }) => { + const integrationId = await createIntegration(qx) + const segmentId = await createSegment(qx) + + await addOctolensKeywordMapping(qx, { + integrationId, + segmentId, + keywordId: 7, + keyword: 'delete-me', + }) + + await removeOctolensKeywordMapping(qx, integrationId, 7) + + const mapping = await findOctolensKeywordMapping(qx, integrationId, 7) + expect(mapping).toBeNull() + }) + + test('removeOctolensKeywordMapping is a no-op when no mapping matches', async ({ qx }) => { + const integrationId = await createIntegration(qx) + + await expect(removeOctolensKeywordMapping(qx, integrationId, 404)).resolves.toBeUndefined() + }) +}) diff --git a/services/libs/data-access-layer/src/octolens/index.ts b/services/libs/data-access-layer/src/octolens/index.ts new file mode 100644 index 0000000000..3901a8e1f6 --- /dev/null +++ b/services/libs/data-access-layer/src/octolens/index.ts @@ -0,0 +1,76 @@ +import { QueryExecutor } from '../queryExecutor' + +export interface IOctolensKeywordMapping { + id: string + integrationId: string + segmentId: string + keywordId: number + keyword: string +} + +export async function findOctolensKeywordMapping( + qx: QueryExecutor, + integrationId: string, + keywordId: number, +): Promise { + return qx.selectOneOrNone( + ` + SELECT id, "integrationId", "segmentId", "keywordId", keyword + FROM integration."octolensKeywordMappings" + WHERE "integrationId" = $(integrationId) AND "keywordId" = $(keywordId) + `, + { integrationId, keywordId }, + ) +} + +export async function listOctolensKeywordMappings( + qx: QueryExecutor, + integrationId: string, +): Promise { + return qx.select( + ` + SELECT id, "integrationId", "segmentId", "keywordId", keyword + FROM integration."octolensKeywordMappings" + WHERE "integrationId" = $(integrationId) + ORDER BY keyword + `, + { integrationId }, + ) +} + +export interface IAddOctolensKeywordMapping { + integrationId: string + segmentId: string + keywordId: number + keyword: string +} + +export async function addOctolensKeywordMapping( + qx: QueryExecutor, + data: IAddOctolensKeywordMapping, +): Promise { + const result = await qx.selectOne( + ` + INSERT INTO integration."octolensKeywordMappings" ("integrationId", "segmentId", "keywordId", keyword) + VALUES ($(integrationId), $(segmentId), $(keywordId), $(keyword)) + RETURNING id + `, + data, + ) + + return result.id +} + +export async function removeOctolensKeywordMapping( + qx: QueryExecutor, + integrationId: string, + keywordId: number, +): Promise { + await qx.result( + ` + DELETE FROM integration."octolensKeywordMappings" + WHERE "integrationId" = $(integrationId) AND "keywordId" = $(keywordId) + `, + { integrationId, keywordId }, + ) +}