Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ export async function getMemberIdentities(req: Request, res: Response): Promise<
const rawIdentities = await fetchMemberIdentities(qx, memberId)

const identities = rawIdentities.map(
({ id, value, platform, verified, source, createdAt, updatedAt }) => ({
({ id, value, platform, verified, verifiedBy, source, createdAt, updatedAt }) => ({
id,
value,
platform,
verified,
verifiedBy: verifiedBy ?? null,
source,
createdAt,
updatedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function toReturn(identity: IMemberIdentity) {
value: identity.value,
platform: identity.platform,
verified: identity.verified,
verifiedBy: identity.verifiedBy ?? null,
source: identity.source,
createdAt: identity.createdAt,
updatedAt: identity.updatedAt,
Expand Down
7 changes: 7 additions & 0 deletions backend/src/api/public/v1/members/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SCOPES } from '@/security/scopes'
import { getMemberIdentities } from './identities/getMemberIdentities'
import { verifyMemberIdentity } from './identities/verifyMemberIdentity'
import { getMemberMaintainerRoles } from './maintainer-roles/getMemberMaintainerRoles'
import { getProjectAffiliations } from './project-affiliations/getProjectAffiliations'
import { resolveMemberByIdentities } from './resolveMember'
import { createMemberWorkExperience } from './work-experiences/createMemberWorkExperience'
import { deleteMemberWorkExperience } from './work-experiences/deleteMemberWorkExperience'
Expand Down Expand Up @@ -37,6 +38,12 @@ export function membersRouter(): Router {
safeWrap(getMemberMaintainerRoles),
)

router.get(
'/:memberId/project-affiliations',
requireScopes([SCOPES.READ_PROJECT_AFFILIATIONS]),
safeWrap(getProjectAffiliations),
)

router.post(
'/:memberId/work-experiences',
requireScopes([SCOPES.WRITE_WORK_EXPERIENCES]),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'
import {
MemberField,
fetchMemberProjectSegments,
fetchMemberSegmentAffiliationsWithOrg,
fetchMemberWorkExperienceAffiliations,
findMaintainerRoles,
findMemberById,
optionsQx,
} from '@crowd/data-access-layer'
import type {
ISegmentAffiliationWithOrg,
IWorkExperienceAffiliation,
} from '@crowd/data-access-layer'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

const paramsSchema = z.object({
memberId: z.uuid(),
})

function mapSegmentAffiliation(a: ISegmentAffiliationWithOrg) {
return {
id: a.id,
organizationId: a.organizationId,
organizationName: a.organizationName,
organizationLogo: a.organizationLogo ?? null,
verified: a.verified,
verifiedBy: a.verifiedBy ?? null,
startDate: a.dateStart ?? null,
endDate: a.dateEnd ?? null,
}
}

function mapWorkExperienceAffiliation(a: IWorkExperienceAffiliation) {
return {
id: a.id,
organizationId: a.organizationId,
organizationName: a.organizationName,
organizationLogo: a.organizationLogo ?? null,
verified: a.verified ?? false,
verifiedBy: a.verifiedBy ?? null,
source: a.source ?? null,
startDate: a.dateStart ?? null,
endDate: a.dateEnd ?? null,
}
}

export async function getProjectAffiliations(req: Request, res: Response): Promise<void> {
const { memberId } = validateOrThrow(paramsSchema, req.params)
const qx = optionsQx(req)

const member = await findMemberById(qx, memberId, [MemberField.ID])

if (!member) {
throw new NotFoundError('Member not found')
}

const [projectSegments, maintainerRoles, segmentAffiliations, workExperiences] =
await Promise.all([
fetchMemberProjectSegments(qx, memberId),
findMaintainerRoles(qx, [memberId]),
fetchMemberSegmentAffiliationsWithOrg(qx, memberId),
fetchMemberWorkExperienceAffiliations(qx, memberId),
])

// Group maintainer roles by segmentId
const rolesBySegment = new Map<string, typeof maintainerRoles>()
for (const role of maintainerRoles) {
const existing = rolesBySegment.get(role.segmentId) ?? []
existing.push(role)
rolesBySegment.set(role.segmentId, existing)
}

// Group segment affiliations by segmentId
const affiliationsBySegment = new Map<string, typeof segmentAffiliations>()
for (const aff of segmentAffiliations) {
const existing = affiliationsBySegment.get(aff.segmentId) ?? []
existing.push(aff)
affiliationsBySegment.set(aff.segmentId, existing)
}

const projectAffiliations = projectSegments.map((segment) => {
const roles = (rolesBySegment.get(segment.id) ?? []).map((r) => ({
id: r.id,
role: r.role,
startDate: r.dateStart ?? null,
endDate: r.dateEnd ?? null,
repoUrl: r.url ?? null,
repoFileUrl: r.maintainerFile ?? null,
}))

// Use segment affiliations if they exist for this project, otherwise fall back to work experiences
const segmentAffs = affiliationsBySegment.get(segment.id)
const affiliations = segmentAffs
? segmentAffs.map(mapSegmentAffiliation)
: workExperiences.map(mapWorkExperienceAffiliation)

return {
id: segment.id,
projectSlug: segment.slug,
projectName: segment.name,
projectLogo: segment.projectLogo ?? null,
contributionCount: Number(segment.activityCount),
roles,
affiliations,
}
})

ok(res, { projectAffiliations })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add missing index on memberSegmentAffiliations for memberId lookups
create index concurrently if not exists "ix_memberSegmentAffiliations_memberId"
on "memberSegmentAffiliations" ("memberId");

-- Add missing index on mv_maintainer_roles materialized view for memberId lookups
create index concurrently if not exists "ix_mv_maintainer_roles_memberId"
on mv_maintainer_roles ("memberId");
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
alter table "memberSegmentAffiliations"
add column if not exists "verified" boolean not null default false,
add column if not exists "verifiedBy" varchar(255) default null;
1 change: 1 addition & 0 deletions backend/src/utils/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function toMemberWorkExperience(mo: IMemberRoleWithOrganization) {
organizationLogo: mo.organizationLogo,
jobTitle: mo.title ?? null,
verified: mo.verified ?? false,
verifiedBy: mo.verifiedBy ?? null,
source: mo.source ?? null,
startDate: mo.dateStart ?? null,
endDate: mo.dateEnd ?? null,
Expand Down
29 changes: 29 additions & 0 deletions services/apps/cron_service/src/jobs/refreshMvs.job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CronTime from 'cron-time-generator'

import { WRITE_DB_CONFIG, getDbConnection } from '@crowd/data-access-layer/src/database'

import { IJobDefinition } from '../types'

const MATERIALIZED_VIEWS = ['mv_maintainer_roles']

const job: IJobDefinition = {
name: 'refresh-mvs',
cronTime: CronTime.every(30).minutes(),
timeout: 10 * 60, // 10 minutes
process: async (ctx) => {
ctx.log.info('Starting materialized view refresh job!')
const dbConnection = await getDbConnection(WRITE_DB_CONFIG(), 1, 0)

for (const mv of MATERIALIZED_VIEWS) {
ctx.log.info({ mv }, `Refreshing materialized view: ${mv}`)
const start = performance.now()
await dbConnection.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY "${mv}"`)
const duration = ((performance.now() - start) / 1000.0).toFixed(2)
ctx.log.info({ mv, duration }, `Refreshed materialized view ${mv} in ${duration}s`)
}

ctx.log.info('Materialized view refresh job completed!')
},
}

export default job
12 changes: 12 additions & 0 deletions services/apps/cron_service/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path'
import pidusage from 'pidusage'

import { getChildLogger, getServiceChildLogger, getServiceLogger } from '@crowd/logging'
import { SlackChannel, SlackPersona, sendSlackNotificationAsync } from '@crowd/slack'

import { loadJobs } from './loader'
import { IJobDefinition } from './types'
Expand Down Expand Up @@ -145,6 +146,17 @@ const queueJob = async (job: IJobDefinition) => {
} catch (err) {
const diff = ((performance.now() - start) / 1000.0).toFixed(2)
jobLogger.error(err, `Error while running a job! Job exited after ${diff} seconds!`)

if (err instanceof Error && err.message.includes('did not finish in time')) {
sendSlackNotificationAsync(
SlackChannel.ALERTS,
SlackPersona.CRITICAL_ALERTER,
`Cron job timed out: ${job.name}`,
`Job \`${job.name}\` was killed after exceeding its ${job.timeout}s timeout (ran for ${diff}s).`,
).catch((slackErr) =>
jobLogger.error(slackErr, 'Failed to send Slack timeout notification'),
)
}
} finally {
activeJobs.delete(job.name)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function enrichMemberWithLFAuth0(member: IMember): Promise<void> {
platform: PlatformType.LFID,
value: enriched.email.toLowerCase(),
verified: true,
verifiedBy: 'lf-auth0',
})
}

Expand All @@ -78,6 +79,7 @@ export async function enrichMemberWithLFAuth0(member: IMember): Promise<void> {
type: MemberIdentityType.USERNAME,
value: enriched.username.toLowerCase(),
verified: true,
verifiedBy: 'lf-auth0',
})
}

Expand All @@ -100,6 +102,7 @@ export async function enrichMemberWithLFAuth0(member: IMember): Promise<void> {
platform: PlatformType.GITHUB,
value: enrichmentGithub.profileData.nickname.toLowerCase(),
verified: true,
verifiedBy: 'lf-auth0',
})
}

Expand Down Expand Up @@ -127,6 +130,7 @@ export async function enrichMemberWithLFAuth0(member: IMember): Promise<void> {
type: MemberIdentityType.EMAIL,
value: githubEmail.email.toLowerCase(),
verified: true,
verifiedBy: 'lf-auth0',
})
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ export class CventTransformer extends TransformerBase {
value: email,
type: MemberIdentityType.EMAIL,
verified: true,
verifiedBy: PlatformType.CVENT,
sourceId,
},
{
platform: PlatformType.CVENT,
value: userName,
type: MemberIdentityType.USERNAME,
verified: true,
verifiedBy: PlatformType.CVENT,
sourceId,
},
)
Expand All @@ -66,6 +68,7 @@ export class CventTransformer extends TransformerBase {
value: email,
type: MemberIdentityType.USERNAME,
verified: true,
verifiedBy: PlatformType.CVENT,
sourceId,
})
}
Expand All @@ -76,6 +79,7 @@ export class CventTransformer extends TransformerBase {
value: lfUsername,
type: MemberIdentityType.USERNAME,
verified: true,
verifiedBy: PlatformType.CVENT,
sourceId,
})
}
Expand Down
10 changes: 8 additions & 2 deletions services/libs/data-access-layer/src/maintainers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Maintainer {
url: string
repoType: MaintainerRepoType
role: string
maintainerFile: string | null
}

export async function findMaintainerRoles(
Expand All @@ -24,8 +25,13 @@ export async function findMaintainerRoles(
): Promise<Maintainer[]> {
return qx.select(
`
SELECT * FROM mv_maintainer_roles
WHERE "memberId" IN ($(memberIds:csv))
SELECT
mmr.*,
rp."maintainerFile"
FROM mv_maintainer_roles mmr
LEFT JOIN public.repositories r ON r.url = mmr.url
LEFT JOIN git."repositoryProcessing" rp ON rp."repositoryId" = r.id
WHERE mmr."memberId" IN ($(memberIds:csv))
`,
{
memberIds,
Expand Down
1 change: 1 addition & 0 deletions services/libs/data-access-layer/src/members/identities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export async function insertManyMemberIdentities(
'value',
'type',
'verified',
'verifiedBy',
],
identities.map((i) => {
return {
Expand Down
1 change: 1 addition & 0 deletions services/libs/data-access-layer/src/members/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export * from './attributes'
export * from './dashboard'
export * from './contributions'
export * from './bot'
export * from './projectAffiliations'
export { MemberQueryCache } from './queryCache'
Loading
Loading