Skip to content
Closed
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 @@ -3,7 +3,7 @@ import { SCHEMA_ENUMS } from '@repo/codegen/src/schema-enums.generated'
import { enumToOptions } from '@/components/shared/enum-mapper/common-enum'
import type { MergeEnumOption, MergeFieldConfig, MergeFieldOverride, MergeFieldOverrides, MergeFieldType } from './types'

const humanizeKey = (input: string): string => {
export const humanizeKey = (input: string): string => {
if (!input) return ''
const spaced = input
.replace(/[_-]+/g, ' ')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
'use client'

import { useMemo } from 'react'
import { useIdentityHolder, useUpdateIdentityHolder, useDeleteIdentityHolder, useIdentityHoldersWithFilter, useGetIdentityHolderEdgesForMerge } from '@/lib/graphql-hooks/identity-holder'
import { useIdentityHolder, useUpdateIdentityHolder, useDeleteIdentityHolder, useIdentityHoldersWithFilter } from '@/lib/graphql-hooks/identity-holder'
import { type IdentityHolderQuery, type UpdateIdentityHolderInput } from '@repo/codegen/src/schema'
import { IDENTITY_HOLDER_ASSOCIATION_CONFIG } from '@/components/shared/object-association/association-configs'
import type { MergeConfig, MergeEdgeTransferCount, MergeFieldOverrides, MergePreSaveExtrasResult } from '../types'

type Personnel = NonNullable<IdentityHolderQuery['identityHolder']>
Expand Down Expand Up @@ -80,80 +79,21 @@ const useSearchPersonnel = (search: string, excludeId: string) => {
return { options, isLoading }
}

type EdgeConnection = { edges?: Array<{ node?: { id: string } | null } | null> | null }
const personnelPreSaveExtras = ({ primary, secondary }: { primary: Personnel; secondary: Personnel }): MergePreSaveExtrasResult<UpdateIdentityHolderInput> => {
const data: Partial<UpdateIdentityHolderInput> = {}
const counts: MergeEdgeTransferCount[] = []

type EdgeMergeData = NonNullable<NonNullable<ReturnType<typeof useGetIdentityHolderEdgesForMerge>['data']>['identityHolder']>

type EdgeTransferSpec = {
sourceKey: keyof EdgeMergeData
addKey: keyof UpdateIdentityHolderInput
label: string
}

const ASSOCIATION_LABELS: Record<string, string> = {
assets: 'Assets',
entities: 'Entities',
campaigns: 'Campaigns',
tasks: 'Tasks',
controls: 'Controls',
internalPolicies: 'Internal policies',
subcontrols: 'Subcontrols',
}

const buildAssociationEdgeSpecs = (initialDataKeys: Record<string, string>): EdgeTransferSpec[] =>
Object.entries(initialDataKeys).map(([idKey, sourceKey]) => ({
sourceKey: sourceKey as EdgeTransferSpec['sourceKey'],
addKey: `add${idKey.charAt(0).toUpperCase()}${idKey.slice(1)}` as EdgeTransferSpec['addKey'],
label: ASSOCIATION_LABELS[sourceKey] ?? sourceKey,
}))

const INTEGRATION_EDGE_SPECS: EdgeTransferSpec[] = [
{ sourceKey: 'directoryAccounts', addKey: 'addDirectoryAccountIDs', label: 'Directory accounts' },
{ sourceKey: 'assessmentResponses', addKey: 'addAssessmentResponseIDs', label: 'Assessment responses' },
{ sourceKey: 'findings', addKey: 'addFindingIDs', label: 'Findings' },
{ sourceKey: 'files', addKey: 'addFileIDs', label: 'Files' },
]

const EDGE_TRANSFER_SPECS: EdgeTransferSpec[] = [...buildAssociationEdgeSpecs(IDENTITY_HOLDER_ASSOCIATION_CONFIG.initialDataKeys), ...INTEGRATION_EDGE_SPECS]

const collectEdgeIds = (edges: EdgeConnection['edges']): string[] => (edges ?? []).map((e) => e?.node?.id).filter((id): id is string => typeof id === 'string')

const usePersonnelPreSaveExtras = ({
secondaryId,
primary,
}: {
primaryId: string
secondaryId: string | null
primary: Personnel | null | undefined
}): MergePreSaveExtrasResult<UpdateIdentityHolderInput> => {
const { data, isLoading } = useGetIdentityHolderEdgesForMerge(secondaryId)

return useMemo(() => {
if (!secondaryId || !data?.identityHolder) {
return { data: null, counts: [], isLoading }
}

const holder = data.identityHolder
const extras: Partial<UpdateIdentityHolderInput> = {}
const counts: MergeEdgeTransferCount[] = []

for (const spec of EDGE_TRANSFER_SPECS) {
const connection = holder[spec.sourceKey] as EdgeConnection | null | undefined
const ids = collectEdgeIds(connection?.edges)
if (ids.length) {
;(extras as Record<string, unknown>)[spec.addKey] = ids
counts.push({ label: spec.label, count: ids.length })
}
}
if (!primary.userID && secondary.userID) {
data.userID = secondary.userID
counts.push({ key: 'userID', label: 'User link', count: 1 })
}

const primaryUserID = primary?.userID ?? null
if (!primaryUserID && holder.userID) {
extras.userID = holder.userID
counts.push({ label: 'User link', count: 1 })
}
if (!primary.employerEntityID && secondary.employerEntityID) {
data.employerID = secondary.employerEntityID
counts.push({ key: 'employerID', label: 'Employer', count: 1 })
}

return { data: extras, counts, isLoading }
}, [data, isLoading, primary?.userID, secondaryId])
return { data, counts }
}

export const personnelMergeConfig: MergeConfig<Personnel, UpdateIdentityHolderInput, 'IdentityHolder'> = {
Expand All @@ -176,6 +116,6 @@ export const personnelMergeConfig: MergeConfig<Personnel, UpdateIdentityHolderIn
defaultOn: true,
label: 'Add secondary email to aliases',
},
usePreSaveInputExtras: usePersonnelPreSaveExtras,
preSaveInputExtras: personnelPreSaveExtras,
deleteSecondaryFirst: true,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { GraphQLClient } from 'graphql-request'
import { getEdgeIds } from '@/components/shared/object-association/utils'

export const EDGE_PAGE_SIZE = 100

const MAX_EDGE_PAGES = 200

const MAX_CONNECTIONS_PER_REQUEST = 10

type PendingEdge = {
name: string
cursor: string | null
}

type EdgeConnectionPage = {
pageInfo: { hasNextPage: boolean; endCursor: string | null }
edges: Array<{ node: { id: string } | null } | null> | null
}

type EdgeIdsQueryResponse = {
record: Record<string, EdgeConnectionPage | null> | null
}

const cursorVariableName = (edgeName: string) => `after_${edgeName}`

const chunk = <T>(items: readonly T[], size: number): T[][] => {
const groups: T[][] = []
for (let index = 0; index < items.length; index += size) groups.push(items.slice(index, index + size))
return groups
}

export const buildEdgeIdsQuery = (queryField: string, pending: readonly PendingEdge[]): string => {
const paged = pending.filter((edge) => edge.cursor !== null)
const variableDefinitions = ['$id: ID!', ...paged.map((edge) => `$${cursorVariableName(edge.name)}: Cursor`)]
const selections = pending.map((edge) => {
const after = edge.cursor === null ? '' : `, after: $${cursorVariableName(edge.name)}`
return ` ${edge.name}(first: ${EDGE_PAGE_SIZE}${after}) { pageInfo { hasNextPage endCursor } edges { node { id } } }`
})

return `query MergeEdgeIds(${variableDefinitions.join(', ')}) {\n record: ${queryField}(id: $id) {\n${selections.join('\n')}\n }\n}`
}

export const fetchEdgeIdsByEdgeName = async (client: GraphQLClient, queryField: string, edgeNames: readonly string[], recordId: string): Promise<Map<string, string[]>> => {
const collected = new Map<string, string[]>(edgeNames.map((name) => [name, []]))
let pending: PendingEdge[] = edgeNames.map((name) => ({ name, cursor: null }))
let pagesFetched = 0

while (pending.length > 0) {
if (pagesFetched >= MAX_EDGE_PAGES) throw new Error(`Reading linked records stopped after ${MAX_EDGE_PAGES} pages; merging would have transferred only part of them.`)

const groups = chunk(pending, MAX_CONNECTIONS_PER_REQUEST)
const responses = await Promise.all(
groups.map((group) => {
const variables: Record<string, string> = { id: recordId }
for (const edge of group) {
if (edge.cursor !== null) variables[cursorVariableName(edge.name)] = edge.cursor
}
return client.request<EdgeIdsQueryResponse>(buildEdgeIdsQuery(queryField, group), variables)
}),
)
pagesFetched += 1

const stillPaging: PendingEdge[] = []
responses.forEach((response, groupIndex) => {
const record = response.record
if (!record) throw new Error(`Record ${recordId} could not be read while collecting its linked records.`)

for (const edge of groups[groupIndex]) {
const page = record[edge.name]
if (!page) throw new Error(`The "${edge.name}" links on record ${recordId} could not be read; merging would silently drop them.`)

collected.get(edge.name)?.push(...getEdgeIds(page.edges))

if (page.pageInfo.hasNextPage && page.pageInfo.endCursor) stillPaging.push({ name: edge.name, cursor: page.pageInfo.endCursor })
}
})

pending = stillPaging
}

return collected
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import React, { useState } from 'react'
import { ArrowRightLeft } from 'lucide-react'
import { Button } from '@repo/ui/button'
import { MergeRecordsSheet } from './merge-records-sheet'
import type { MergeableTypeName } from '@repo/codegen/src/merge-fields.generated'
import type { MergeConfig } from './types'

type Props<TRecord extends object, TUpdateInput> = {
type Props<TRecord extends object, TUpdateInput, TEntity extends MergeableTypeName> = {
primaryId: string
config: MergeConfig<TRecord, TUpdateInput>
config: MergeConfig<TRecord, TUpdateInput, TEntity>
onMergeComplete?: () => void
}

export const MergeMenuItem = <TRecord extends object, TUpdateInput>({ primaryId, config, onMergeComplete }: Props<TRecord, TUpdateInput>) => {
export const MergeMenuItem = <TRecord extends object, TUpdateInput, TEntity extends MergeableTypeName>({ primaryId, config, onMergeComplete }: Props<TRecord, TUpdateInput, TEntity>) => {
const [open, setOpen] = useState(false)
return (
<>
Expand All @@ -25,7 +26,7 @@ export const MergeMenuItem = <TRecord extends object, TUpdateInput>({ primaryId,
)
}

export const MergeHeaderButton = <TRecord extends object, TUpdateInput>({ primaryId, config, onMergeComplete }: Props<TRecord, TUpdateInput>) => {
export const MergeHeaderButton = <TRecord extends object, TUpdateInput, TEntity extends MergeableTypeName>({ primaryId, config, onMergeComplete }: Props<TRecord, TUpdateInput, TEntity>) => {
const [open, setOpen] = useState(false)
return (
<>
Expand Down
Loading