Skip to content
12 changes: 12 additions & 0 deletions backend/src/api/dashboard/dashboardMetricsGet.ts
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions backend/src/api/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
58 changes: 48 additions & 10 deletions backend/src/database/repositories/integrationRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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 }
}

Expand All @@ -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<Array<Object>>} 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 }]
}

Expand Down
22 changes: 22 additions & 0 deletions backend/src/services/dashboardService.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,6 +12,10 @@ interface IDashboardQueryParams {
timeframe: DashboardTimeframe
}

interface IDashboardMetricsQueryParams {
segment?: string
}

export default class DashboardService {
options: IServiceOptions

Expand Down Expand Up @@ -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')
}
}
}
59 changes: 59 additions & 0 deletions services/libs/data-access-layer/src/dashboards/base.ts
Original file line number Diff line number Diff line change
@@ -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<IDashboardMetrics> {
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<Omit<IDashboardMetrics, 'projectsTotal' | 'projectsLast30Days'> | 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
}
2 changes: 2 additions & 0 deletions services/libs/data-access-layer/src/dashboards/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './base'
export * from './types'
10 changes: 10 additions & 0 deletions services/libs/data-access-layer/src/dashboards/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface IDashboardMetrics {
activitiesTotal: number
activitiesLast30Days: number
organizationsTotal: number
organizationsLast30Days: number
membersTotal: number
membersLast30Days: number
projectsTotal: number
projectsLast30Days: number
}
1 change: 1 addition & 0 deletions services/libs/data-access-layer/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './activities'
export * from './activityRelations'
export * from './dashboards'
export * from './members'
export * from './organizations'
export * from './prompt-history'
Expand Down
16 changes: 16 additions & 0 deletions services/libs/data-access-layer/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export async function fetchGlobalIntegrations(
query: string,
limit: number,
offset: number,
segmentId?: string | null,
): Promise<IIntegration[]> {
return qx.select(
`
Expand All @@ -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,
Expand All @@ -73,6 +76,7 @@ export async function fetchGlobalIntegrationsCount(
status: string[],
platform: string | null,
query: string,
segmentId?: string | null,
): Promise<{ count: number }[]> {
return qx.select(
`
Expand All @@ -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}%`,
},
)
Expand All @@ -109,6 +115,7 @@ export async function fetchGlobalNotConnectedIntegrations(
query: string,
limit: number,
offset: number,
segmentId?: string | null,
): Promise<IIntegration[]> {
return qx.select(
`
Expand All @@ -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,
Expand All @@ -157,6 +166,7 @@ export async function fetchGlobalNotConnectedIntegrationsCount(
qx: QueryExecutor,
platform: string | null,
query: string,
segmentId?: string | null,
): Promise<{ count: number }[]> {
return qx.select(
`
Expand All @@ -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}%`,
},
)
Expand All @@ -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(
`
Expand All @@ -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,
},
)
}
Expand Down
37 changes: 37 additions & 0 deletions services/libs/data-access-layer/src/segments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>

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,
}
}
Loading