Different social ids for calendar (#10008)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-10-02 23:31:59 +05:00
committed by GitHub
parent a84e2ec40a
commit aa0cfeae46
9 changed files with 252 additions and 103 deletions
+1 -1
View File
@@ -1 +1 @@
"0.7.230"
"0.7.269"
+1
View File
@@ -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",
+124 -1
View File
@@ -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<void> {
)
}
async function moveMigration (client: MigrationClient, secret: IntegrationSecret, socialId: PersonId): Promise<void> {
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<void> {
const secrets = await client.accountClient.listIntegrationsSecrets({
kind: 'google-calendar' as IntegrationKind
})
const map = new Map<PersonId, PersonId | null>()
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<void> {
const calendars = await client.find<ExternalCalendar>(DOMAIN_CALENDAR, {
_class: calendar.class.ExternalCalendar
})
const accs = new Map<string, PersonId>()
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<void> {
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
}
])
},
@@ -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
@@ -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
+111 -90
View File
@@ -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<void> {
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<void> {
const authRes = await this.authorize(code)
await this.setWorkspaceIntegration(authRes)
if (authRes.success) {
private async process (code: string, state: State): Promise<void> {
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<AuthResult> {
private async authorize (code: string, state: State): Promise<AuthResult> {
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<void> {
private async setWorkspaceIntegration (res: AuthResult, personId: PersonId, client: TxOperations): Promise<void> {
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<void> {
await this.ctx.with(
private async createAccIntegrationIfNotExists (accountUuid: AccountUuid, email: string): Promise<PersonId> {
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<void> {
private async updateToken (token: Credentials, personId: PersonId, email: GoogleEmail): Promise<void> {
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<void> {
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<Employee> })
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<PersonId> {
static async getPrimaryUserId (
account: AccountUuid,
workspace: WorkspaceUuid,
token: string,
email: GoogleEmail
): Promise<PersonId> {
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 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<PersonId> {
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 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
}
}
+5 -6
View File
@@ -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<void> => {
}
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<void> => {
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 })
@@ -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
+4 -2
View File
@@ -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 {