Skip to content

Commit d832a60

Browse files
authored
chore: added missing endpoint, verifiedby and mv refresh job (CM-1030, CM-1038) (#3907)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 82c57ac commit d832a60

File tree

29 files changed

+346
-3
lines changed

29 files changed

+346
-3
lines changed

backend/src/api/public/v1/members/identities/getMemberIdentities.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ export async function getMemberIdentities(req: Request, res: Response): Promise<
2727
const rawIdentities = await fetchMemberIdentities(qx, memberId)
2828

2929
const identities = rawIdentities.map(
30-
({ id, value, platform, verified, source, createdAt, updatedAt }) => ({
30+
({ id, value, platform, verified, verifiedBy, source, createdAt, updatedAt }) => ({
3131
id,
3232
value,
3333
platform,
3434
verified,
35+
verifiedBy: verifiedBy ?? null,
3536
source,
3637
createdAt,
3738
updatedAt,

backend/src/api/public/v1/members/identities/verifyMemberIdentity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function toReturn(identity: IMemberIdentity) {
5454
value: identity.value,
5555
platform: identity.platform,
5656
verified: identity.verified,
57+
verifiedBy: identity.verifiedBy ?? null,
5758
source: identity.source,
5859
createdAt: identity.createdAt,
5960
updatedAt: identity.updatedAt,

backend/src/api/public/v1/members/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SCOPES } from '@/security/scopes'
77
import { getMemberIdentities } from './identities/getMemberIdentities'
88
import { verifyMemberIdentity } from './identities/verifyMemberIdentity'
99
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
10+
import { getProjectAffiliations } from './project-affiliations/getProjectAffiliations'
1011
import { resolveMemberByIdentities } from './resolveMember'
1112
import { createMemberWorkExperience } from './work-experiences/createMemberWorkExperience'
1213
import { deleteMemberWorkExperience } from './work-experiences/deleteMemberWorkExperience'
@@ -37,6 +38,12 @@ export function membersRouter(): Router {
3738
safeWrap(getMemberMaintainerRoles),
3839
)
3940

41+
router.get(
42+
'/:memberId/project-affiliations',
43+
requireScopes([SCOPES.READ_PROJECT_AFFILIATIONS]),
44+
safeWrap(getProjectAffiliations),
45+
)
46+
4047
router.post(
4148
'/:memberId/work-experiences',
4249
requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]),
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { Request, Response } from 'express'
2+
import { z } from 'zod'
3+
4+
import { NotFoundError } from '@crowd/common'
5+
import {
6+
MemberField,
7+
fetchMemberProjectSegments,
8+
fetchMemberSegmentAffiliationsWithOrg,
9+
fetchMemberWorkExperienceAffiliations,
10+
findMaintainerRoles,
11+
findMemberById,
12+
optionsQx,
13+
} from '@crowd/data-access-layer'
14+
import type {
15+
ISegmentAffiliationWithOrg,
16+
IWorkExperienceAffiliation,
17+
} from '@crowd/data-access-layer'
18+
19+
import { ok } from '@/utils/api'
20+
import { validateOrThrow } from '@/utils/validation'
21+
22+
const paramsSchema = z.object({
23+
memberId: z.uuid(),
24+
})
25+
26+
function mapSegmentAffiliation(a: ISegmentAffiliationWithOrg) {
27+
return {
28+
id: a.id,
29+
organizationId: a.organizationId,
30+
organizationName: a.organizationName,
31+
organizationLogo: a.organizationLogo ?? null,
32+
verified: a.verified,
33+
verifiedBy: a.verifiedBy ?? null,
34+
startDate: a.dateStart ?? null,
35+
endDate: a.dateEnd ?? null,
36+
}
37+
}
38+
39+
function mapWorkExperienceAffiliation(a: IWorkExperienceAffiliation) {
40+
return {
41+
id: a.id,
42+
organizationId: a.organizationId,
43+
organizationName: a.organizationName,
44+
organizationLogo: a.organizationLogo ?? null,
45+
verified: a.verified ?? false,
46+
verifiedBy: a.verifiedBy ?? null,
47+
source: a.source ?? null,
48+
startDate: a.dateStart ?? null,
49+
endDate: a.dateEnd ?? null,
50+
}
51+
}
52+
53+
export async function getProjectAffiliations(req: Request, res: Response): Promise<void> {
54+
const { memberId } = validateOrThrow(paramsSchema, req.params)
55+
const qx = optionsQx(req)
56+
57+
const member = await findMemberById(qx, memberId, [MemberField.ID])
58+
59+
if (!member) {
60+
throw new NotFoundError('Member not found')
61+
}
62+
63+
const [projectSegments, maintainerRoles, segmentAffiliations, workExperiences] =
64+
await Promise.all([
65+
fetchMemberProjectSegments(qx, memberId),
66+
findMaintainerRoles(qx, [memberId]),
67+
fetchMemberSegmentAffiliationsWithOrg(qx, memberId),
68+
fetchMemberWorkExperienceAffiliations(qx, memberId),
69+
])
70+
71+
// Group maintainer roles by segmentId
72+
const rolesBySegment = new Map<string, typeof maintainerRoles>()
73+
for (const role of maintainerRoles) {
74+
const existing = rolesBySegment.get(role.segmentId) ?? []
75+
existing.push(role)
76+
rolesBySegment.set(role.segmentId, existing)
77+
}
78+
79+
// Group segment affiliations by segmentId
80+
const affiliationsBySegment = new Map<string, typeof segmentAffiliations>()
81+
for (const aff of segmentAffiliations) {
82+
const existing = affiliationsBySegment.get(aff.segmentId) ?? []
83+
existing.push(aff)
84+
affiliationsBySegment.set(aff.segmentId, existing)
85+
}
86+
87+
const projectAffiliations = projectSegments.map((segment) => {
88+
const roles = (rolesBySegment.get(segment.id) ?? []).map((r) => ({
89+
id: r.id,
90+
role: r.role,
91+
startDate: r.dateStart ?? null,
92+
endDate: r.dateEnd ?? null,
93+
repoUrl: r.url ?? null,
94+
repoFileUrl: r.maintainerFile ?? null,
95+
}))
96+
97+
// Use segment affiliations if they exist for this project, otherwise fall back to work experiences
98+
const segmentAffs = affiliationsBySegment.get(segment.id)
99+
const affiliations = segmentAffs
100+
? segmentAffs.map(mapSegmentAffiliation)
101+
: workExperiences.map(mapWorkExperienceAffiliation)
102+
103+
return {
104+
id: segment.id,
105+
projectSlug: segment.slug,
106+
projectName: segment.name,
107+
projectLogo: segment.projectLogo ?? null,
108+
contributionCount: Number(segment.activityCount),
109+
roles,
110+
affiliations,
111+
}
112+
})
113+
114+
ok(res, { projectAffiliations })
115+
}

backend/src/database/migrations/U1772799041__add-missing-indexes-for-project-affiliations.sql

Whitespace-only changes.

backend/src/database/migrations/U1773139177__add-verified-to-member-segment-affiliations.sql

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add missing index on memberSegmentAffiliations for memberId lookups
2+
create index concurrently if not exists "ix_memberSegmentAffiliations_memberId"
3+
on "memberSegmentAffiliations" ("memberId");
4+
5+
-- Add missing index on mv_maintainer_roles materialized view for memberId lookups
6+
create index concurrently if not exists "ix_mv_maintainer_roles_memberId"
7+
on mv_maintainer_roles ("memberId");
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
alter table "memberSegmentAffiliations"
2+
add column if not exists "verified" boolean not null default false,
3+
add column if not exists "verifiedBy" varchar(255) default null;

backend/src/utils/mapper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function toMemberWorkExperience(mo: IMemberRoleWithOrganization) {
88
organizationLogo: mo.organizationLogo,
99
jobTitle: mo.title ?? null,
1010
verified: mo.verified ?? false,
11+
verifiedBy: mo.verifiedBy ?? null,
1112
source: mo.source ?? null,
1213
startDate: mo.dateStart ?? null,
1314
endDate: mo.dateEnd ?? null,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import CronTime from 'cron-time-generator'
2+
3+
import { WRITE_DB_CONFIG, getDbConnection } from '@crowd/data-access-layer/src/database'
4+
5+
import { IJobDefinition } from '../types'
6+
7+
const MATERIALIZED_VIEWS = ['mv_maintainer_roles']
8+
9+
const job: IJobDefinition = {
10+
name: 'refresh-mvs',
11+
cronTime: CronTime.every(30).minutes(),
12+
timeout: 10 * 60, // 10 minutes
13+
process: async (ctx) => {
14+
ctx.log.info('Starting materialized view refresh job!')
15+
const dbConnection = await getDbConnection(WRITE_DB_CONFIG(), 1, 0)
16+
17+
for (const mv of MATERIALIZED_VIEWS) {
18+
ctx.log.info({ mv }, `Refreshing materialized view: ${mv}`)
19+
const start = performance.now()
20+
await dbConnection.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY "${mv}"`)
21+
const duration = ((performance.now() - start) / 1000.0).toFixed(2)
22+
ctx.log.info({ mv, duration }, `Refreshed materialized view ${mv} in ${duration}s`)
23+
}
24+
25+
ctx.log.info('Materialized view refresh job completed!')
26+
},
27+
}
28+
29+
export default job

0 commit comments

Comments
 (0)