From 2a7189908bce92971753a59480833e87dcdfa027 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Thu, 2 Oct 2025 21:10:56 +0500 Subject: [PATCH] Different social ids for calendar Signed-off-by: Denis Bykhov --- common/scripts/version.txt | 2 +- models/calendar/package.json | 1 + models/calendar/src/migration.ts | 125 ++++++++++- .../src/components/CalendarSelector.svelte | 2 +- .../src/components/CalendarSettings.svelte | 5 +- services/calendar/pod-calendar/src/auth.ts | 201 ++++++++++-------- services/calendar/pod-calendar/src/main.ts | 11 +- .../pod-calendar/src/outcomingClient.ts | 2 +- services/calendar/pod-calendar/src/types.ts | 6 +- 9 files changed, 252 insertions(+), 103 deletions(-) diff --git a/common/scripts/version.txt b/common/scripts/version.txt index 04c724932a4..955ba890f03 100644 --- a/common/scripts/version.txt +++ b/common/scripts/version.txt @@ -1 +1 @@ -"0.7.230" +"0.7.269" diff --git a/models/calendar/package.json b/models/calendar/package.json index fc7cf799b98..c71f8e61fb3 100644 --- a/models/calendar/package.json +++ b/models/calendar/package.json @@ -48,6 +48,7 @@ "@hcengineering/model-view": "^0.6.0", "@hcengineering/model-setting": "^0.6.0", "@hcengineering/model-workbench": "^0.6.1", + "@hcengineering/account-client": "^0.6.0", "@hcengineering/activity": "^0.6.0", "@hcengineering/workbench": "^0.6.16", "@hcengineering/model-preference": "^0.6.0", diff --git a/models/calendar/src/migration.ts b/models/calendar/src/migration.ts index b7a054b6fc8..cc800333b13 100644 --- a/models/calendar/src/migration.ts +++ b/models/calendar/src/migration.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import { type IntegrationSecret } from '@hcengineering/account-client' import { AccessLevel, type Calendar, @@ -21,10 +22,13 @@ import { type ExternalCalendar, type ReccuringEvent } from '@hcengineering/calendar' +import contact, { type SocialIdentity, type SocialIdentityRef } from '@hcengineering/contact' import core, { type AccountUuid, + buildSocialIdString, type Doc, DOMAIN_TX, + type IntegrationKind, type PersonId, pickPrimarySocialId, type Ref, @@ -50,7 +54,6 @@ import { getSocialKeyByOldAccount } from '@hcengineering/model-core' import setting, { DOMAIN_SETTING, type Integration } from '@hcengineering/setting' -import contact, { type SocialIdentityRef, type SocialIdentity } from '@hcengineering/contact' import { DOMAIN_CALENDAR, DOMAIN_EVENT } from '.' import calendar from './plugin' @@ -455,6 +458,116 @@ async function migrateTimezone (client: MigrationClient): Promise { ) } +async function moveMigration (client: MigrationClient, secret: IntegrationSecret, socialId: PersonId): Promise { + await client.accountClient.deleteIntegrationSecret(secret) + const exists = await client.accountClient.listIntegrations({ + kind: secret.kind, + socialId, + workspaceUuid: secret.workspaceUuid + }) + if (exists.length === 0) { + await client.accountClient.createIntegration({ + kind: secret.kind, + workspaceUuid: secret.workspaceUuid, + socialId, + data: { + email: secret.key + } + }) + } + await client.accountClient.addIntegrationSecret({ + ...secret, + socialId + }) +} + +async function migrateIntegrations (client: MigrationClient): Promise { + const secrets = await client.accountClient.listIntegrationsSecrets({ + kind: 'google-calendar' as IntegrationKind + }) + const map = new Map() + for (const secret of secrets) { + try { + const exists = map.get(secret.socialId) + if (exists == null) continue + if (exists !== undefined) { + await moveMigration(client, secret, exists) + } else { + const socials = await client.accountClient.findFullSocialIds([secret.socialId]) + if (socials.length === 0) continue + if (socials[0].type === SocialIdType.GOOGLE && socials[0].value === secret.key) continue + const person = socials[0].personUuid + + const target = await client.accountClient.findFullSocialIdBySocialKey( + buildSocialIdString({ + type: SocialIdType.GOOGLE, + value: secret.key + }) + ) + if (target?.personUuid === person) { + // Ok, it is the same person, just recreate integration + map.set(secret.socialId, target._id) + await moveMigration(client, secret, target._id) + } else if (target == null) { + const newOne = await client.accountClient.addSocialIdToPerson( + person, + SocialIdType.GOOGLE, + secret.key, + true, + secret.key + ) + map.set(secret.socialId, newOne) + await moveMigration(client, secret, newOne) + } else { + // oh, shit, it's a different person, let's remove it + await client.accountClient.deleteIntegrationSecret(secret) + } + } + } catch (err) { + client.logger.error('Error while migrating integration', { secret, error: err }) + } + } +} + +async function updateCalendarUser (client: MigrationClient): Promise { + const calendars = await client.find(DOMAIN_CALENDAR, { + _class: calendar.class.ExternalCalendar + }) + const accs = new Map() + for (const calendar of calendars) { + try { + const exists = accs.get(calendar.user) + if (exists !== undefined) { + await client.update(DOMAIN_CALENDAR, { _id: calendar._id }, { user: exists }) + continue + } + const accId = await client.accountClient.findPersonBySocialId(calendar.user) + if (accId === undefined) continue + const socialId = await client.accountClient.findFullSocialIdBySocialKey( + buildSocialIdString({ + type: SocialIdType.GOOGLE, + value: calendar.externalUser + }) + ) + if (socialId === undefined) { + const newOne = await client.accountClient.addSocialIdToPerson( + accId, + SocialIdType.GOOGLE, + calendar.externalUser, + true, + calendar.externalUser + ) + accs.set(calendar.user, newOne) + } else if (socialId.personUuid === accId) { + accs.set(calendar.user, socialId._id) + await client.update(DOMAIN_CALENDAR, { _id: calendar._id }, { user: accId }) + } + } catch (e) { + client.logger.error('Error while updating calendar user', { calendar: calendar._id, error: e }) + } + } +} + export const calendarOperation: MigrateOperation = { async migrate (client: MigrationClient, mode): Promise { await tryMigrate(mode, client, calendarId, [ @@ -528,6 +641,16 @@ export const calendarOperation: MigrateOperation = { state: 'migrate-ev-user-for-deleted', mode: 'upgrade', func: migrateEventUserForDeleted + }, + { + state: 'migrate-integrations', + mode: 'upgrade', + func: migrateIntegrations + }, + { + state: 'update-calendar-user', + mode: 'upgrade', + func: updateCalendarUser } ]) }, diff --git a/plugins/calendar-resources/src/components/CalendarSelector.svelte b/plugins/calendar-resources/src/components/CalendarSelector.svelte index 1b8a146005f..948f83c0d13 100644 --- a/plugins/calendar-resources/src/components/CalendarSelector.svelte +++ b/plugins/calendar-resources/src/components/CalendarSelector.svelte @@ -24,7 +24,7 @@ q.query( calendar.class.Calendar, - { user: me.primarySocialId, hidden: false, access: { $in: [AccessLevel.Owner, AccessLevel.Writer] } }, + { user: { $in: me.socialIds }, hidden: false, access: { $in: [AccessLevel.Owner, AccessLevel.Writer] } }, (res) => { calendarsLoaded = true calendars = res diff --git a/plugins/calendar-resources/src/components/CalendarSettings.svelte b/plugins/calendar-resources/src/components/CalendarSettings.svelte index ddbc6ec7fc2..cefee8447c7 100644 --- a/plugins/calendar-resources/src/components/CalendarSettings.svelte +++ b/plugins/calendar-resources/src/components/CalendarSettings.svelte @@ -10,13 +10,16 @@ const client = getClient() + const myAcc = getCurrentAccount() + const socialStrings = myAcc.socialIds + let calendars: Calendar[] = [] const query = createQuery() query.query( calendar.class.Calendar, { - user: getCurrentAccount().primarySocialId + user: { $in: socialStrings } }, (res) => { calendars = res diff --git a/services/calendar/pod-calendar/src/auth.ts b/services/calendar/pod-calendar/src/auth.ts index 6d385867083..9ba196f2ec8 100644 --- a/services/calendar/pod-calendar/src/auth.ts +++ b/services/calendar/pod-calendar/src/auth.ts @@ -15,34 +15,33 @@ import { AccountClient, IntegrationSecret } from '@hcengineering/account-client' import calendar, { calendarIntegrationKind } from '@hcengineering/calendar' -import contact, { Employee, getPrimarySocialId, SocialIdentityRef } from '@hcengineering/contact' +import contact, { getPrimarySocialId } from '@hcengineering/contact' import core, { AccountUuid, - buildSocialIdString, MeasureContext, PersonId, - Ref, SocialIdType, TxOperations, WorkspaceUuid } from '@hcengineering/core' +import type { IntegrationClient } from '@hcengineering/integration-client' import setting from '@hcengineering/setting' import { Credentials, OAuth2Client } from 'google-auth-library' import { calendar_v3, google } from 'googleapis' -import type { IntegrationClient } from '@hcengineering/integration-client' import { encode64 } from './base64' import { getClient } from './client' import { setSyncHistory } from './kvsUtils' import { lock } from './mutex' import { IncomingSyncManager } from './sync' -import { GoogleEmail, SCOPES, State, Token, User } from './types' +import { GoogleEmail, SCOPES, State, Token } from './types' import { addUserByEmail, getGoogleClient, removeIntegrationSecret, removeUserByEmail } from './utils' import { WatchController } from './watch' interface AuthResult { success: boolean email: GoogleEmail + personId?: PersonId } export class AuthController { @@ -54,8 +53,7 @@ export class AuthController { private readonly ctx: MeasureContext, private readonly accountClient: AccountClient, private readonly integrationClient: IntegrationClient, - private readonly client: TxOperations, - private readonly user: User + private readonly user: State ) { const res = getGoogleClient() this.googleClient = res.google @@ -73,17 +71,15 @@ export class AuthController { 'Create auth controller', {}, async () => { - const mutex = await lock(`${state.workspace}:${state.userId}`) + const mutex = await lock(`${state.workspace}:${state.accountUuid}`) try { - const client = await getClient(state.workspace) - const txOp = new TxOperations(client, state.userId) - const controller = new AuthController(ctx, accountClient, integrationClient, txOp, state) - await controller.process(code) + const controller = new AuthController(ctx, accountClient, integrationClient, state) + await controller.process(code, state) } finally { mutex() } }, - { workspace: state.workspace, user: state.userId } + { workspace: state.workspace, user: state.accountUuid } ) } @@ -102,12 +98,34 @@ export class AuthController { const mutex = await lock(`${workspace}:${userId}`) try { const client = await getClient(workspace) - const txOp = new TxOperations(client, core.account.System) - const controller = new AuthController(ctx, accountClient, integrationClient, txOp, { - userId, - workspace + const txOp = new TxOperations(client, userId) + const integration = await txOp.findOne(setting.class.Integration, { + type: calendar.integrationType.Calendar, + createdBy: userId, + value }) - await controller.signout(value) + if (integration !== undefined) { + await txOp.remove(integration) + } + removeUserByEmail( + { + workspace, + userId + }, + value + ) + const data = { + kind: calendarIntegrationKind, + workspaceUuid: workspace, + key: value, + socialId: userId + } + const secret = await accountClient.getIntegrationSecret(data) + if (secret == null) return + const token = JSON.parse(secret.secret) + await removeIntegrationSecret(ctx, accountClient, data) + const watchController = WatchController.get(ctx, accountClient) + await watchController.unsubscribe(token) } catch (err) { ctx.error('signout', { workspace, userId, err }) } finally { @@ -118,40 +136,21 @@ export class AuthController { ) } - private async signout (value: GoogleEmail): Promise { - const integration = await this.client.findOne(setting.class.Integration, { - type: calendar.integrationType.Calendar, - createdBy: this.user.userId, - value - }) - if (integration !== undefined) { - await this.client.remove(integration) - } - removeUserByEmail(this.user, value) - const data = { - kind: calendarIntegrationKind, - workspaceUuid: this.user.workspace, - key: value, - socialId: this.user.userId - } - const secret = await this.accountClient.getIntegrationSecret(data) - if (secret == null) return - const token = JSON.parse(secret.secret) - await removeIntegrationSecret(this.ctx, this.accountClient, data) - const watchController = WatchController.get(this.ctx, this.accountClient) - await watchController.unsubscribe(token) - } - - private async process (code: string): Promise { - const authRes = await this.authorize(code) - await this.setWorkspaceIntegration(authRes) - if (authRes.success) { + private async process (code: string, state: State): Promise { + const authRes = await this.authorize(code, state) + if (authRes.success && authRes.personId !== undefined) { + const client = await getClient(this.user.workspace) + const txOp = new TxOperations(client, authRes.personId) + await this.setWorkspaceIntegration(authRes, authRes.personId, txOp) await setSyncHistory(this.user.workspace, Date.now()) void IncomingSyncManager.initSync( this.ctx, this.accountClient, - this.client, - this.user, + txOp, + { + workspace: this.user.workspace, + userId: authRes.personId + }, authRes.email, this.googleClient ) @@ -169,7 +168,7 @@ export class AuthController { return this.email } - private async authorize (code: string): Promise { + private async authorize (code: string, state: State): Promise { const token = await this.oAuth2Client.getToken(code) this.oAuth2Client.setCredentials(token.tokens) const email = await this.getEmail() @@ -181,34 +180,34 @@ export class AuthController { } } const res = await this.oAuth2Client.refreshAccessToken() - await this.createAccIntegrationIfNotExists(email) - await this.updateToken(res.credentials, email) + const personId = await this.createAccIntegrationIfNotExists(state.accountUuid, email) + await this.updateToken(res.credentials, personId, email) - return { success: true, email } + return { success: true, email, personId } } - private async setWorkspaceIntegration (res: AuthResult): Promise { + private async setWorkspaceIntegration (res: AuthResult, personId: PersonId, client: TxOperations): Promise { await this.ctx.with( 'Set workspace integration', {}, async () => { - const integrations = await this.client.findAll(setting.class.Integration, { - createdBy: this.user.userId, + const integrations = await client.findAll(setting.class.Integration, { + createdBy: personId, type: calendar.integrationType.Calendar }) const updated = integrations.find((p) => p.disabled && p.value === res.email) for (const integration of integrations.filter((p) => p.value === '')) { - await this.client.remove(integration) + await client.remove(integration) } if (!res.success) { if (updated !== undefined) { - await this.client.update(updated, { + await client.update(updated, { disabled: true, error: calendar.string.NotAllPermissions }) } else { - await this.client.createDoc(setting.class.Integration, core.space.Workspace, { + await client.createDoc(setting.class.Integration, core.space.Workspace, { type: calendar.integrationType.Calendar, disabled: true, error: calendar.string.NotAllPermissions, @@ -218,49 +217,57 @@ export class AuthController { throw new Error('Not all scopes provided') } else { if (updated !== undefined) { - await this.client.update(updated, { + await client.update(updated, { disabled: false, error: null }) } else { - await this.client.createDoc(setting.class.Integration, core.space.Workspace, { + await client.createDoc(setting.class.Integration, core.space.Workspace, { type: calendar.integrationType.Calendar, disabled: false, value: res.email }) } } - await this.addSocialId(res.email) }, { - user: this.user.userId, + user: personId, workspace: this.user.workspace, email: res.email } ) } - private async createAccIntegrationIfNotExists (email: string): Promise { - await this.ctx.with( + private async createAccIntegrationIfNotExists (accountUuid: AccountUuid, email: string): Promise { + return await this.ctx.with( 'Create account integration if not exists', {}, async () => { const data = { email } - const connection = await this.integrationClient.connect(this.user.userId, data) + const newSocialId = await this.accountClient.addSocialIdToPerson( + accountUuid, + SocialIdType.GOOGLE, + email, + true, + email + ) + const connection = await this.integrationClient.connect(newSocialId, data) await this.integrationClient.integrate(connection, this.user.workspace, data) + return newSocialId }, - { user: this.user.userId } + { accountUuid, email } ) } - private async updateToken (token: Credentials, email: GoogleEmail): Promise { + private async updateToken (token: Credentials, personId: PersonId, email: GoogleEmail): Promise { const _token: Token = { - ...this.user, + workspace: this.user.workspace, + userId: personId, email, ...token } const data: IntegrationSecret = { - socialId: this.user.userId, + socialId: personId, kind: calendarIntegrationKind, workspaceUuid: this.user.workspace, key: email, @@ -270,31 +277,16 @@ export class AuthController { await this.integrationClient.setSecret(data) addUserByEmail(_token, email) } catch (err) { - this.ctx.error('update token error', { workspace: this.user.workspace, user: this.user.userId, err }) + this.ctx.error('update token error', { workspace: this.user.workspace, email, err }) } } - private async addSocialId (email: GoogleEmail): Promise { - const socialString = buildSocialIdString({ type: SocialIdType.GOOGLE, value: email }) - const exists = await this.accountClient.findFullSocialIdBySocialKey(socialString) - if (exists !== undefined) { - return - } - const sID = await this.client.findOne(contact.class.SocialIdentity, { - _id: this.user.userId as SocialIdentityRef - }) - if (sID === undefined) return - const person = await this.client.findOne(contact.mixin.Employee, { _id: sID.attachedTo as Ref }) - if (person?.personUuid === undefined) return - await this.accountClient.addSocialIdToPerson(person.personUuid, SocialIdType.GOOGLE, email, true) - } - - static getAuthUrl (redirectURL: string, workspace: WorkspaceUuid, userId: PersonId, token: string): string { + static getAuthUrl (redirectURL: string, workspace: WorkspaceUuid, accountUuid: AccountUuid): string { const res = getGoogleClient() const oAuth2Client = res.auth const state: State = { redirectURL, - userId, + accountUuid, workspace } const authUrl = oAuth2Client.generateAuthUrl({ @@ -306,19 +298,48 @@ export class AuthController { return authUrl } - static async getUserId (account: AccountUuid, workspace: WorkspaceUuid, token: string): Promise { + static async getPrimaryUserId ( + account: AccountUuid, + workspace: WorkspaceUuid, + token: string, + email: GoogleEmail + ): Promise { + const client = await getClient(workspace, token) + const person = await client.findOne(contact.class.Person, { personUuid: account }) + if (person === undefined) { + throw new Error('Person not found') + } + + const primaryId = await getPrimarySocialId(client, person._id) + if (primaryId === undefined) { + throw new Error('PrimaryId not found') + } + + return primaryId + } + + static async getUserId ( + account: AccountUuid, + workspace: WorkspaceUuid, + token: string, + email: GoogleEmail + ): Promise { const client = await getClient(workspace, token) const person = await client.findOne(contact.class.Person, { personUuid: account }) if (person === undefined) { throw new Error('Person not found') } - const personId = await getPrimarySocialId(client, person._id) + const personId = await client.findOne(contact.class.SocialIdentity, { + attachedTo: person._id, + type: SocialIdType.GOOGLE, + value: email + }) if (personId === undefined) { throw new Error('PersonId not found') } - return personId + return personId._id } } diff --git a/services/calendar/pod-calendar/src/main.ts b/services/calendar/pod-calendar/src/main.ts index b72551637ac..c0502b54539 100644 --- a/services/calendar/pod-calendar/src/main.ts +++ b/services/calendar/pod-calendar/src/main.ts @@ -15,15 +15,15 @@ import { SplitLogger } from '@hcengineering/analytics-service' import { createOpenTelemetryMetricsContext } from '@hcengineering/analytics-service/src' +import { calendarIntegrationKind } from '@hcengineering/calendar' import { newMetrics } from '@hcengineering/core' +import { getIntegrationClient } from '@hcengineering/integration-client' import { setMetadata } from '@hcengineering/platform' import serverClient, { getAccountClient } from '@hcengineering/server-client' import { initStatisticsContext } from '@hcengineering/server-core' import serverToken, { decodeToken } from '@hcengineering/server-token' -import { calendarIntegrationKind } from '@hcengineering/calendar' import { type IncomingHttpHeaders } from 'http' import { join } from 'path' -import { getIntegrationClient } from '@hcengineering/integration-client' import { AuthController } from './auth' import { decode64 } from './base64' @@ -93,9 +93,8 @@ export const main = async (): Promise => { } const redirectURL = req.query.redirectURL as string - const { account, workspace } = decodeToken(token) - const userId = await AuthController.getUserId(account, workspace, token) - const url = AuthController.getAuthUrl(redirectURL, workspace, userId, token) + const { workspace } = decodeToken(token) + const url = AuthController.getAuthUrl(redirectURL, workspace, token) res.send(url) } catch (err) { ctx.error('signin error', { message: (err as any).message }) @@ -135,7 +134,7 @@ export const main = async (): Promise => { const value = req.query.value as GoogleEmail const { account, workspace } = decodeToken(token) - const userId = await AuthController.getUserId(account, workspace, token) + const userId = await AuthController.getUserId(account, workspace, token, value) await AuthController.signout(ctx, accountClient, integrationClient, userId, workspace, value) } catch (err) { ctx.error('signout', { message: (err as any).message }) diff --git a/services/calendar/pod-calendar/src/outcomingClient.ts b/services/calendar/pod-calendar/src/outcomingClient.ts index c199662ac6a..ac82d050168 100644 --- a/services/calendar/pod-calendar/src/outcomingClient.ts +++ b/services/calendar/pod-calendar/src/outcomingClient.ts @@ -453,7 +453,7 @@ async function getTokenByEvent ( }) if (_calendar === undefined) return const res = await accountClient.getIntegrationSecret({ - socialId: event.user, + socialId: _calendar.user, kind: calendarIntegrationKind, workspaceUuid: workspace, key: _calendar.externalUser diff --git a/services/calendar/pod-calendar/src/types.ts b/services/calendar/pod-calendar/src/types.ts index dbaed069b8c..828cf11106a 100644 --- a/services/calendar/pod-calendar/src/types.ts +++ b/services/calendar/pod-calendar/src/types.ts @@ -14,7 +14,7 @@ // import { RecurringRule } from '@hcengineering/calendar' -import type { PersonId, Timestamp, WorkspaceUuid } from '@hcengineering/core' +import type { AccountUuid, PersonId, Timestamp, WorkspaceUuid } from '@hcengineering/core' import type { NextFunction, Request, Response } from 'express' import type { Credentials } from 'google-auth-library' @@ -56,8 +56,10 @@ export interface User { workspace: WorkspaceUuid } -export type State = User & { +export interface State { redirectURL: string + workspace: WorkspaceUuid + accountUuid: AccountUuid } export interface AttachedFile {