Add tool to create missing SocialIdentity (#10758)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-15 14:33:55 +07:00
committed by GitHub
parent 7bb98df779
commit 869bec96dc
3 changed files with 378 additions and 2 deletions
+208
View File
@@ -0,0 +1,208 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import contact, { type Person } from '@hcengineering/contact'
import {
type Doc,
MeasureMetricsContext,
SocialIdType,
type Space,
type PersonId,
type PersonInfo,
type PersonUuid,
type Ref,
type TxOperations
} from '@hcengineering/core'
import { ensureMissingSocialIdentities } from './contact'
function personFixture (overrides: Partial<Person> = {}): Person {
return {
_id: 'contact:person:p1' as Ref<Person>,
_class: contact.class.Person,
space: contact.space.Contacts,
name: 'Person One',
modifiedOn: 0,
modifiedBy: 'core:account:System' as PersonId,
personUuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as PersonUuid,
...overrides
} as any
}
function createMockOps (config: {
persons: Person[]
findOne?: jest.Mock
addCollection?: jest.Mock
employeePersonUuid?: PersonUuid
}): { ops: TxOperations, addCollection: jest.Mock, findOne: jest.Mock } {
const findOne =
config.findOne ??
jest.fn(async () => {
return null
})
const addCollection = config.addCollection ?? jest.fn(async () => 'new-id' as Ref<Person>)
const hierarchy = {
as: (_person: Person, mixin: Ref<Doc<Space>>) => {
if (mixin === contact.mixin.Employee) {
return config.employeePersonUuid != null ? { personUuid: config.employeePersonUuid } : {}
}
return undefined
}
}
const ops = {
findAll: jest.fn(async () => config.persons),
getHierarchy: () => hierarchy,
findOne,
addCollection
} as unknown as TxOperations
return { ops, addCollection, findOne }
}
describe('ensureMissingSocialIdentities', () => {
const toolCtx = new MeasureMetricsContext('test', {})
it('counts persons without personUuid as skipped', async () => {
const person = personFixture({ personUuid: undefined })
const { ops } = createMockOps({ persons: [person] })
const accountClient = { getPersonInfo: jest.fn() }
const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false)
expect(result.skippedPersons).toBe(1)
expect(result.created).toBe(0)
expect(accountClient.getPersonInfo).not.toHaveBeenCalled()
})
it('dry-run does not call addCollection and counts wouldCreate', async () => {
const person = personFixture()
const { ops, addCollection } = createMockOps({ persons: [person] })
const socialId = 'social-id-1' as PersonId
const accountClient = {
getPersonInfo: jest.fn(
async (): Promise<PersonInfo> => ({
name: 'n',
socialIds: [
{
_id: socialId,
type: SocialIdType.EMAIL,
value: 'u@v.com',
key: 'email:u@v.com'
}
]
})
)
}
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, true)
expect(result.wouldCreate).toBe(1)
expect(result.created).toBe(0)
expect(addCollection).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledWith('[dry-run] missing SocialIdentity', expect.stringContaining('social-id-1'))
logSpy.mockRestore()
})
it('creates SocialIdentity with isDeleted false when not dry-run', async () => {
const person = personFixture()
const { ops, addCollection } = createMockOps({ persons: [person] })
const socialId = 'social-id-2' as PersonId
const accountClient = {
getPersonInfo: jest.fn(
async (): Promise<PersonInfo> => ({
name: 'n',
socialIds: [
{
_id: socialId,
type: SocialIdType.EMAIL,
value: 'a@b.com',
key: 'email:a@b.com',
verifiedOn: 1
}
]
})
)
}
const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false)
expect(result.created).toBe(1)
expect(addCollection).toHaveBeenCalledTimes(1)
expect(addCollection).toHaveBeenCalledWith(
contact.class.SocialIdentity,
contact.space.Contacts,
person._id,
contact.class.Person,
'socialIds',
expect.objectContaining({
type: SocialIdType.EMAIL,
value: 'a@b.com',
key: 'email:a@b.com',
isDeleted: false,
verifiedOn: 1
}),
socialId
)
})
it('skips creation when SocialIdentity already exists by _id', async () => {
const person = personFixture()
const socialId = 'social-id-3' as PersonId
const findOne = jest.fn(async (_cls: unknown, query: { _id?: PersonId }) => {
if (query._id === socialId) {
return { _id: socialId }
}
return null
})
const { ops, addCollection } = createMockOps({ persons: [person], findOne })
const accountClient = {
getPersonInfo: jest.fn(
async (): Promise<PersonInfo> => ({
name: 'n',
socialIds: [
{
_id: socialId,
type: SocialIdType.EMAIL,
value: 'x@y.com',
key: 'email:x@y.com'
}
]
})
)
}
const result = await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false)
expect(result.created).toBe(0)
expect(addCollection).not.toHaveBeenCalled()
})
it('uses employee.personUuid when present', async () => {
const empUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' as PersonUuid
const person = personFixture({ personUuid: undefined })
const { ops } = createMockOps({ persons: [person], employeePersonUuid: empUuid })
const accountClient = {
getPersonInfo: jest.fn(
async (): Promise<PersonInfo> => ({
name: 'n',
socialIds: []
})
)
}
await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false)
expect(accountClient.getPersonInfo).toHaveBeenCalledWith(empUuid)
})
})
+131
View File
@@ -0,0 +1,131 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { AccountClient } from '@hcengineering/account-client'
import contact, { type SocialIdentityRef } from '@hcengineering/contact'
import {
buildSocialIdString,
type MeasureMetricsContext,
type PersonInfo,
type TxOperations
} from '@hcengineering/core'
export interface EnsureMissingSocialIdentitiesResult {
skippedPersons: number
wouldCreate: number
created: number
}
/**
* For each workspace person linked to an account, ensures SocialIdentity docs exist
* for every active account social id (same _id as in the account DB). See
* {@link createSocialIdentities} in model-contact migration.
*/
export async function ensureMissingSocialIdentities (
toolCtx: MeasureMetricsContext,
ops: TxOperations,
accountClient: Pick<AccountClient, 'getPersonInfo'>,
dryRun: boolean
): Promise<EnsureMissingSocialIdentitiesResult> {
let wouldCreate = 0
let created = 0
let skippedPersons = 0
const persons = await ops.findAll(contact.class.Person, {})
for (const person of persons) {
const employee = ops.getHierarchy().as(person, contact.mixin.Employee)
const personUuid = employee?.personUuid ?? person.personUuid
if (personUuid == null) {
skippedPersons++
continue
}
let personInfo: PersonInfo
try {
personInfo = await accountClient.getPersonInfo(personUuid)
} catch (err: any) {
toolCtx.error('ensure-missing-social-identities: getPersonInfo failed', {
person: person._id,
personUuid,
message: err?.message ?? String(err)
})
continue
}
const socials = (personInfo.socialIds ?? []).filter((s) => s.isDeleted !== true)
for (const social of socials) {
const socialDocId = social._id as SocialIdentityRef
const expectedKey = buildSocialIdString({ type: social.type, value: social.value })
if (social.key !== expectedKey) {
toolCtx.warn('ensure-missing-social-identities: social key does not match type:value', {
person: person._id,
personUuid,
socialId: social._id,
key: social.key,
expectedKey
})
}
const existingById = await ops.findOne(contact.class.SocialIdentity, { _id: socialDocId })
if (existingById != null) {
continue
}
const existingByKey = await ops.findOne(contact.class.SocialIdentity, {
attachedTo: person._id,
key: social.key
})
if (existingByKey != null) {
toolCtx.warn('ensure-missing-social-identities: SocialIdentity exists for key but different _id', {
person: person._id,
personUuid,
accountSocialId: social._id,
existingId: existingByKey._id,
key: social.key
})
continue
}
if (dryRun) {
wouldCreate++
console.log(
'[dry-run] missing SocialIdentity',
JSON.stringify({
person: person._id,
personUuid,
socialId: social._id,
key: social.key,
type: social.type
})
)
} else {
await ops.addCollection(
contact.class.SocialIdentity,
contact.space.Contacts,
person._id,
contact.class.Person,
'socialIds',
{
type: social.type,
value: social.value,
key: social.key,
isDeleted: false,
...(social.verifiedOn != null ? { verifiedOn: social.verifiedOn } : {}),
...(social.displayValue != null ? { displayValue: social.displayValue } : {})
},
socialDocId
)
created++
}
}
}
return { skippedPersons, wouldCreate, created }
}
+39 -2
View File
@@ -73,7 +73,7 @@ import { updateField } from './workspace'
import { RatingCalculator, ratingEvents, type QueueRatingMessage } from '@hcengineering/pod-rating'
import {
import core, {
AccountRole,
isArchivingMode,
isDeletingMode,
@@ -82,6 +82,7 @@ import {
SocialIdType,
systemAccountEmail,
systemAccountUuid,
TxOperations,
type AccountUuid,
type Data,
type Doc,
@@ -125,13 +126,14 @@ import {
restoreFromv6All,
restoreTrustedV6Workspace
} from './db'
import { ensureMissingSocialIdentities } from './contact'
import { performGithubAccountMigrations } from './github'
import { performGmailAccountMigrations } from './gmail'
import { getToolToken, getWorkspace, getWorkspaceTransactorEndpoint } from './utils'
import { createRestClient } from '@hcengineering/api-client'
import { type CardID } from '@hcengineering/communication-types'
import { sendTransactorEvent } from '@hcengineering/server-tool'
import { connect, sendTransactorEvent } from '@hcengineering/server-tool'
import { existsSync } from 'fs'
import { mkdir, writeFile } from 'fs/promises'
import { dirname } from 'path'
@@ -2978,6 +2980,41 @@ export function devTool (
})
})
program
.command('ensure-missing-social-identities <workspace>')
.description(
'Create contact.class.SocialIdentity in the workspace for account social ids missing on the person (see contact migration createSocialIdentities)'
)
.option('-d, --dry-run', 'Only log persons/social ids that would be created', false)
.action(async (workspace: string, cmd: { dryRun: boolean }) => {
await withAccountDatabase(async (db) => {
const info = await getWorkspace(db, workspace)
if (info === null) {
throw new Error(`Workspace ${workspace} not found`)
}
const wsUuid = info.uuid
const endpoint = await getWorkspaceTransactorEndpoint(wsUuid)
const accountClient = getAccountClient(getToolToken(wsUuid))
const connection = await connect(endpoint, wsUuid, undefined, { model: 'upgrade' })
const ops = new TxOperations(connection, core.account.ConfigUser)
try {
const { skippedPersons, wouldCreate, created } = await ensureMissingSocialIdentities(
toolCtx,
ops,
accountClient,
cmd.dryRun
)
console.log(
cmd.dryRun
? `ensure-missing-social-identities dry-run: persons without personUuid skipped=${skippedPersons}, social identities that would be created=${wouldCreate}`
: `ensure-missing-social-identities: persons without personUuid skipped=${skippedPersons}, SocialIdentity docs created=${created}`
)
} finally {
await connection.close()
}
})
})
extendProgram?.(program)
process.on('unhandledRejection', (reason, promise) => {