From c11b2696d179bcc97589e78b1d48318f91ccd33c Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 15:51:04 +0100 Subject: [PATCH 01/15] feat: add metrics api --- .../src/api/dashboard/dashboardMetricsGet.ts | 12 ++++ backend/src/api/dashboard/index.ts | 1 + backend/src/services/dashboardService.ts | 24 +++++++ .../data-access-layer/src/dashboards/base.ts | 63 +++++++++++++++++++ .../data-access-layer/src/dashboards/index.ts | 2 + .../data-access-layer/src/dashboards/types.ts | 10 +++ services/libs/data-access-layer/src/index.ts | 1 + 7 files changed, 113 insertions(+) create mode 100644 backend/src/api/dashboard/dashboardMetricsGet.ts create mode 100644 services/libs/data-access-layer/src/dashboards/base.ts create mode 100644 services/libs/data-access-layer/src/dashboards/index.ts create mode 100644 services/libs/data-access-layer/src/dashboards/types.ts diff --git a/backend/src/api/dashboard/dashboardMetricsGet.ts b/backend/src/api/dashboard/dashboardMetricsGet.ts new file mode 100644 index 0000000000..ddf277da0a --- /dev/null +++ b/backend/src/api/dashboard/dashboardMetricsGet.ts @@ -0,0 +1,12 @@ +import DashboardService from '@/services/dashboardService' + +import Permissions from '../../security/permissions' +import PermissionChecker from '../../services/user/permissionChecker' + +export default async (req, res) => { + new PermissionChecker(req).validateHas(Permissions.values.memberRead) + + const payload = await new DashboardService(req).getMetrics(req.query) + + await req.responseHandler.success(req, res, payload) +} diff --git a/backend/src/api/dashboard/index.ts b/backend/src/api/dashboard/index.ts index 0fd01a65c3..7d370cfe07 100644 --- a/backend/src/api/dashboard/index.ts +++ b/backend/src/api/dashboard/index.ts @@ -2,4 +2,5 @@ import { safeWrap } from '../../middlewares/errorMiddleware' export default (app) => { app.get(`/dashboard`, safeWrap(require('./dashboardGet').default)) + app.get(`/dashboard/metrics`, safeWrap(require('./dashboardMetricsGet').default)) } diff --git a/backend/src/services/dashboardService.ts b/backend/src/services/dashboardService.ts index 8569634fb3..8bf70a2864 100644 --- a/backend/src/services/dashboardService.ts +++ b/backend/src/services/dashboardService.ts @@ -1,6 +1,9 @@ +import { getMetrics } from '@crowd/data-access-layer/src/dashboards' import { RedisCache } from '@crowd/redis' import { DashboardTimeframe } from '@crowd/types' +import SequelizeRepository from '../database/repositories/sequelizeRepository' + import { IServiceOptions } from './IServiceOptions' interface IDashboardQueryParams { @@ -9,6 +12,10 @@ interface IDashboardQueryParams { timeframe: DashboardTimeframe } +interface IDashboardMetricsQueryParams { + segment?: string +} + export default class DashboardService { options: IServiceOptions @@ -75,4 +82,21 @@ export default class DashboardService { return JSON.parse(data) } + + async getMetrics(params: IDashboardMetricsQueryParams) { + try { + const segmentId = params.segment || this.options.currentSegments[0]?.id + + if (!segmentId) { + this.options.log.warn('No segment ID provided for metrics query') + } + + const qx = SequelizeRepository.getQueryExecutor(this.options) + const metrics = await getMetrics(qx, segmentId) + return metrics + } catch (error) { + this.options.log.error('Failed to fetch dashboard metrics', { error, params }) + throw new Error('Unable to fetch dashboard metrics') + } + } } diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts new file mode 100644 index 0000000000..60586320d1 --- /dev/null +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -0,0 +1,63 @@ +import { QueryExecutor } from '../queryExecutor' + +import { IDashboardMetrics } from './types' + +export async function getMetrics( + qx: QueryExecutor, + segmentId?: string, +): Promise { + const tableName = segmentId + ? 'dashboardMetricsPerSegmentSnapshot' + : 'dashboardMetricsTotalSnapshot' + + const query = segmentId + ? ` + SELECT * + FROM "${tableName}" + WHERE "segmentId" = $(segmentId) + LIMIT 1 + ` + : ` + SELECT * + FROM "${tableName}" + LIMIT 1 + ` + + const params = segmentId ? { segmentId } : {} + + try { + const [row] = (await qx.select(query, params)) as IDashboardMetrics[] + + if (!row) { + // TODO: remove this mock once Tinybird sinks are available + return getMockMetrics() + } + + return row + } catch (error: any) { + const msg = error?.message ?? '' + + // Detect missing table + const isMissingTable = error?.code === '42P01' || /does not exist/i.test(msg) + + if (isMissingTable) { + // TODO: remove this mock once Tinybird sinks are available + return getMockMetrics() + } + + throw error + } +} + +function getMockMetrics(): IDashboardMetrics { + return { + activitiesTotal: 9926553, + activitiesLast30Days: 64329, + organizationsTotal: 104300, + organizationsLast30Days: 36, + membersTotal: 798730, + membersLast30Days: 2694, + projectsTotal: 123, + projectsLast30Days: 12312, + } +} diff --git a/services/libs/data-access-layer/src/dashboards/index.ts b/services/libs/data-access-layer/src/dashboards/index.ts new file mode 100644 index 0000000000..b097b26bf7 --- /dev/null +++ b/services/libs/data-access-layer/src/dashboards/index.ts @@ -0,0 +1,2 @@ +export * from './base' +export * from './types' diff --git a/services/libs/data-access-layer/src/dashboards/types.ts b/services/libs/data-access-layer/src/dashboards/types.ts new file mode 100644 index 0000000000..a8b3eb1066 --- /dev/null +++ b/services/libs/data-access-layer/src/dashboards/types.ts @@ -0,0 +1,10 @@ +export interface IDashboardMetrics { + activitiesTotal: number + activitiesLast30Days: number + organizationsTotal: number + organizationsLast30Days: number + membersTotal: number + membersLast30Days: number + projectsTotal: number + projectsLast30Days: number +} diff --git a/services/libs/data-access-layer/src/index.ts b/services/libs/data-access-layer/src/index.ts index c7963a6a40..05ac370cca 100644 --- a/services/libs/data-access-layer/src/index.ts +++ b/services/libs/data-access-layer/src/index.ts @@ -1,5 +1,6 @@ export * from './activities' export * from './activityRelations' +export * from './dashboards' export * from './members' export * from './organizations' export * from './prompt-history' From 3cd2d576f0f0e42b50cad551332e564df34dbd65 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 15:54:37 +0100 Subject: [PATCH 02/15] fix: lint --- services/libs/data-access-layer/src/dashboards/base.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index 60586320d1..77422da998 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -34,11 +34,12 @@ export async function getMetrics( } return row - } catch (error: any) { - const msg = error?.message ?? '' + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : '' + const code = error && typeof error === 'object' && 'code' in error ? error.code : null // Detect missing table - const isMissingTable = error?.code === '42P01' || /does not exist/i.test(msg) + const isMissingTable = code === '42P01' || /does not exist/i.test(msg) if (isMissingTable) { // TODO: remove this mock once Tinybird sinks are available From 15b16239799a6e79a16f9ae05aaf147666e628e1 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 16:11:02 +0100 Subject: [PATCH 03/15] fix: adjust return type from query --- backend/src/services/dashboardService.ts | 6 ++---- services/libs/data-access-layer/src/dashboards/base.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/src/services/dashboardService.ts b/backend/src/services/dashboardService.ts index 8bf70a2864..42590e26cd 100644 --- a/backend/src/services/dashboardService.ts +++ b/backend/src/services/dashboardService.ts @@ -85,14 +85,12 @@ export default class DashboardService { async getMetrics(params: IDashboardMetricsQueryParams) { try { - const segmentId = params.segment || this.options.currentSegments[0]?.id - - if (!segmentId) { + if (!params.segment) { this.options.log.warn('No segment ID provided for metrics query') } const qx = SequelizeRepository.getQueryExecutor(this.options) - const metrics = await getMetrics(qx, segmentId) + const metrics = await getMetrics(qx, params.segment) return metrics } catch (error) { this.options.log.error('Failed to fetch dashboard metrics', { error, params }) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index 77422da998..9653de35d6 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -26,7 +26,7 @@ export async function getMetrics( const params = segmentId ? { segmentId } : {} try { - const [row] = (await qx.select(query, params)) as IDashboardMetrics[] + const row = await qx.select(query, params) if (!row) { // TODO: remove this mock once Tinybird sinks are available From 1c02537b6c317c8944e98fe57a2ad5d561c3ca94 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 16:22:10 +0100 Subject: [PATCH 04/15] fix: return just first value --- .../libs/data-access-layer/src/dashboards/base.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index 9653de35d6..6367587dff 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -18,7 +18,14 @@ export async function getMetrics( LIMIT 1 ` : ` - SELECT * + SELECT + "activitiesLast30Days", + "activitiesTotal", + "membersLast30Days", + "membersTotal", + "organizationsLast30Days", + "organizationsTotal", + "updatedAt" FROM "${tableName}" LIMIT 1 ` @@ -26,7 +33,7 @@ export async function getMetrics( const params = segmentId ? { segmentId } : {} try { - const row = await qx.select(query, params) + const [row] = await qx.select(query, params) if (!row) { // TODO: remove this mock once Tinybird sinks are available From 2a00f467e0de7bab64cd8761578f7deeadf6264b Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 17:44:37 +0100 Subject: [PATCH 05/15] feat: add project metric --- .../data-access-layer/src/dashboards/base.ts | 99 +++++++++++++++---- 1 file changed, 80 insertions(+), 19 deletions(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index 6367587dff..2033f643d0 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -6,6 +6,53 @@ export async function getMetrics( qx: QueryExecutor, segmentId?: string, ): Promise { + try { + const [snapshotData, projectsData] = await Promise.all([ + getSnapshotMetrics(qx, segmentId), + getProjectsCount(qx, segmentId), + ]) + + if (!snapshotData) { + // TODO: remove this mock once Tinybird sinks are available + const mockMetrics = getMockMetrics() + return { + ...mockMetrics, + projectsTotal: projectsData.projectsTotal, + projectsLast30Days: projectsData.projectsLast30Days, + } + } + + return { + ...snapshotData, + projectsTotal: projectsData.projectsTotal, + projectsLast30Days: projectsData.projectsLast30Days, + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : '' + const code = error && typeof error === 'object' && 'code' in error ? error.code : null + + // Detect missing table + const isMissingTable = code === '42P01' || /does not exist/i.test(msg) + + if (isMissingTable) { + // TODO: remove this mock once Tinybird sinks are available + const mockMetrics = getMockMetrics() + const projectsData = await getProjectsCount(qx, segmentId) + return { + ...mockMetrics, + projectsTotal: projectsData.projectsTotal, + projectsLast30Days: projectsData.projectsLast30Days, + } + } + + throw error + } +} + +async function getSnapshotMetrics( + qx: QueryExecutor, + segmentId?: string, +): Promise | null> { const tableName = segmentId ? 'dashboardMetricsPerSegmentSnapshot' : 'dashboardMetricsTotalSnapshot' @@ -31,29 +78,43 @@ export async function getMetrics( ` const params = segmentId ? { segmentId } : {} + const [row] = await qx.select(query, params) - try { - const [row] = await qx.select(query, params) - - if (!row) { - // TODO: remove this mock once Tinybird sinks are available - return getMockMetrics() - } - - return row - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : '' - const code = error && typeof error === 'object' && 'code' in error ? error.code : null + return row || null +} - // Detect missing table - const isMissingTable = code === '42P01' || /does not exist/i.test(msg) +async function getProjectsCount( + qx: QueryExecutor, + segmentId?: string, +): Promise<{ projectsTotal: number; projectsLast30Days: number }> { + let query: string + let params: Record - if (isMissingTable) { - // TODO: remove this mock once Tinybird sinks are available - return getMockMetrics() - } + if (!segmentId) { + // Count all segments + query = ` + SELECT + COUNT(*) as "projectsTotal", + COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" + FROM segments + ` + params = {} + } else { + // Count segments where the provided segmentId is current, parent, or grandparent + query = ` + SELECT + COUNT(*) as "projectsTotal", + COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" + FROM segments s + WHERE (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) + ` + params = { segmentId } + } - throw error + const [result] = await qx.select(query, params) + return { + projectsTotal: parseInt(result.projectsTotal) || 0, + projectsLast30Days: parseInt(result.projectsLast30Days) || 0, } } From dadafb3cc7e20a07199bbcb9edce73930c1e56a9 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 17:48:50 +0100 Subject: [PATCH 06/15] fix: lint --- services/libs/data-access-layer/src/dashboards/base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index 2033f643d0..ef1def2929 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -88,7 +88,7 @@ async function getProjectsCount( segmentId?: string, ): Promise<{ projectsTotal: number; projectsLast30Days: number }> { let query: string - let params: Record + let params: Record if (!segmentId) { // Count all segments From 254edd5fd7d9cf7340e42b700ec09eb4ab21bf5c Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 18:27:28 +0100 Subject: [PATCH 07/15] refactor: using semgnets entity --- .../data-access-layer/src/dashboards/base.ts | 36 +----------------- .../data-access-layer/src/segments/index.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index ef1def2929..dbb724c93b 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -1,4 +1,5 @@ import { QueryExecutor } from '../queryExecutor' +import { getProjectsCount } from '../segments' import { IDashboardMetrics } from './types' @@ -83,41 +84,6 @@ async function getSnapshotMetrics( return row || null } -async function getProjectsCount( - qx: QueryExecutor, - segmentId?: string, -): Promise<{ projectsTotal: number; projectsLast30Days: number }> { - let query: string - let params: Record - - if (!segmentId) { - // Count all segments - query = ` - SELECT - COUNT(*) as "projectsTotal", - COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" - FROM segments - ` - params = {} - } else { - // Count segments where the provided segmentId is current, parent, or grandparent - query = ` - SELECT - COUNT(*) as "projectsTotal", - COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" - FROM segments s - WHERE (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) - ` - params = { segmentId } - } - - const [result] = await qx.select(query, params) - return { - projectsTotal: parseInt(result.projectsTotal) || 0, - projectsLast30Days: parseInt(result.projectsLast30Days) || 0, - } -} - function getMockMetrics(): IDashboardMetrics { return { activitiesTotal: 9926553, diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index f0eed84a21..ea368ca4cb 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -260,3 +260,40 @@ export async function getGitlabRepoUrlsMappedToOtherSegments( return rows.map((r) => r.url) } + +export async function getProjectsCount( + qx: QueryExecutor, + segmentId?: string, +): Promise<{ projectsTotal: number; projectsLast30Days: number }> { + let query: string + let params: Record + + if (!segmentId) { + // Count all segments not deleted + query = ` + SELECT + COUNT(*) as "projectsTotal", + COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" + FROM segments + WHERE "deletedAt" IS NULL + ` + params = {} + } else { + // Count segments where the provided segmentId is current, parent, or grandparent + query = ` + SELECT + COUNT(*) as "projectsTotal", + COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" + FROM segments s + WHERE s."deletedAt" IS NULL + AND (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) + ` + params = { segmentId } + } + + const [result] = await qx.select(query, params) + return { + projectsTotal: parseInt(result.projectsTotal) || 0, + projectsLast30Days: parseInt(result.projectsLast30Days) || 0, + } +} From 62002828629d21b637d7acef2c9d4fc1b6b07c11 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Thu, 11 Dec 2025 19:05:30 +0100 Subject: [PATCH 08/15] fix: remove deleted at --- services/libs/data-access-layer/src/segments/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index ea368ca4cb..ee2bf592f8 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -275,7 +275,6 @@ export async function getProjectsCount( COUNT(*) as "projectsTotal", COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments - WHERE "deletedAt" IS NULL ` params = {} } else { @@ -285,8 +284,7 @@ export async function getProjectsCount( COUNT(*) as "projectsTotal", COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments s - WHERE s."deletedAt" IS NULL - AND (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) + WHERE (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) ` params = { segmentId } } From 5bfb5593fcf5427581844e5574c325a7fddb0824 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 10:59:04 +0100 Subject: [PATCH 09/15] feat: add segment filter on integratino --- .../repositories/integrationRepository.ts | 28 +++++++++++++------ .../src/integrations/index.ts | 15 ++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/backend/src/database/repositories/integrationRepository.ts b/backend/src/database/repositories/integrationRepository.ts index 83a9059a50..8d73b5e78c 100644 --- a/backend/src/database/repositories/integrationRepository.ts +++ b/backend/src/database/repositories/integrationRepository.ts @@ -319,22 +319,30 @@ class IntegrationRepository { * @param {string} [filters.query=''] - The search query to filter integrations. * @param {number} [filters.limit=20] - The maximum number of integrations to return. * @param {number} [filters.offset=0] - The offset for pagination. + * @param {string} [filters.segment=null] - The segment to filter integrations by. * @param {IRepositoryOptions} options - The repository options for querying. * @returns {Promise} The result containing the rows of integrations and metadata about the query. */ static async findGlobalIntegrations( - { platform = null, status = ['done'], query = '', limit = 20, offset = 0 }, + { platform = null, status = ['done'], query = '', limit = 20, offset = 0, segment = null }, options: IRepositoryOptions, ) { const qx = SequelizeRepository.getQueryExecutor(options) if (status.includes('not-connected')) { - const rows = await fetchGlobalNotConnectedIntegrations(qx, platform, query, limit, offset) - const [result] = await fetchGlobalNotConnectedIntegrationsCount(qx, platform, query) + const rows = await fetchGlobalNotConnectedIntegrations( + qx, + platform, + query, + limit, + offset, + segment, + ) + const [result] = await fetchGlobalNotConnectedIntegrationsCount(qx, platform, query, segment) return { rows, count: +result.count, limit: +limit, offset: +offset } } - const rows = await fetchGlobalIntegrations(qx, status, platform, query, limit, offset) - const [result] = await fetchGlobalIntegrationsCount(qx, status, platform, query) + const rows = await fetchGlobalIntegrations(qx, status, platform, query, limit, offset, segment) + const [result] = await fetchGlobalIntegrationsCount(qx, status, platform, query, segment) return { rows, count: +result.count, limit: +limit, offset: +offset } } @@ -344,13 +352,17 @@ class IntegrationRepository { * * @param {Object} param1 - The optional parameters. * @param {string|null} [param1.platform=null] - The platform to filter the integrations. Default is null. + * @param {string|null} [param1.segment=null] - The segment to filter the integrations. Default is null. * @param {IRepositoryOptions} options - The options for the repository operations. * @return {Promise>} A promise that resolves to an array of objects containing the statuses and their counts. */ - static async findGlobalIntegrationsStatusCount({ platform = null }, options: IRepositoryOptions) { + static async findGlobalIntegrationsStatusCount( + { platform = null, segment = null }, + options: IRepositoryOptions, + ) { const qx = SequelizeRepository.getQueryExecutor(options) - const [result] = await fetchGlobalNotConnectedIntegrationsCount(qx, platform, '') - const rows = await fetchGlobalIntegrationsStatusCount(qx, platform) + const [result] = await fetchGlobalNotConnectedIntegrationsCount(qx, platform, '', segment) + const rows = await fetchGlobalIntegrationsStatusCount(qx, platform, segment) return [...rows, { status: 'not-connected', count: +result.count }] } diff --git a/services/libs/data-access-layer/src/integrations/index.ts b/services/libs/data-access-layer/src/integrations/index.ts index 1a6b639d99..8726aa22de 100644 --- a/services/libs/data-access-layer/src/integrations/index.ts +++ b/services/libs/data-access-layer/src/integrations/index.ts @@ -28,6 +28,7 @@ export async function fetchGlobalIntegrations( query: string, limit: number, offset: number, + segmentId?: string | null, ): Promise { return qx.select( ` @@ -46,12 +47,14 @@ export async function fetchGlobalIntegrations( WHERE i."status" = ANY ($(status)::text[]) AND i."deletedAt" IS NULL AND ($(platform) IS NULL OR i."platform" = $(platform)) + AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId)) AND s.name ILIKE $(query) LIMIT $(limit) OFFSET $(offset) `, { status, platform, + segmentId, query: `%${query}%`, limit, offset, @@ -73,6 +76,7 @@ export async function fetchGlobalIntegrationsCount( status: string[], platform: string | null, query: string, + segmentId?: string | null, ): Promise<{ count: number }[]> { return qx.select( ` @@ -82,11 +86,13 @@ export async function fetchGlobalIntegrationsCount( WHERE i."status" = ANY ($(status)::text[]) AND i."deletedAt" IS NULL AND ($(platform) IS NULL OR i."platform" = $(platform)) + AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId)) AND s.name ILIKE $(query) `, { status, platform, + segmentId, query: `%${query}%`, }, ) @@ -109,6 +115,7 @@ export async function fetchGlobalNotConnectedIntegrations( query: string, limit: number, offset: number, + segmentId?: string | null, ): Promise { return qx.select( ` @@ -133,11 +140,13 @@ export async function fetchGlobalNotConnectedIntegrations( AND s."parentId" IS NOT NULL AND s."grandparentId" IS NOT NULL AND ($(platform) IS NULL OR up."platform" = $(platform)) + AND ($(segmentId) IS NULL OR s.id = $(segmentId)) AND s.name ILIKE $(query) LIMIT $(limit) OFFSET $(offset) `, { platform, + segmentId, query: `%${query}%`, limit, offset, @@ -157,6 +166,7 @@ export async function fetchGlobalNotConnectedIntegrationsCount( qx: QueryExecutor, platform: string | null, query: string, + segmentId?: string | null, ): Promise<{ count: number }[]> { return qx.select( ` @@ -175,10 +185,12 @@ export async function fetchGlobalNotConnectedIntegrationsCount( AND s."parentId" IS NOT NULL AND s."grandparentId" IS NOT NULL AND ($(platform) IS NULL OR up."platform" = $(platform)) + AND ($(segmentId) IS NULL OR s.id = $(segmentId)) AND s.name ILIKE $(query) `, { platform, + segmentId, query: `%${query}%`, }, ) @@ -194,6 +206,7 @@ export async function fetchGlobalNotConnectedIntegrationsCount( export async function fetchGlobalIntegrationsStatusCount( qx: QueryExecutor, platform: string | null, + segmentId?: string | null, ): Promise<{ status: string; count: number }[]> { return qx.select( ` @@ -202,10 +215,12 @@ export async function fetchGlobalIntegrationsStatusCount( FROM "integrations" i WHERE i."deletedAt" IS NULL AND ($(platform) IS NULL OR i."platform" = $(platform)) + AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId)) GROUP BY i.status `, { platform, + segmentId, }, ) } From 448eec6d0e4c0b236410a9ff8d01ec38ff5e0475 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 11:04:41 +0100 Subject: [PATCH 10/15] fix: lint --- .../repositories/integrationRepository.ts | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/backend/src/database/repositories/integrationRepository.ts b/backend/src/database/repositories/integrationRepository.ts index 8d73b5e78c..7543c70244 100644 --- a/backend/src/database/repositories/integrationRepository.ts +++ b/backend/src/database/repositories/integrationRepository.ts @@ -315,7 +315,7 @@ class IntegrationRepository { * * @param {Object} filters - An object containing various filter options. * @param {string} [filters.platform=null] - The platform to filter integrations by. - * @param {string[]} [filters.status=['done']] - The status of the integrations to be filtered. + * @param {string | string[]} [filters.status=['done']] - The status of the integrations to be filtered. Can be a single status or array of statuses. * @param {string} [filters.query=''] - The search query to filter integrations. * @param {number} [filters.limit=20] - The maximum number of integrations to return. * @param {number} [filters.offset=0] - The offset for pagination. @@ -324,11 +324,29 @@ class IntegrationRepository { * @returns {Promise} The result containing the rows of integrations and metadata about the query. */ static async findGlobalIntegrations( - { platform = null, status = ['done'], query = '', limit = 20, offset = 0, segment = null }, + { + platform = null, + status = ['done'], + query = '', + limit = 20, + offset = 0, + segment = null, + }: { + platform?: string | null + status?: string | string[] + query?: string + limit?: number + offset?: number + segment?: string | null + }, options: IRepositoryOptions, ) { const qx = SequelizeRepository.getQueryExecutor(options) - if (status.includes('not-connected')) { + + // Ensure status is always an array to prevent type confusion + const statusArray = Array.isArray(status) ? status : [status] + + if (statusArray.includes('not-connected')) { const rows = await fetchGlobalNotConnectedIntegrations( qx, platform, @@ -341,8 +359,16 @@ class IntegrationRepository { return { rows, count: +result.count, limit: +limit, offset: +offset } } - const rows = await fetchGlobalIntegrations(qx, status, platform, query, limit, offset, segment) - const [result] = await fetchGlobalIntegrationsCount(qx, status, platform, query, segment) + const rows = await fetchGlobalIntegrations( + qx, + statusArray, + platform, + query, + limit, + offset, + segment, + ) + const [result] = await fetchGlobalIntegrationsCount(qx, statusArray, platform, query, segment) return { rows, count: +result.count, limit: +limit, offset: +offset } } From 1e909255e11f1a32c4893b872711ddab4ab83e48 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 11:18:17 +0100 Subject: [PATCH 11/15] feat: add segment hierarchy --- .../libs/data-access-layer/src/integrations/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/services/libs/data-access-layer/src/integrations/index.ts b/services/libs/data-access-layer/src/integrations/index.ts index 8726aa22de..bf554b185f 100644 --- a/services/libs/data-access-layer/src/integrations/index.ts +++ b/services/libs/data-access-layer/src/integrations/index.ts @@ -47,7 +47,7 @@ export async function fetchGlobalIntegrations( WHERE i."status" = ANY ($(status)::text[]) AND i."deletedAt" IS NULL AND ($(platform) IS NULL OR i."platform" = $(platform)) - AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId)) + AND ($(segmentId) IS NULL OR s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) AND s.name ILIKE $(query) LIMIT $(limit) OFFSET $(offset) `, @@ -86,7 +86,7 @@ export async function fetchGlobalIntegrationsCount( WHERE i."status" = ANY ($(status)::text[]) AND i."deletedAt" IS NULL AND ($(platform) IS NULL OR i."platform" = $(platform)) - AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId)) + AND ($(segmentId) IS NULL OR s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) AND s.name ILIKE $(query) `, { @@ -140,7 +140,7 @@ export async function fetchGlobalNotConnectedIntegrations( AND s."parentId" IS NOT NULL AND s."grandparentId" IS NOT NULL AND ($(platform) IS NULL OR up."platform" = $(platform)) - AND ($(segmentId) IS NULL OR s.id = $(segmentId)) + AND ($(segmentId) IS NULL OR s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) AND s.name ILIKE $(query) LIMIT $(limit) OFFSET $(offset) `, @@ -185,7 +185,7 @@ export async function fetchGlobalNotConnectedIntegrationsCount( AND s."parentId" IS NOT NULL AND s."grandparentId" IS NOT NULL AND ($(platform) IS NULL OR up."platform" = $(platform)) - AND ($(segmentId) IS NULL OR s.id = $(segmentId)) + AND ($(segmentId) IS NULL OR s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) AND s.name ILIKE $(query) `, { @@ -215,7 +215,8 @@ export async function fetchGlobalIntegrationsStatusCount( FROM "integrations" i WHERE i."deletedAt" IS NULL AND ($(platform) IS NULL OR i."platform" = $(platform)) - AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId)) + AND ($(segmentId) IS NULL OR i."segmentId" = $(segmentId) OR + EXISTS (SELECT 1 FROM segments s WHERE s.id = i."segmentId" AND (s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)))) GROUP BY i.status `, { From 67eda89a988bc2dc96d3911d1c41f9c6f467e205 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 12:16:49 +0100 Subject: [PATCH 12/15] fix: count only subprojects --- services/libs/data-access-layer/src/dashboards/base.ts | 6 +++--- services/libs/data-access-layer/src/segments/index.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index dbb724c93b..223668548e 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -1,5 +1,5 @@ import { QueryExecutor } from '../queryExecutor' -import { getProjectsCount } from '../segments' +import { getSubProjectsCount } from '../segments' import { IDashboardMetrics } from './types' @@ -10,7 +10,7 @@ export async function getMetrics( try { const [snapshotData, projectsData] = await Promise.all([ getSnapshotMetrics(qx, segmentId), - getProjectsCount(qx, segmentId), + getSubProjectsCount(qx, segmentId), ]) if (!snapshotData) { @@ -38,7 +38,7 @@ export async function getMetrics( if (isMissingTable) { // TODO: remove this mock once Tinybird sinks are available const mockMetrics = getMockMetrics() - const projectsData = await getProjectsCount(qx, segmentId) + const projectsData = await getSubProjectsCount(qx, segmentId) return { ...mockMetrics, projectsTotal: projectsData.projectsTotal, diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index ee2bf592f8..4d905125ee 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -261,7 +261,7 @@ export async function getGitlabRepoUrlsMappedToOtherSegments( return rows.map((r) => r.url) } -export async function getProjectsCount( +export async function getSubProjectsCount( qx: QueryExecutor, segmentId?: string, ): Promise<{ projectsTotal: number; projectsLast30Days: number }> { @@ -269,22 +269,24 @@ export async function getProjectsCount( let params: Record if (!segmentId) { - // Count all segments not deleted + // Count only subprojects (segments with both parentSlug and grandparentSlug) query = ` SELECT COUNT(*) as "projectsTotal", COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments + WHERE "parentSlug" IS NOT NULL AND "grandparentSlug" IS NOT NULL ` params = {} } else { - // Count segments where the provided segmentId is current, parent, or grandparent + // Count only subprojects regardless of the filter being applied (project group or project) query = ` SELECT COUNT(*) as "projectsTotal", COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments s - WHERE (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) + WHERE s."parentSlug" IS NOT NULL AND s."grandparentSlug" IS NOT NULL + AND (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) ` params = { segmentId } } From b0951f3a100ec143d093d0ef1601209e8149cd42 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 12:34:03 +0100 Subject: [PATCH 13/15] fix: use type subproject in query --- services/libs/data-access-layer/src/segments/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index 4d905125ee..5dd7440447 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -275,7 +275,7 @@ export async function getSubProjectsCount( COUNT(*) as "projectsTotal", COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments - WHERE "parentSlug" IS NOT NULL AND "grandparentSlug" IS NOT NULL + WHERE type = "subproject" ` params = {} } else { @@ -285,7 +285,7 @@ export async function getSubProjectsCount( COUNT(*) as "projectsTotal", COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments s - WHERE s."parentSlug" IS NOT NULL AND s."grandparentSlug" IS NOT NULL + WHERE s.type = "subproject" AND (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) ` params = { segmentId } From dbd67c3fd602fd5a726d6fee6ced9494dcdd3f96 Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 12:59:22 +0100 Subject: [PATCH 14/15] fix: subproject query --- services/libs/data-access-layer/src/segments/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index 5dd7440447..dd20838ade 100644 --- a/services/libs/data-access-layer/src/segments/index.ts +++ b/services/libs/data-access-layer/src/segments/index.ts @@ -275,7 +275,7 @@ export async function getSubProjectsCount( COUNT(*) as "projectsTotal", COUNT(CASE WHEN "createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments - WHERE type = "subproject" + WHERE type = 'subproject' ` params = {} } else { @@ -285,7 +285,7 @@ export async function getSubProjectsCount( COUNT(*) as "projectsTotal", COUNT(CASE WHEN s."createdAt" >= NOW() - INTERVAL '30 days' THEN 1 END) as "projectsLast30Days" FROM segments s - WHERE s.type = "subproject" + WHERE type = 'subproject' AND (s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)) ` params = { segmentId } From 6730a32bfb757f026e99096a1fa54a2c3fcee67f Mon Sep 17 00:00:00 2001 From: Umberto Sgueglia Date: Fri, 12 Dec 2025 15:01:27 +0100 Subject: [PATCH 15/15] fix: remove mock --- .../data-access-layer/src/dashboards/base.ts | 65 ++++--------------- 1 file changed, 13 insertions(+), 52 deletions(-) diff --git a/services/libs/data-access-layer/src/dashboards/base.ts b/services/libs/data-access-layer/src/dashboards/base.ts index 223668548e..6e418541c2 100644 --- a/services/libs/data-access-layer/src/dashboards/base.ts +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -7,46 +7,20 @@ export async function getMetrics( qx: QueryExecutor, segmentId?: string, ): Promise { - try { - const [snapshotData, projectsData] = await Promise.all([ - getSnapshotMetrics(qx, segmentId), - getSubProjectsCount(qx, segmentId), - ]) + const [snapshotData, projectsData] = await Promise.all([ + getSnapshotMetrics(qx, segmentId), + getSubProjectsCount(qx, segmentId), + ]) - if (!snapshotData) { - // TODO: remove this mock once Tinybird sinks are available - const mockMetrics = getMockMetrics() - return { - ...mockMetrics, - projectsTotal: projectsData.projectsTotal, - projectsLast30Days: projectsData.projectsLast30Days, - } - } - - return { - ...snapshotData, - projectsTotal: projectsData.projectsTotal, - projectsLast30Days: projectsData.projectsLast30Days, - } - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : '' - const code = error && typeof error === 'object' && 'code' in error ? error.code : null - - // Detect missing table - const isMissingTable = code === '42P01' || /does not exist/i.test(msg) - - if (isMissingTable) { - // TODO: remove this mock once Tinybird sinks are available - const mockMetrics = getMockMetrics() - const projectsData = await getSubProjectsCount(qx, segmentId) - return { - ...mockMetrics, - projectsTotal: projectsData.projectsTotal, - projectsLast30Days: projectsData.projectsLast30Days, - } - } - - throw error + return { + activitiesLast30Days: snapshotData?.activitiesLast30Days || 0, + activitiesTotal: snapshotData?.activitiesTotal || 0, + membersLast30Days: snapshotData?.membersLast30Days || 0, + membersTotal: snapshotData?.membersTotal || 0, + organizationsLast30Days: snapshotData?.organizationsLast30Days || 0, + organizationsTotal: snapshotData?.organizationsTotal || 0, + projectsLast30Days: projectsData.projectsLast30Days, + projectsTotal: projectsData.projectsTotal, } } @@ -83,16 +57,3 @@ async function getSnapshotMetrics( return row || null } - -function getMockMetrics(): IDashboardMetrics { - return { - activitiesTotal: 9926553, - activitiesLast30Days: 64329, - organizationsTotal: 104300, - organizationsLast30Days: 36, - membersTotal: 798730, - membersLast30Days: 2694, - projectsTotal: 123, - projectsLast30Days: 12312, - } -}