diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index f3b9d350fa..617a7671c2 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -34,6 +34,8 @@ jobs: run: node common/scripts/install-run-rush.js install - name: Building... run: node common/scripts/install-run-rush.js build + - name: Emit TypeScript declarations + run: node common/scripts/install-run-rush.js validate - name: Publish to npm env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 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) => { diff --git a/plugins/converter-resources/src/__tests__/formatter.utils.test.ts b/plugins/converter-resources/src/__tests__/formatter.utils.test.ts index a8729ef016..411ec0313a 100644 --- a/plugins/converter-resources/src/__tests__/formatter.utils.test.ts +++ b/plugins/converter-resources/src/__tests__/formatter.utils.test.ts @@ -50,6 +50,40 @@ describe('formatter/utils', () => { it('returns false for non-string', () => { expect(isIntlString(null as any)).toBe(false) expect(isIntlString(undefined as any)).toBe(false) + expect(isIntlString(42 as any)).toBe(false) + expect(isIntlString({} as any)).toBe(false) + expect(isIntlString([] as any)).toBe(false) + }) + + it('returns false when value looks like a URL (contains ://)', () => { + expect(isIntlString('https://example.com/path')).toBe(false) + expect(isIntlString('http://localhost:8080')).toBe(false) + expect(isIntlString('card:string:https://oops')).toBe(false) + }) + + it('returns true for embedded label prefix when non-empty after prefix', () => { + expect(isIntlString('embedded:embedded:Hello')).toBe(true) + expect(isIntlString('embedded:embedded:x')).toBe(true) + }) + + it('returns false for embedded label prefix only', () => { + expect(isIntlString('embedded:embedded:')).toBe(false) + }) + + it('returns false when plugin looks like http or https scheme', () => { + expect(isIntlString('http:string:Something')).toBe(false) + expect(isIntlString('https:string:Something')).toBe(false) + expect(isIntlString('HTTP:string:Something')).toBe(false) + }) + + it('returns false when plugin or resource kind does not match id pattern', () => { + expect(isIntlString('Card:string:Card')).toBe(false) + expect(isIntlString('plugin:1kind:Key')).toBe(false) + expect(isIntlString('plugin:_kind:Key')).toBe(false) + }) + + it('returns true for hyphenated plugin and underscore in resource id', () => { + expect(isIntlString('my-plugin:string:My_Key')).toBe(true) }) }) diff --git a/plugins/converter-resources/src/formatter/utils.ts b/plugins/converter-resources/src/formatter/utils.ts index b0499f6fee..941a9d2217 100644 --- a/plugins/converter-resources/src/formatter/utils.ts +++ b/plugins/converter-resources/src/formatter/utils.ts @@ -31,15 +31,33 @@ export enum DateFormatOption { } /** - * Check if a value is an IntlString (format: "plugin:resource:key"). - * Type guard: narrows unknown to string when true. + * Check if a value is an IntlString id ({@link Id}: {@code plugin:resourceKind:key}) or + * {@link getEmbeddedLabel} output ({@code embedded:embedded:...}). + * */ export function isIntlString (value: unknown): value is string { if (typeof value !== 'string' || value.length === 0) { return false } - const parts = value.split(':') - return parts.length >= 3 && parts.every((part) => part.length > 0) + if (value.includes('://')) { + return false + } + if (value.startsWith('embedded:embedded:')) { + return value.length > 'embedded:embedded:'.length + } + const m = /^([a-z][a-z0-9-]*):([a-zA-Z][a-zA-Z0-9_]*):(.+)$/.exec(value) + if (m === null) { + return false + } + const plugin = m[1] + const rest = m[3] + if (rest.length === 0) { + return false + } + if (/^https?$/i.test(plugin)) { + return false + } + return true } /** diff --git a/plugins/converter-resources/src/formatter/valueFormatter.ts b/plugins/converter-resources/src/formatter/valueFormatter.ts index b4aa3c11bb..cf7397f81f 100644 --- a/plugins/converter-resources/src/formatter/valueFormatter.ts +++ b/plugins/converter-resources/src/formatter/valueFormatter.ts @@ -245,7 +245,11 @@ export async function formatCustomAttributeValue ( if (typeof value === 'string') { if (isIntlString(value)) { - return await translate(value as unknown as IntlString, {}, language) + try { + return await translate(value as unknown as IntlString, {}, language) + } catch { + console.warn('Failed to translate intl string', value) + } } const isRef = attrType?._class === core.class.RefTo @@ -341,7 +345,11 @@ async function formatValueFallback ( } if (isIntlString(value)) { - return await translate(value as unknown as IntlString, {}, language) + try { + return await translate(value as unknown as IntlString, {}, language) + } catch { + console.warn('Failed to translate intl string', value) + } } if (attr.key === DocumentAttributeKey.CreatedBy || attr.key === DocumentAttributeKey.ModifiedBy) { return await loadPersonName(value as PersonId, hierarchy, userCache) diff --git a/plugins/export-resources/src/export.ts b/plugins/export-resources/src/export.ts index 60a9fff065..69333a989b 100644 --- a/plugins/export-resources/src/export.ts +++ b/plugins/export-resources/src/export.ts @@ -60,6 +60,11 @@ export async function exportToWorkspace ( 'documents:class:DocumentMeta': { author: '$currentUser', owner: '$currentUser' + }, + 'products:class:ProductVersion': { + major: 1, + minor: 0, + patch: 0 } }