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/database/repositories/integrationRepository.ts b/backend/src/database/repositories/integrationRepository.ts index 83a9059a50..7543c70244 100644 --- a/backend/src/database/repositories/integrationRepository.ts +++ b/backend/src/database/repositories/integrationRepository.ts @@ -315,26 +315,60 @@ 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. + * @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, + }: { + 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')) { - const rows = await fetchGlobalNotConnectedIntegrations(qx, platform, query, limit, offset) - const [result] = await fetchGlobalNotConnectedIntegrationsCount(qx, platform, query) + + // 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, + 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, + statusArray, + platform, + query, + limit, + offset, + segment, + ) + const [result] = await fetchGlobalIntegrationsCount(qx, statusArray, platform, query, segment) return { rows, count: +result.count, limit: +limit, offset: +offset } } @@ -344,13 +378,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/backend/src/services/dashboardService.ts b/backend/src/services/dashboardService.ts index 8569634fb3..42590e26cd 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,19 @@ export default class DashboardService { return JSON.parse(data) } + + async getMetrics(params: IDashboardMetricsQueryParams) { + try { + 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, params.segment) + 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..6e418541c2 --- /dev/null +++ b/services/libs/data-access-layer/src/dashboards/base.ts @@ -0,0 +1,59 @@ +import { QueryExecutor } from '../queryExecutor' +import { getSubProjectsCount } from '../segments' + +import { IDashboardMetrics } from './types' + +export async function getMetrics( + qx: QueryExecutor, + segmentId?: string, +): Promise { + const [snapshotData, projectsData] = await Promise.all([ + getSnapshotMetrics(qx, segmentId), + getSubProjectsCount(qx, segmentId), + ]) + + 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, + } +} + +async function getSnapshotMetrics( + qx: QueryExecutor, + segmentId?: string, +): Promise | null> { + const tableName = segmentId + ? 'dashboardMetricsPerSegmentSnapshot' + : 'dashboardMetricsTotalSnapshot' + + const query = segmentId + ? ` + SELECT * + FROM "${tableName}" + WHERE "segmentId" = $(segmentId) + LIMIT 1 + ` + : ` + SELECT + "activitiesLast30Days", + "activitiesTotal", + "membersLast30Days", + "membersTotal", + "organizationsLast30Days", + "organizationsTotal", + "updatedAt" + FROM "${tableName}" + LIMIT 1 + ` + + const params = segmentId ? { segmentId } : {} + const [row] = await qx.select(query, params) + + return row || null +} 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' diff --git a/services/libs/data-access-layer/src/integrations/index.ts b/services/libs/data-access-layer/src/integrations/index.ts index 1a6b639d99..bf554b185f 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 s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(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 s.id = $(segmentId) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(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) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(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) OR s."parentId" = $(segmentId) OR s."grandparentId" = $(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,13 @@ 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) OR + EXISTS (SELECT 1 FROM segments s WHERE s.id = i."segmentId" AND (s."parentId" = $(segmentId) OR s."grandparentId" = $(segmentId)))) GROUP BY i.status `, { platform, + segmentId, }, ) } diff --git a/services/libs/data-access-layer/src/segments/index.ts b/services/libs/data-access-layer/src/segments/index.ts index f0eed84a21..dd20838ade 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 getSubProjectsCount( + qx: QueryExecutor, + segmentId?: string, +): Promise<{ projectsTotal: number; projectsLast30Days: number }> { + let query: string + let params: Record + + if (!segmentId) { + // 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 type = 'subproject' + ` + params = {} + } else { + // 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 type = 'subproject' + 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, + } +}