mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Add tool to create missing SocialIdentity (#10758)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user