diff --git a/dev/tool/src/contact.test.ts b/dev/tool/src/contact.test.ts new file mode 100644 index 0000000000..9957ddf842 --- /dev/null +++ b/dev/tool/src/contact.test.ts @@ -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 { + return { + _id: 'contact:person:p1' as Ref, + _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) + const hierarchy = { + as: (_person: Person, mixin: Ref>) => { + 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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + name: 'n', + socialIds: [] + }) + ) + } + + await ensureMissingSocialIdentities(toolCtx, ops, accountClient, false) + + expect(accountClient.getPersonInfo).toHaveBeenCalledWith(empUuid) + }) +}) diff --git a/dev/tool/src/contact.ts b/dev/tool/src/contact.ts new file mode 100644 index 0000000000..c9f7865bd2 --- /dev/null +++ b/dev/tool/src/contact.ts @@ -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, + dryRun: boolean +): Promise { + 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 } +} diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 56acb52fea..e748e621ba 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -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 ') + .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) => {