Skip to content

Commit 574713f

Browse files
authored
feat: resolveproject group in the segments middleware (CM-189) (#4011)
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
1 parent ca7032a commit 574713f

14 files changed

Lines changed: 215 additions & 150 deletions

File tree

backend/src/database/repositories/organizationRepository.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,7 +1599,6 @@ class OrganizationRepository {
15991599

16001600
const result = { rows, count, limit, offset }
16011601

1602-
// Cache the result
16031602
await cache.set(cacheKey, result, 21600) // 6 hours TTL
16041603

16051604
return result
@@ -1661,7 +1660,6 @@ class OrganizationRepository {
16611660
options: IRepositoryOptions,
16621661
): Promise<void> {
16631662
try {
1664-
options.log.info(`Refreshing organizations advanced query cache in background: ${cacheKey}`)
16651663
await this.executeQuery(cache, cacheKey, params, options)
16661664
} catch (error) {
16671665
options.log.warn('Background cache refresh failed:', error)
Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,123 @@
1+
import { NextFunction, Request, Response } from 'express'
2+
3+
import {
4+
buildSegmentActivityTypes,
5+
isSegmentSubproject,
6+
} from '@crowd/data-access-layer/src/segments'
7+
import { getServiceChildLogger } from '@crowd/logging'
8+
9+
import { IRepositoryOptions } from '../database/repositories/IRepositoryOptions'
110
import SegmentRepository from '../database/repositories/segmentRepository'
211

3-
export async function segmentMiddleware(req, res, next) {
12+
const log = getServiceChildLogger('segmentMiddleware')
13+
14+
export async function segmentMiddleware(req: Request, _res: Response, next: NextFunction) {
415
try {
516
let segments: any = null
6-
const segmentRepository = new SegmentRepository(req)
7-
8-
if (req.params.segmentId) {
9-
// for param requests, segments will be in the url
10-
segments = { rows: await segmentRepository.findInIds([req.params.segmentId]) }
11-
} else if (req.query.segments) {
12-
// for get requests, segments will be in query
13-
segments = { rows: await segmentRepository.findInIds(req.query.segments) }
14-
} else if (req.body.segments) {
15-
// for post and put requests, segments will be in body
16-
segments = { rows: await segmentRepository.findInIds(req.body.segments) }
17+
const segmentRepository = new SegmentRepository(req as unknown as IRepositoryOptions)
18+
19+
// Note: req.params is NOT available here. This middleware is registered via app.use(),
20+
// which runs before Express matches a specific route and populates req.params.
21+
// Any check on req.params (e.g. req.params.segmentId) would always be undefined.
22+
// Route handlers that need a specific segment by ID (e.g. GET /segment/:segmentId)
23+
// read req.params directly and ignore req.currentSegments entirely — so the
24+
// resolution below is harmless for those endpoints.
25+
const querySegments = toStringArray(req.query.segments)
26+
const bodySegments = toStringArray((req.body as Record<string, unknown>)?.segments)
27+
28+
if (querySegments.length > 0) {
29+
segments = {
30+
rows: await resolveToLeafSegments(segmentRepository, querySegments, req),
31+
}
32+
} else if (bodySegments.length > 0) {
33+
const resolvedRows = await resolveToLeafSegments(segmentRepository, bodySegments, req)
34+
segments = { rows: resolvedRows }
1735
} else {
1836
segments = await segmentRepository.querySubprojects({ limit: 1, offset: 0 })
1937
}
2038

21-
req.currentSegments = segments.rows
39+
const options = req as unknown as IRepositoryOptions
40+
options.currentSegments = segments.rows
2241

2342
next()
2443
} catch (error) {
2544
next(error)
2645
}
2746
}
47+
48+
/**
49+
* Safely extracts a string[] from an unknown query/body value.
50+
* Rejects ParsedQs objects (e.g. ?segments[key]=val) that would cause type confusion.
51+
*/
52+
function toStringArray(value: unknown): string[] {
53+
if (value === undefined || value === null) return []
54+
const items = Array.isArray(value) ? value : [value]
55+
return items.filter((v): v is string => typeof v === 'string')
56+
}
57+
58+
/**
59+
* Resolves segment IDs to their leaf sub-projects.
60+
*
61+
* If all provided IDs are already sub-projects (leaf level), returns them as-is
62+
* without any extra DB call — fully backward-compatible with the current behavior.
63+
*
64+
* If any ID is a project or project group (non-leaf), expands it to all its
65+
* active sub-projects and applies populateSegmentRelations to match the shape
66+
* that downstream services expect from req.currentSegments.
67+
*/
68+
async function resolveToLeafSegments(
69+
segmentRepository: SegmentRepository,
70+
segmentIds: string[],
71+
req: Request,
72+
) {
73+
const fetched = await segmentRepository.findInIds(segmentIds)
74+
75+
const nonLeaf = fetched.filter((s) => !isSegmentSubproject(s))
76+
77+
const segmentLevel = (s: any) => {
78+
if (s.grandparentSlug) return 'subproject'
79+
if (s.parentSlug) return 'project'
80+
return 'projectGroup'
81+
}
82+
83+
const nullActivityTypes = (record: any) => ({ ...record, activityTypes: null })
84+
85+
if (nonLeaf.length === 0) {
86+
// All inputs are already leaf subprojects. findInIds() already called populateSegmentRelations
87+
// on each record, which includes a cloneDeep(DEFAULT_ACTIVITY_TYPE_SETTINGS) per segment.
88+
// Keep activityTypes on the first record only; null the rest to release those clones.
89+
// getSegmentActivityTypes merges with lodash.merge which skips null values, so the first
90+
// record's activityTypes (default + its custom types) is sufficient for display purposes.
91+
const [first, ...rest] = fetched
92+
log.debug(
93+
{
94+
api: `${req.method} ${req.path}`,
95+
usedInDbQueries: fetched.map((s) => ({ id: s.id, name: s.name, level: segmentLevel(s) })),
96+
},
97+
`All segments are already leaf — used as-is in DB queries`,
98+
)
99+
return first ? [first, ...rest.map(nullActivityTypes)] : []
100+
}
101+
102+
const leafRecords = await segmentRepository.getSegmentSubprojects(segmentIds)
103+
104+
log.debug(
105+
{
106+
api: `${req.method} ${req.path}`,
107+
input_segments: nonLeaf.map((s) => ({ id: s.id, name: s.name, level: segmentLevel(s) })),
108+
resolved_count: leafRecords.length,
109+
},
110+
'Non-leaf segments resolved to leaf sub-projects',
111+
)
112+
113+
if (leafRecords.length === 0) return []
114+
115+
// getSegmentSubprojects returns raw DB rows (no populateSegmentRelations/cloneDeep).
116+
// Build activityTypes from the first leaf only (one cloneDeep of DEFAULT_ACTIVITY_TYPE_SETTINGS).
117+
// null the rest — getSegmentActivityTypes merges all and lodash.merge skips null sources.
118+
const [first, ...rest] = leafRecords
119+
return [
120+
{ ...first, activityTypes: buildSegmentActivityTypes(first) },
121+
...rest.map(nullActivityTypes),
122+
]
123+
}

frontend/src/modules/activity/components/activity-timeline.vue

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,8 @@ const query = ref('');
267267
const activities = ref([]);
268268
const limit = ref(10);
269269
const offset = ref(0);
270-
const timestamp = ref(dateHelper(props.entity.joinedAt).toISOString());
270+
const joinedAt = dateHelper(props.entity.joinedAt);
271+
const timestamp = ref(joinedAt.isValid() ? joinedAt.toISOString() : new Date(0).toISOString());
271272
const noMore = ref(false);
272273
const selectedSegment = ref(props.selectedSegment || null);
273274
@@ -373,14 +374,21 @@ const fetchActivities = async ({ reset } = { reset: false }) => {
373374
374375
loading.value = true;
375376
377+
let querySegments: string[];
378+
if (selectedSegment.value) {
379+
querySegments = [selectedSegment.value];
380+
} else if (segments.value.length > 0) {
381+
querySegments = segments.value.map((s) => s.id);
382+
} else {
383+
querySegments = [];
384+
}
385+
376386
const data = await ActivityService.query({
377387
filter: filterToApply,
378388
orderBy: 'timestamp_DESC',
379389
limit: limit.value,
380390
offset: offset.value,
381-
segments: selectedSegment.value
382-
? [selectedSegment.value]
383-
: segments.value.map((s) => s.id),
391+
segments: querySegments,
384392
});
385393
386394
loading.value = false;
@@ -419,7 +427,7 @@ watch(platform, async (newValue, oldValue) => {
419427
onMounted(async () => {
420428
await store.dispatch(
421429
'integration/doFetch',
422-
segments.value.map((s: any) => s.id),
430+
selectedProjectGroup.value?.id ? [selectedProjectGroup.value.id] : [],
423431
);
424432
await fetchActivities();
425433
});

frontend/src/modules/activity/config/filters/activityType/ActivityTypeFilter.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
} from '@/shared/modules/filters/types/filterTypes/MultiSelectFilterConfig';
1515
import { CustomFilterConfig } from '@/shared/modules/filters/types/filterTypes/CustomFilterConfig';
1616
import { useActivityTypeStore } from '@/modules/activity/store/type';
17-
import { getSegmentsFromProjectGroup } from '@/utils/segments';
1817
import { useLfSegmentsStore } from '@/modules/lf/segments/store';
1918
import { lfIdentities } from '@/config/identities';
2019
import useIntegrationsHelpers from '@/config/integrations/integrations.helpers';
@@ -77,6 +76,6 @@ watch([types, activeIntegrations], ([typesValue, activeIntegrationsValue]) => {
7776
});
7877
7978
onMounted(async () => {
80-
await store.dispatch('integration/doFetch', getSegmentsFromProjectGroup(selectedProjectGroup.value));
79+
await store.dispatch('integration/doFetch', selectedProjectGroup.value?.id ? [selectedProjectGroup.value.id] : []);
8180
});
8281
</script>

frontend/src/modules/lf/layout/components/lf-banners.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,6 @@ import {
150150
watch, ref, computed, onUnmounted,
151151
} from 'vue';
152152
import { IntegrationService } from '@/modules/integration/integration-service';
153-
import { getSegmentsFromProjectGroup } from '@/utils/segments';
154153
import { isCurrentDateAfterGivenWorkingDays } from '@/utils/date';
155154
import { useRoute } from 'vue-router';
156155
import usePermissions from '@/shared/modules/permissions/helpers/usePermissions';
@@ -227,7 +226,7 @@ const showBanner = computed(() => (integrationsWithErrors.value.length
227226
228227
const fetchIntegrations = (projectGroup) => {
229228
if (projectGroup) {
230-
IntegrationService.list(null, null, null, null, getSegmentsFromProjectGroup(projectGroup))
229+
IntegrationService.list(null, null, null, null, [projectGroup.id])
231230
.then((response) => {
232231
integrations.value = response.rows;
233232
})

frontend/src/modules/member/components/list/member-list-table.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,6 @@ import LfTable from '@/ui-kit/table/Table.vue';
424424
import LfTableCell from '@/ui-kit/table/TableCell.vue';
425425
import LfTableHead from '@/ui-kit/table/TableHead.vue';
426426
import { formatNumber } from '@/utils/number';
427-
import { getSegmentsFromProjectGroup } from '@/utils/segments';
428427
import { ClickOutside as vClickOutside } from 'element-plus';
429428
import { storeToRefs } from 'pinia';
430429
import {
@@ -679,7 +678,7 @@ const doExport = () => MemberService.export({
679678
onMounted(async () => {
680679
await store.dispatch(
681680
'integration/doFetch',
682-
getSegmentsFromProjectGroup(selectedProjectGroup.value),
681+
selectedProjectGroup.value?.id ? [selectedProjectGroup.value.id] : [],
683682
);
684683
});
685684

frontend/src/modules/member/config/filters/activityType/ActivityTypeFilter.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
} from '@/shared/modules/filters/types/filterTypes/MultiSelectFilterConfig';
1515
import { CustomFilterConfig } from '@/shared/modules/filters/types/filterTypes/CustomFilterConfig';
1616
import { useActivityTypeStore } from '@/modules/activity/store/type';
17-
import { getSegmentsFromProjectGroup } from '@/utils/segments';
1817
import { useLfSegmentsStore } from '@/modules/lf/segments/store';
1918
import useIntegrationsHelpers from '@/config/integrations/integrations.helpers';
2019
import { lfIntegrations } from '@/config/integrations';
@@ -77,6 +76,6 @@ watch([types, activeIntegrations], ([typesValue, activeIntegrationsValue]) => {
7776
});
7877
7978
onMounted(async () => {
80-
await store.dispatch('integration/doFetch', getSegmentsFromProjectGroup(selectedProjectGroup.value));
79+
await store.dispatch('integration/doFetch', selectedProjectGroup.value?.id ? [selectedProjectGroup.value.id] : []);
8180
});
8281
</script>

0 commit comments

Comments
 (0)