Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2025-02-26 15:59:41 +05:00 committed by GitHub
parent 7972d698ed
commit 5d5f6e89b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1049 additions and 514 deletions

View File

@ -25,7 +25,8 @@ import {
type WorkspaceMemberInfo,
WorkspaceMode,
concatLink,
type WorkspaceUserOperation
type WorkspaceUserOperation,
WorkspaceUuid
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import type { LoginInfo, OtpInfo, WorkspaceLoginInfo, RegionInfo, WorkspaceOperation } from './types'
@ -70,6 +71,7 @@ export interface AccountClient {
) => Promise<string>
checkJoin: (inviteId: string) => Promise<WorkspaceLoginInfo>
getWorkspaceInfo: (updateLastVisit?: boolean) => Promise<WorkspaceInfoWithStatus>
getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise<WorkspaceInfoWithStatus[]>
getRegionInfo: () => Promise<RegionInfo[]>
createWorkspace: (name: string, region?: string) => Promise<WorkspaceLoginInfo>
signUpOtp: (email: string, first: string, last: string) => Promise<OtpInfo>
@ -362,6 +364,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise<WorkspaceInfoWithStatus[]> {
const request = {
method: 'getWorkspacesInfo' as const,
params: { ids }
}
return await this.rpc(request)
}
async getWorkspaceInfo (updateLastVisit: boolean = false): Promise<WorkspaceInfoWithStatus> {
const request = {
method: 'getWorkspaceInfo' as const,

View File

@ -119,12 +119,17 @@ export const getFrequentlyEmojis = (): EmojiWithGroup[] | undefined => {
const parsedEmojis = JSON.parse(frequentlyEmojis)
if (!Array.isArray(parsedEmojis)) return undefined
return parsedEmojis
.map((pe) => {
const map = getEmoji(pe.hexcode)
return map?.parent ?? map?.emoji
})
.filter((f) => f !== undefined) as EmojiWithGroup[]
const res: EmojiWithGroup[] = []
for (const val of parsedEmojis) {
const map = getEmoji(val.hexcode)
const emoji = map?.parent ?? map?.emoji
if (emoji !== undefined) {
res.push(emoji)
}
}
return res
} catch (e) {
console.error(e)
return undefined

View File

@ -70,6 +70,7 @@
"@hcengineering/analytics-service": "^0.6.0",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/notification": "^0.6.23",
"@hcengineering/server-calendar": "^0.6.0",
"@hcengineering/server-notification": "^0.6.1",
"@hcengineering/server-telegram": "^0.6.0",
"@hcengineering/pod-telegram-bot": "^0.6.0",

View File

@ -10,6 +10,7 @@ import { MeasureMetricsContext, newMetrics, setOperationLogProfiling } from '@hc
import { setMetadata } from '@hcengineering/platform'
import { serverConfigFromEnv } from '@hcengineering/server'
import serverAiBot from '@hcengineering/server-ai-bot'
import serverCalendar from '@hcengineering/server-calendar'
import serverCore, {
type ConnectionSocket,
type Session,
@ -74,6 +75,7 @@ setMetadata(serverNotification.metadata.SesUrl, config.sesUrl ?? '')
setMetadata(serverNotification.metadata.SesAuthToken, config.sesAuthToken)
setMetadata(serverTelegram.metadata.BotUrl, process.env.TELEGRAM_BOT_URL)
setMetadata(serverAiBot.metadata.EndpointURL, process.env.AI_BOT_URL)
setMetadata(serverCalendar.metadata.EndpointURL, process.env.CALENDAR_URL)
const { shutdown, sessionManager } = start(metricsContext, config.dbUrl, {
fulltextUrl: config.fulltextUrl,

View File

@ -39,6 +39,8 @@
"dependencies": {
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/server-calendar": "^0.6.0",
"@hcengineering/server-token": "^0.6.11",
"@hcengineering/calendar": "^0.6.24",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/server-core": "^0.6.1",

View File

@ -16,6 +16,7 @@ import calendar, { Calendar, Event, ExternalCalendar } from '@hcengineering/cale
import contactPlugin, { Employee, Person, SocialIdentity, pickPrimarySocialId } from '@hcengineering/contact'
import core, {
Class,
concatLink,
Data,
Doc,
DocumentQuery,
@ -26,6 +27,7 @@ import core, {
buildSocialIdString,
parseSocialIdString,
Ref,
systemAccountUuid,
Tx,
TxCreateDoc,
TxCUD,
@ -34,10 +36,12 @@ import core, {
TxRemoveDoc,
TxUpdateDoc
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import serverCalendar from '@hcengineering/server-calendar'
import { getMetadata, getResource } from '@hcengineering/platform'
import { TriggerControl } from '@hcengineering/server-core'
import { getPerson, getSocialStrings } from '@hcengineering/server-contact'
import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources'
import { generateToken } from '@hcengineering/server-token'
/**
* @public
@ -193,6 +197,9 @@ async function onEventUpdate (ctx: TxUpdateDoc<Event>, control: TriggerControl):
if (Object.keys(otherOps).length === 0) return []
const event = (await control.findAll(control.ctx, calendar.class.Event, { _id: ctx.objectId }, { limit: 1 }))[0]
if (event === undefined) return []
if (ctx.modifiedBy !== core.account.System) {
void sendEventToService(event, 'update', control)
}
if (event.access !== 'owner') return []
const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId })
const res: Tx[] = []
@ -268,8 +275,43 @@ async function eventForNewParticipants (
return res
}
async function sendEventToService (
event: Event,
type: 'create' | 'update' | 'delete',
control: TriggerControl
): Promise<void> {
const url = getMetadata(serverCalendar.metadata.EndpointURL) ?? ''
if (url === '') {
return
}
const workspace = control.workspace.uuid
try {
await fetch(concatLink(url, '/event'), {
method: 'POST',
keepalive: true,
headers: {
Authorization: 'Bearer ' + generateToken(systemAccountUuid, workspace),
'Content-Type': 'application/json'
},
body: JSON.stringify({
event,
workspace,
type
})
})
} catch (err) {
control.ctx.error('Could not send calendar event to service', { err })
}
}
async function onEventCreate (ctx: TxCreateDoc<Event>, control: TriggerControl): Promise<Tx[]> {
const event = TxProcessor.createDoc2Doc(ctx)
if (ctx.modifiedBy !== core.account.System) {
void sendEventToService(event, 'create', control)
}
if (event.access !== 'owner') return []
const res: Tx[] = []
const { _class, space, attachedTo, attachedToClass, collection, ...attr } = event
@ -309,6 +351,9 @@ async function onRemoveEvent (ctx: TxRemoveDoc<Event>, control: TriggerControl):
const removed = control.removedMap.get(ctx.objectId) as Event
const res: Tx[] = []
if (removed !== undefined) {
if (ctx.modifiedBy !== core.account.System) {
void sendEventToService(removed, 'delete', control)
}
if (removed.access !== 'owner') return []
const current = await control.findAll(control.ctx, calendar.class.Event, { eventId: removed.eventId })
for (const cur of current) {

View File

@ -14,7 +14,7 @@
// limitations under the License.
//
import type { Plugin, Resource } from '@hcengineering/platform'
import type { Metadata, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { ObjectDDParticipantFunc, TriggerFunc } from '@hcengineering/server-core'
import { Presenter } from '@hcengineering/server-notification'
@ -28,6 +28,9 @@ export const serverCalendarId = 'server-calendar' as Plugin
* @public
*/
export default plugin(serverCalendarId, {
metadata: {
EndpointURL: '' as Metadata<string>
},
function: {
ReminderHTMLPresenter: '' as Resource<Presenter>,
ReminderTextPresenter: '' as Resource<Presenter>,

View File

@ -16,6 +16,7 @@
import { Analytics } from '@hcengineering/analytics'
import {
AccountRole,
buildSocialIdString,
concatLink,
Data,
isActiveMode,
@ -27,13 +28,12 @@ import {
type BackupStatus,
type Branding,
type Person,
type PersonUuid,
type PersonId,
type PersonInfo,
type PersonUuid,
type WorkspaceMemberInfo,
type WorkspaceMode,
type WorkspaceUuid,
type PersonId,
buildSocialIdString
type WorkspaceUuid
} from '@hcengineering/core'
import platform, {
getMetadata,
@ -45,6 +45,7 @@ import platform, {
} from '@hcengineering/platform'
import { decodeTokenVerbose, generateToken } from '@hcengineering/server-token'
import { isAdminEmail } from './admin'
import { accountPlugin } from './plugin'
import type {
AccountDB,
@ -72,6 +73,8 @@ import {
getEmailSocialId,
getEndpoint,
getFrontUrl,
getInviteEmail,
getPersonName,
getRegions,
getRolePower,
getSesUrl,
@ -84,18 +87,15 @@ import {
GUEST_ACCOUNT,
isOtpValid,
selectWorkspace,
sendEmail,
sendEmailConfirmation,
sendOtp,
setPassword,
signUpByEmail,
verifyPassword,
wrap,
verifyAllowedServices,
getPersonName,
sendEmail,
getInviteEmail
verifyPassword,
wrap
} from './utils'
import { isAdminEmail } from './admin'
// Move to config?
const processingTimeoutMs = 30 * 1000
@ -910,6 +910,35 @@ export async function getUserWorkspaces (
)
}
/**
* @public
*/
export async function getWorkspacesInfo (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
ids: WorkspaceUuid[]
): Promise<WorkspaceInfoWithStatus[]> {
const { account } = decodeTokenVerbose(ctx, token)
if (account !== systemAccountUuid) {
ctx.error('getWorkspaceInfos with wrong user', { account, token })
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const workspaces: WorkspaceInfoWithStatus[] = []
for (const id of ids) {
const ws = await getWorkspaceInfoWithStatusById(db, id)
if (ws !== null) {
workspaces.push(ws)
}
}
workspaces.sort((a, b) => (b.status.lastVisit ?? 0) - (a.status.lastVisit ?? 0))
return workspaces
}
export async function getWorkspaceInfo (
ctx: MeasureContext,
db: AccountDB,
@ -1651,6 +1680,7 @@ export type AccountMethods =
| 'getRegionInfo'
| 'getUserWorkspaces'
| 'getWorkspaceInfo'
| 'getWorkspacesInfo'
| 'listWorkspaces'
| 'getLoginInfoByToken'
| 'getSocialIds'
@ -1700,6 +1730,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
getRegionInfo: wrap(getRegionInfo),
getUserWorkspaces: wrap(getUserWorkspaces),
getWorkspaceInfo: wrap(getWorkspaceInfo),
getWorkspacesInfo: wrap(getWorkspacesInfo),
getLoginInfoByToken: wrap(getLoginInfoByToken),
getSocialIds: wrap(getSocialIds),
getPerson: wrap(getPerson),

View File

@ -362,6 +362,8 @@ export function startHttpServer (
registerRPC(app, sessions, ctx, pipelineFactory)
registerRPC(app, sessions, ctx, pipelineFactory)
app.put('/api/v1/broadcast', (req, res) => {
try {
const token = req.query.token as string

View File

@ -56,6 +56,7 @@
"@hcengineering/attachment": "^0.6.14",
"@hcengineering/calendar": "^0.6.24",
"@hcengineering/client": "^0.6.18",
"@hcengineering/account-client": "^0.6.0",
"@hcengineering/client-resources": "^0.6.27",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/core": "^0.6.32",

View File

@ -23,7 +23,6 @@ import calendar, {
} from '@hcengineering/calendar'
import { Contact } from '@hcengineering/contact'
import core, {
PersonId,
AttachedData,
Client,
Data,
@ -33,132 +32,114 @@ import core, {
Mixin,
Ref,
TxOperations,
TxUpdateDoc,
generateId,
parseSocialIdString
} from '@hcengineering/core'
import setting from '@hcengineering/setting'
import { htmlToMarkup, markupToHTML } from '@hcengineering/text'
import { deepEqual } from 'fast-equals'
import type { Credentials, OAuth2Client } from 'google-auth-library'
import { calendar_v3, google } from 'googleapis'
import { calendar_v3 } from 'googleapis'
import type { Collection, Db } from 'mongodb'
import { encode64 } from './base64'
import { CalendarController } from './calendarController'
import config from './config'
import { RateLimiter } from './rateLimiter'
import type { CalendarHistory, EventHistory, EventWatch, ProjectCredentials, State, Token, User, Watch } from './types'
import { GoogleClient } from './googleClient'
import type { CalendarHistory, DummyWatch, EventHistory, Token, User } from './types'
import { encodeReccuring, isToken, parseRecurrenceStrings } from './utils'
import { WatchController } from './watch'
import type { WorkspaceClient } from './workspaceClient'
const SCOPES = [
'https://www.googleapis.com/auth/calendar.calendars.readonly',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/userinfo.email'
]
const DUMMY_RESOURCE = 'Dummy'
export class CalendarClient {
private readonly oAuth2Client: OAuth2Client
private readonly calendar: calendar_v3.Calendar
private readonly tokens: Collection<Token>
private readonly calendarHistories: Collection<CalendarHistory>
private readonly histories: Collection<EventHistory>
private readonly client: TxOperations
private readonly watches: EventWatch[] = []
private calendarWatch: Watch | undefined = undefined
private refreshTimer: NodeJS.Timeout | undefined = undefined
private readonly systemTxOp: TxOperations
private readonly activeSync: Record<string, boolean> = {}
private readonly rateLimiter = new RateLimiter(1000, 500)
private readonly dummyWatches: DummyWatch[] = []
// to do< find!!!!
private readonly googleClient
private inactiveTimer: NodeJS.Timeout
isClosed: boolean = false
private constructor (
credentials: ProjectCredentials,
private readonly user: User,
mongo: Db,
private readonly mongo: Db,
client: Client,
private readonly workspace: WorkspaceClient
) {
const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line
this.oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) // eslint-disable-line
this.calendar = google.calendar({ version: 'v3', auth: this.oAuth2Client })
this.tokens = mongo.collection<Token>('tokens')
this.client = new TxOperations(client, this.user.userId)
this.systemTxOp = new TxOperations(client, core.account.System)
this.googleClient = new GoogleClient(user, mongo, this)
this.calendar = this.googleClient.calendar
this.histories = mongo.collection<EventHistory>('histories')
this.calendarHistories = mongo.collection<CalendarHistory>('calendarHistories')
this.client = new TxOperations(client, this.user.userId)
this.inactiveTimer = setTimeout(() => {
this.closeByTimer()
}, 60 * 1000)
}
async cleanIntegration (): Promise<void> {
const integration = await this.client.findOne(setting.class.Integration, {
createdBy: this.user.userId,
type: calendar.integrationType.Calendar,
value: this.getEmail()
})
if (integration !== undefined) {
await this.client.update(integration, { disabled: true })
}
this.workspace.removeClient(this.user.userId)
}
private updateTimer (): void {
clearTimeout(this.inactiveTimer)
this.inactiveTimer = setTimeout(() => {
this.closeByTimer()
}, 60 * 1000)
}
static async create (
credentials: ProjectCredentials,
user: User | Token,
mongo: Db,
client: Client,
workspace: WorkspaceClient
): Promise<CalendarClient> {
const calendarClient = new CalendarClient(credentials, user, mongo, client, workspace)
const calendarClient = new CalendarClient(user, mongo, client, workspace)
if (isToken(user)) {
await calendarClient.setToken(user)
await calendarClient.refreshToken()
await calendarClient.addClient()
await calendarClient.googleClient.init(user)
calendarClient.updateTimer()
}
return calendarClient
}
static getAuthUrl (redirectURL: string, workspace: string, userId: PersonId, token: string): string {
const credentials = JSON.parse(config.Credentials)
const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) // eslint-disable-line
const state: State = {
token,
redirectURL,
workspace: workspace as any, // TODO: FIXME
userId
}
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
state: encode64(JSON.stringify(state))
})
return authUrl
}
async authorize (code: string): Promise<string> {
const token = await this.oAuth2Client.getToken(code)
await this.setToken(token.tokens)
const providedScopes = token.tokens.scope?.split(' ') ?? []
for (const scope of SCOPES) {
if (providedScopes.findIndex((p) => p === scope) === -1) {
const integrations = await this.client.findAll(setting.class.Integration, {
createdBy: this.user.userId,
type: calendar.integrationType.Calendar
})
for (const integration of integrations.filter((p) => p.value === '')) {
await this.client.remove(integration)
}
const updated = integrations.find((p) => p.disabled && p.value === this.user.userId)
if (updated !== undefined) {
await this.client.update(updated, {
disabled: true,
error: calendar.string.NotAllPermissions
})
} else {
await this.client.createDoc(setting.class.Integration, core.space.Workspace, {
type: calendar.integrationType.Calendar,
disabled: true,
error: calendar.string.NotAllPermissions,
value: this.user.userId
})
}
throw new Error(
`Not all scopes provided, provided: ${providedScopes.join(', ')} required: ${SCOPES.join(', ')}`
)
this.updateTimer()
const me = await this.googleClient.authorize(code)
if (me === undefined) {
const integrations = await this.client.findAll(setting.class.Integration, {
createdBy: this.user.userId,
type: calendar.integrationType.Calendar
})
for (const integration of integrations.filter((p) => p.value === '')) {
await this.client.remove(integration)
}
const updated = integrations.find((p) => p.disabled && p.value === me)
if (updated !== undefined) {
await this.client.update(updated, {
disabled: true,
error: calendar.string.NotAllPermissions
})
} else {
const value = await this.googleClient.getMe()
await this.client.createDoc(setting.class.Integration, core.space.Workspace, {
type: calendar.integrationType.Calendar,
disabled: true,
error: calendar.string.NotAllPermissions,
value
})
}
throw new Error('Not all scopes provided')
}
await this.refreshToken()
await this.addClient()
this.updateTimer()
const integrations = await this.client.findAll(setting.class.Integration, {
createdBy: this.user.userId,
@ -183,32 +164,31 @@ export class CalendarClient {
})
}
await this.startSync()
void this.syncOurEvents()
void this.syncOurEvents().then(async () => {
await this.startSync()
})
return this.user.userId
}
async signout (byError: boolean = false): Promise<void> {
async signout (): Promise<void> {
this.updateTimer()
try {
await this.close()
await this.oAuth2Client.revokeCredentials()
this.close()
if (isToken(this.user)) {
const watch = WatchController.get(this.mongo)
await watch.unsubscribe(this.user)
}
await this.googleClient.signout()
} catch {}
await this.tokens.deleteOne({
userId: this.user.userId,
workspace: this.user.workspace
})
const integration = await this.client.findOne(setting.class.Integration, {
createdBy: this.user.userId,
type: calendar.integrationType.Calendar,
value: this.user.userId
})
if (integration !== undefined) {
if (byError) {
await this.client.update(integration, { disabled: true })
} else {
await this.client.remove(integration)
}
await this.client.remove(integration)
}
this.workspace.removeClient(this.user.userId)
}
@ -219,181 +199,37 @@ export class CalendarClient {
const calendars = this.workspace.getMyCalendars(this.user.userId)
for (const calendar of calendars) {
if (calendar.externalId !== undefined) {
void this.sync(calendar.externalId)
await this.sync(calendar.externalId)
}
}
} catch (err) {
console.log('Start sync error', this.user.workspace, this.user.userId, err)
console.error('Start sync error', this.user.workspace, this.user.userId, err)
}
}
async startSyncCalendar (calendar: ExternalCalendar): Promise<void> {
void this.sync(calendar.externalId)
await this.sync(calendar.externalId)
}
async close (): Promise<void> {
if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer)
for (const watch of this.watches) {
private closeByTimer (): void {
this.close()
this.workspace.removeClient(this.user.userId)
}
close (): void {
this.googleClient.close()
for (const watch of this.dummyWatches) {
clearTimeout(watch.timer)
try {
if (watch.resourceId !== DUMMY_RESOURCE) {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({ requestBody: { id: watch.channelId, resourceId: watch.resourceId } })
}
} catch (err) {
console.log('close error', err)
}
}
if (this.calendarWatch !== undefined) {
clearTimeout(this.calendarWatch.timer)
try {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({
requestBody: { id: this.calendarWatch.channelId, resourceId: this.calendarWatch.resourceId }
})
} catch (err) {
console.log('close error', err)
}
}
this.isClosed = true
}
// TODO: Should not be needed anymore.
// this.user.userId should always be a google social id like "google:john.appleseed@gmail.com"
// and the value part should be the same as what is returned by getMe()
// private async getMe (): Promise<PersonId> {
// if (this.me !== undefined) {
// return this.me
// }
// const info = await google.oauth2({ version: 'v2', auth: this.oAuth2Client }).userinfo.get()
// const email = info.data.email ?? ''
// this.me = email !== '' ? buildSocialIdString({ type: SocialIdType.GOOGLE, value: email }) : ''
// return this.me
// }
// #region Token
private async getCurrentToken (): Promise<Token | null> {
return await this.tokens.findOne({
userId: this.user.userId,
workspace: this.user.workspace
})
}
private async updateCurrentToken (token: Credentials): Promise<void> {
await this.tokens.updateOne(
{
userId: this.user.userId,
workspace: this.user.workspace
},
{
$set: {
...token
}
}
)
}
private async addClient (): Promise<void> {
try {
const controller = CalendarController.getCalendarController()
controller.addClient(this.user.userId, this)
} catch (err) {
console.log('Add client error', this.user.workspace, this.user.userId, err)
}
}
private async setToken (token: Credentials): Promise<void> {
try {
this.oAuth2Client.setCredentials(token)
} catch (err: any) {
console.log('Set token error', this.user.workspace, this.user.userId, err)
await this.checkError(err)
throw err
}
}
private async updateToken (token: Credentials): Promise<void> {
try {
const currentToken = await this.getCurrentToken()
if (currentToken != null) {
await this.updateCurrentToken(token)
} else {
await this.tokens.insertOne({
userId: this.user.userId,
workspace: this.user.workspace,
token: this.user.token,
...token
})
}
} catch (err) {
console.log('update token error', this.user.workspace, this.user.userId, err)
}
}
private async refreshToken (): Promise<void> {
try {
const res = await this.oAuth2Client.refreshAccessToken()
await this.updateToken(res.credentials)
this.refreshTimer = setTimeout(
() => {
void this.refreshToken()
},
30 * 60 * 1000
)
} catch (err: any) {
console.log("Couldn't refresh token, error:", err)
if (err?.response?.data?.error === 'invalid_grant' || err.message === 'No refresh token is set.') {
await this.signout(true)
} else {
this.refreshTimer = setTimeout(
() => {
void this.refreshToken()
},
15 * 60 * 1000
)
}
throw err
}
}
// #endregion
// #region Calendars
private async watchCalendar (): Promise<void> {
try {
const current = this.calendarWatch
if (current !== undefined) {
clearTimeout(current.timer)
await this.rateLimiter.take(1)
await this.calendar.channels.stop({ requestBody: { id: current.channelId, resourceId: current.resourceId } })
}
const channelId = generateId()
const email = this.getEmail()
const body = { id: channelId, address: config.WATCH_URL, type: 'webhook', token: `user=${email}&mode=calendar` }
await this.rateLimiter.take(1)
const res = await this.calendar.calendarList.watch({ requestBody: body })
if (res.data.expiration != null && res.data.resourceId !== null) {
const time = Number(res.data.expiration) - new Date().getTime()
// eslint-disable-next-line
const timer = setTimeout(() => void this.watchCalendar(), time)
this.calendarWatch = {
channelId,
resourceId: res.data.resourceId ?? '',
timer
}
}
} catch (err) {
console.log('Calendar watch error', err)
}
}
async syncCalendars (): Promise<void> {
const history = await this.getCalendarHistory()
await this.calendarSync(history?.historyId)
await this.watchCalendar()
await this.googleClient.watchCalendar()
}
private getEmail (): string {
@ -402,8 +238,9 @@ export class CalendarClient {
private async calendarSync (syncToken?: string, pageToken?: string): Promise<void> {
try {
await this.rateLimiter.take(1)
const res = await this.calendar.calendarList.list({
this.updateTimer()
await this.googleClient.rateLimiter.take(1)
const res = await this.googleClient.calendar.calendarList.list({
syncToken,
pageToken
})
@ -416,7 +253,7 @@ export class CalendarClient {
try {
await this.syncCalendar(calendar)
} catch (err) {
console.log('save calendar error', JSON.stringify(event), err)
console.error('save calendar error', JSON.stringify(calendar), err)
}
}
if (nextPageToken != null) {
@ -430,7 +267,7 @@ export class CalendarClient {
await this.calendarSync()
return
}
console.log('Calendar sync error', this.user.workspace, this.user.userId, err)
console.error('Calendar sync error', this.user.workspace, this.user.userId, err)
}
}
@ -502,78 +339,12 @@ export class CalendarClient {
// #region Events
// #region Incoming
async stopWatch (calendar: ExternalCalendar): Promise<void> {
for (const watch of this.watches) {
if (watch.calendarId === calendar.externalId) {
clearTimeout(watch.timer)
try {
if (watch.resourceId !== DUMMY_RESOURCE) {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({ requestBody: { id: watch.channelId, resourceId: watch.resourceId } })
}
} catch (err) {
console.log('close error', err)
}
}
}
}
private async watch (calendarId: string): Promise<void> {
try {
const index = this.watches.findIndex((p) => p.calendarId === calendarId)
if (index !== -1) {
const current = this.watches[index]
if (current !== undefined) {
clearTimeout(current.timer)
if (current.resourceId !== DUMMY_RESOURCE) {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({
requestBody: { id: current.channelId, resourceId: current.resourceId }
})
}
}
this.watches.splice(index, 1)
}
const channelId = generateId()
const email = this.getEmail()
const body = {
id: channelId,
address: config.WATCH_URL,
type: 'webhook',
token: `user=${email}&mode=events&calendarId=${calendarId}`
}
await this.rateLimiter.take(1)
const res = await this.calendar.events.watch({ calendarId, requestBody: body })
if (res.data.expiration != null && res.data.resourceId != null) {
const time = Number(res.data.expiration) - new Date().getTime()
// eslint-disable-next-line
const timer = setTimeout(() => void this.watch(calendarId), time)
this.watches.push({
calendarId,
channelId,
resourceId: res.data.resourceId ?? '',
timer
})
}
} catch (err: any) {
if (err?.errors?.[0]?.reason === 'pushNotSupportedForRequestedResource') {
await this.dummyWatch(calendarId)
} else {
console.log('Watch error', err)
await this.checkError(err)
}
if (!(await this.googleClient.watch(calendarId))) {
await this.dummyWatch(calendarId)
}
}
private async checkError (err: any): Promise<boolean> {
if (err?.response?.data?.error === 'invalid_grant') {
await this.signout(true)
return true
}
return false
}
private async dummyWatch (calendarId: string): Promise<void> {
const timer = setTimeout(
() => {
@ -581,10 +352,8 @@ export class CalendarClient {
},
6 * 60 * 60 * 1000
)
this.watches.push({
this.dummyWatches.push({
calendarId,
channelId: DUMMY_RESOURCE,
resourceId: DUMMY_RESOURCE,
timer
})
}
@ -629,7 +398,7 @@ export class CalendarClient {
private async eventsSync (calendarId: string, syncToken?: string, pageToken?: string): Promise<void> {
try {
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
const res = await this.calendar.events.list({
calendarId,
syncToken,
@ -645,7 +414,7 @@ export class CalendarClient {
try {
await this.syncEvent(calendarId, event, res.data.accessRole ?? 'reader')
} catch (err) {
console.log('save event error', JSON.stringify(event), err)
console.error('save event error', JSON.stringify(event), err)
}
}
if (nextPageToken != null) {
@ -659,12 +428,13 @@ export class CalendarClient {
await this.eventsSync(calendarId)
return
}
await this.checkError(err)
console.log('Event sync error', this.user.workspace, this.user.userId, err)
await this.googleClient.checkError(err)
console.error('Event sync error', this.user.workspace, this.user.userId, err)
}
}
private async syncEvent (calendarId: string, event: calendar_v3.Schema$Event, accessRole: string): Promise<void> {
this.updateTimer()
if (event.id != null) {
const calendars = this.workspace.getMyCalendars(this.user.userId)
const _calendar =
@ -686,6 +456,7 @@ export class CalendarClient {
}
private async updateExtEvent (event: calendar_v3.Schema$Event, current: Event): Promise<void> {
this.updateTimer()
if (event.status === 'cancelled' && current._class !== calendar.class.ReccuringInstance) {
await this.client.remove(current)
return
@ -738,7 +509,7 @@ export class CalendarClient {
if (this.client.getHierarchy().hasMixin(current, mixin as Ref<Mixin<Doc>>)) {
const diff = this.getDiff(attr, this.client.getHierarchy().as(current, mixin as Ref<Mixin<Doc>>))
if (Object.keys(diff).length > 0) {
await this.client.updateMixin(
await this.systemTxOp.updateMixin(
current._id,
current._class,
calendar.space.Calendar,
@ -747,7 +518,7 @@ export class CalendarClient {
)
}
} else {
await this.client.createMixin(
await this.systemTxOp.createMixin(
current._id,
current._class,
calendar.space.Calendar,
@ -773,7 +544,7 @@ export class CalendarClient {
for (const mixin in mixins) {
const attr = mixins[mixin]
if (typeof attr === 'object' && Object.keys(attr).length > 0) {
await this.client.createMixin(
await this.systemTxOp.createMixin(
_id,
calendar.class.Event,
calendar.space.Calendar,
@ -790,10 +561,11 @@ export class CalendarClient {
accessRole: string,
_calendar: ExternalCalendar
): Promise<void> {
this.updateTimer()
const data: AttachedData<Event> = await this.parseData(event, accessRole, _calendar._id)
if (event.recurringEventId != null) {
const parseRule = parseRecurrenceStrings(event.recurrence ?? [])
const id = await this.client.addCollection(
const id = await this.systemTxOp.addCollection(
calendar.class.ReccuringInstance,
calendar.space.Calendar,
calendar.ids.NoAttached,
@ -814,7 +586,7 @@ export class CalendarClient {
} else if (event.status !== 'cancelled') {
if (event.recurrence != null) {
const parseRule = parseRecurrenceStrings(event.recurrence)
const id = await this.client.addCollection(
const id = await this.systemTxOp.addCollection(
calendar.class.ReccuringEvent,
calendar.space.Calendar,
calendar.ids.NoAttached,
@ -831,7 +603,7 @@ export class CalendarClient {
)
await this.saveMixins(event, id)
} else {
const id = await this.client.addCollection(
const id = await this.systemTxOp.addCollection(
calendar.class.Event,
calendar.space.Calendar,
calendar.ids.NoAttached,
@ -995,12 +767,14 @@ export class CalendarClient {
}
private async createRecInstance (calendarId: string, event: ReccuringInstance): Promise<void> {
const body = this.convertBody(event)
this.updateTimer()
const me = await this.googleClient.getMe()
const body = this.convertBody(event, me)
const req: calendar_v3.Params$Resource$Events$Instances = {
calendarId,
eventId: event.recurringEventId
}
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
const instancesResp = await this.calendar.events.instances(req)
const items = instancesResp.data.items
const target = items?.find(
@ -1011,7 +785,7 @@ export class CalendarClient {
)
if (target?.id != null) {
body.id = target.id
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
await this.calendar.events.update({
calendarId,
eventId: target.id,
@ -1022,14 +796,15 @@ export class CalendarClient {
}
async createEvent (event: Event): Promise<void> {
const me = await this.googleClient.getMe()
try {
const _calendar = this.workspace.calendars.byId.get(event.calendar as Ref<ExternalCalendar>)
if (_calendar !== undefined) {
if (event._class === calendar.class.ReccuringInstance) {
await this.createRecInstance(_calendar.externalId, event as ReccuringInstance)
} else {
const body = this.convertBody(event)
await this.rateLimiter.take(1)
const body = this.convertBody(event, me)
await this.googleClient.rateLimiter.take(1)
await this.calendar.events.insert({
calendarId: _calendar.externalId,
requestBody: body
@ -1037,23 +812,24 @@ export class CalendarClient {
}
}
} catch (err: any) {
await this.checkError(err)
await this.googleClient.checkError(err)
// eslint-disable-next-line
throw new Error(`Create event error, ${this.user.workspace}, ${this.user.userId}, ${event._id}, ${err?.message}`)
}
}
async updateEvent (event: Event, tx: TxUpdateDoc<Event>): Promise<void> {
async updateEvent (event: Event): Promise<void> {
const me = await this.googleClient.getMe()
const _calendar = this.workspace.calendars.byId.get(event.calendar as Ref<ExternalCalendar>)
const calendarId = _calendar?.externalId
if (calendarId !== undefined) {
try {
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
const current = await this.calendar.events.get({ calendarId, eventId: event.eventId })
if (current?.data !== undefined) {
if (current.data.organizer?.self === true) {
const ev = this.applyUpdate(current.data, event)
await this.rateLimiter.take(1)
const ev = this.applyUpdate(current.data, event, me)
await this.googleClient.rateLimiter.take(1)
await this.calendar.events.update({
calendarId,
eventId: event.eventId,
@ -1065,18 +841,19 @@ export class CalendarClient {
if (err.code === 404) {
await this.createEvent(event)
} else {
console.log('Update event error', this.user.workspace, this.user.userId, err)
await this.checkError(err)
console.error('Update event error', this.user.workspace, this.user.userId, err)
await this.googleClient.checkError(err)
}
}
}
}
async remove (eventId: string, calendarId: string): Promise<void> {
this.updateTimer()
const current = await this.calendar.events.get({ calendarId, eventId })
if (current?.data !== undefined) {
if (current.data.organizer?.self === true) {
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
await this.calendar.events.delete({
eventId,
calendarId
@ -1092,11 +869,12 @@ export class CalendarClient {
await this.remove(event.eventId, _calendar.externalId)
}
} catch (err) {
console.log('Remove event error', this.user.workspace, this.user.userId, err)
console.error('Remove event error', this.user.workspace, this.user.userId, err)
}
}
async syncOurEvents (): Promise<void> {
this.updateTimer()
const events = await this.client.findAll(calendar.class.Event, {
access: 'owner',
createdBy: this.user.userId,
@ -1114,21 +892,24 @@ export class CalendarClient {
const space = this.workspace.calendars.byId.get(event.calendar as Ref<ExternalCalendar>)
const email = this.getEmail()
if (space !== undefined && space.externalUser === email) {
this.updateTimer()
if (!(await this.update(event, space))) {
await this.create(event, space)
}
}
} catch (err: any) {
console.log('Sync event error', this.user.workspace, this.user.userId, event._id, err.message)
console.error('Sync event error', this.user.workspace, this.user.userId, event._id, err.message)
}
}
}
private async create (event: Event, space: ExternalCalendar): Promise<void> {
const body = this.convertBody(event)
this.updateTimer()
const me = await this.googleClient.getMe()
const body = this.convertBody(event, me)
const calendarId = space?.externalId
if (calendarId !== undefined) {
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
await this.calendar.events.insert({
calendarId,
requestBody: body
@ -1137,14 +918,16 @@ export class CalendarClient {
}
private async update (event: Event, space: ExternalCalendar): Promise<boolean> {
this.updateTimer()
const me = await this.googleClient.getMe()
const calendarId = space?.externalId
if (calendarId !== undefined) {
try {
await this.rateLimiter.take(1)
await this.googleClient.rateLimiter.take(1)
const current = await this.calendar.events.get({ calendarId, eventId: event.eventId })
if (current !== undefined) {
const ev = this.applyUpdate(current.data, event)
await this.rateLimiter.take(1)
const ev = this.applyUpdate(current.data, event, me)
await this.googleClient.rateLimiter.take(1)
await this.calendar.events.update({
calendarId,
eventId: event.eventId,
@ -1181,7 +964,7 @@ export class CalendarClient {
return res
}
private convertBody (event: Event): calendar_v3.Schema$Event {
private convertBody (event: Event, me: string): calendar_v3.Schema$Event {
const res: calendar_v3.Schema$Event = {
start: convertDate(event.date, event.allDay, getTimezone(event)),
end: convertDate(event.dueDate, event.allDay, getTimezone(event)),
@ -1220,7 +1003,7 @@ export class CalendarClient {
})
}
}
const attendees = this.getAttendees(event)
const attendees = this.getAttendees(event, me)
if (attendees.length > 0) {
const email = this.getEmail()
res.attendees = attendees.map((p) => {
@ -1243,7 +1026,7 @@ export class CalendarClient {
return res
}
private applyUpdate (event: calendar_v3.Schema$Event, current: Event): calendar_v3.Schema$Event {
private applyUpdate (event: calendar_v3.Schema$Event, current: Event, me: string): calendar_v3.Schema$Event {
if (current.title !== event.summary) {
event.summary = current.title
}
@ -1268,7 +1051,7 @@ export class CalendarClient {
if (current.location !== event.location) {
event.location = current.location
}
const attendees = this.getAttendees(current)
const attendees = this.getAttendees(current, me)
if (attendees.length > 0 && event.attendees !== undefined) {
for (const attendee of attendees) {
if (event.attendees.findIndex((p) => p.email === attendee) === -1) {
@ -1285,7 +1068,7 @@ export class CalendarClient {
return event
}
private getAttendees (event: Event): string[] {
private getAttendees (event: Event, me: string): string[] {
const res = new Set<string>()
const email = this.getEmail()
for (const participant of event.participants) {

View File

@ -13,27 +13,43 @@
// limitations under the License.
//
import { PersonId, isActiveMode, RateLimiter, systemAccountUuid, WorkspaceUuid, PersonUuid } from '@hcengineering/core'
import { type Db } from 'mongodb'
import { Event } from '@hcengineering/calendar'
import {
PersonId,
PersonUuid,
RateLimiter,
WorkspaceUuid,
isActiveMode,
isDeletingMode,
parseSocialIdString,
systemAccountUuid
} from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import { Collection, type Db } from 'mongodb'
import { type CalendarClient } from './calendar'
import config from './config'
import { type ProjectCredentials, type Token, type User } from './types'
import { type Token, type User } from './types'
import { WorkspaceClient } from './workspaceClient'
import { getAccountClient } from '@hcengineering/server-client'
import { generateToken } from '@hcengineering/server-token'
export class CalendarController {
private readonly workspaces: Map<WorkspaceUuid, WorkspaceClient> = new Map<WorkspaceUuid, WorkspaceClient>()
private readonly workspaces: Map<WorkspaceUuid, WorkspaceClient | Promise<WorkspaceClient>> = new Map<
WorkspaceUuid,
WorkspaceClient | Promise<WorkspaceClient>
>()
private readonly credentials: ProjectCredentials
private readonly clients: Map<PersonId, CalendarClient[]> = new Map<PersonId, CalendarClient[]>()
private readonly initLimitter = new RateLimiter(config.InitLimit)
private readonly tokens: Collection<Token>
protected static _instance: CalendarController
private constructor (private readonly mongo: Db) {
this.credentials = JSON.parse(config.Credentials)
this.tokens = mongo.collection<Token>('tokens')
CalendarController._instance = this
setInterval(() => {
if (this.workspaces.size > 0) {
console.log('active workspaces', this.workspaces.size)
}
}, 60000)
}
static getCalendarController (mongo?: Db): CalendarController {
@ -45,7 +61,7 @@ export class CalendarController {
}
async startAll (): Promise<void> {
const tokens = await this.mongo.collection<Token>('tokens').find().toArray()
const tokens = await this.tokens.find().toArray()
const groups = new Map<WorkspaceUuid, Token[]>()
console.log('start calendar service', tokens.length)
for (const token of tokens) {
@ -59,89 +75,85 @@ export class CalendarController {
}
const limiter = new RateLimiter(config.InitLimit)
for (const [workspace, tokens] of groups) {
const token = generateToken(systemAccountUuid)
const ids = [...groups.keys()]
console.log('start workspaces', ids)
const infos = await getAccountClient(token).getWorkspacesInfo(ids)
console.log('infos', infos)
for (const info of infos) {
const tokens = groups.get(info.uuid)
if (tokens === undefined) {
console.log('no tokens for workspace', info.uuid)
continue
}
if (isDeletingMode(info.mode)) {
if (tokens !== undefined) {
for (const token of tokens) {
await this.tokens.deleteOne({ userId: token.userId, workspace: token.workspace })
}
}
continue
}
if (!isActiveMode(info.mode)) {
continue
}
await limiter.add(async () => {
const wstok = generateToken(systemAccountUuid, workspace, { service: 'calendar' })
const accountClient = getAccountClient(wstok)
const info = await accountClient.getWorkspaceInfo()
if (info === undefined) {
console.log('workspace not found', workspace)
return
}
if (!isActiveMode(info.mode)) {
console.log('workspace is not active', workspace)
return
}
const startPromise = this.startWorkspace(workspace, tokens)
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 60000)
})
await Promise.race([startPromise, timeoutPromise])
console.log('start workspace', info.uuid)
const workspace = await this.startWorkspace(info.uuid, tokens)
await workspace.sync()
})
}
await limiter.waitProcessing()
console.log('Calendar service started')
}
async startWorkspace (workspace: WorkspaceUuid, tokens: Token[]): Promise<void> {
async startWorkspace (workspace: WorkspaceUuid, tokens: Token[]): Promise<WorkspaceClient> {
const workspaceClient = await this.getWorkspaceClient(workspace)
const clients: CalendarClient[] = []
for (const token of tokens) {
try {
const timeout = setTimeout(() => {
console.log('init client hang', token.workspace, token.userId)
console.warn('init client hang', token.workspace, token.userId)
}, 60000)
const client = await workspaceClient.createCalendarClient(token)
console.log('init client', token.workspace, token.userId)
await workspaceClient.createCalendarClient(token)
clearTimeout(timeout)
clients.push(client)
} catch (err) {
console.error(`Couldn't create client for ${workspace} ${token.userId}`)
}
}
for (const client of clients) {
void this.initLimitter.add(async () => {
await client.startSync()
})
}
void workspaceClient.sync()
console.log('Workspace started', workspace)
return workspaceClient
}
push (personId: PersonId, mode: 'events' | 'calendar', calendarId?: string): void {
const clients = this.clients.get(personId)
for (const client of clients ?? []) {
async push (personId: PersonId, mode: 'events' | 'calendar', calendarId?: string): Promise<void> {
const email = parseSocialIdString(personId).value
const tokens = await this.tokens.find({ email, access_token: { $exists: true } }).toArray()
const token = generateToken(systemAccountUuid)
const workspaces = [...new Set(tokens.map((p) => p.workspace))]
const infos = await getAccountClient(token).getWorkspacesInfo(workspaces)
for (const token of tokens) {
const info = infos.find((p) => p.uuid === token.workspace)
if (info === undefined) {
continue
}
if (isDeletingMode(info.mode)) {
await this.tokens.deleteOne({ userId: token.userId, workspace: token.workspace })
continue
}
if (!isActiveMode(info.mode)) {
continue
}
const workspace = await this.getWorkspaceClient(token.workspace)
const calendarClient = await workspace.createCalendarClient(token)
if (mode === 'calendar') {
void client.syncCalendars()
await calendarClient.syncCalendars()
}
if (mode === 'events' && calendarId !== undefined) {
void client.sync(calendarId)
await calendarClient.sync(calendarId)
}
}
}
addClient (personId: PersonId, client: CalendarClient): void {
const clients = this.clients.get(personId)
if (clients === undefined) {
this.clients.set(personId, [client])
} else {
clients.push(client)
this.clients.set(personId, clients)
}
}
removeClient (personId: PersonId): void {
const clients = this.clients.get(personId)
if (clients !== undefined) {
this.clients.set(
personId,
clients.filter((p) => !p.isClosed)
)
}
async pushEvent (workspace: WorkspaceUuid, event: Event, type: 'create' | 'update' | 'delete'): Promise<void> {
const workspaceController = await this.getWorkspaceClient(workspace)
await workspaceController.pushEvent(event, type)
}
async getUserId (account: PersonUuid, workspace: WorkspaceUuid): Promise<PersonId> {
@ -154,7 +166,7 @@ export class CalendarController {
const workspaceClient = await this.getWorkspaceClient(workspace)
const clients = await workspaceClient.signout(value)
if (clients === 0) {
this.workspaces.delete(workspace)
this.removeWorkspace(workspace)
}
}
@ -163,7 +175,10 @@ export class CalendarController {
}
async close (): Promise<void> {
for (const workspace of this.workspaces.values()) {
for (let workspace of this.workspaces.values()) {
if (workspace instanceof Promise) {
workspace = await workspace
}
await workspace.close()
}
this.workspaces.clear()
@ -182,16 +197,22 @@ export class CalendarController {
}
private async getWorkspaceClient (workspace: WorkspaceUuid): Promise<WorkspaceClient> {
let res = this.workspaces.get(workspace)
if (res === undefined) {
try {
res = await WorkspaceClient.create(this.credentials, this.mongo, workspace, this)
this.workspaces.set(workspace, res)
} catch (err) {
console.error(`Couldn't create workspace worker for ${workspace}, reason: ${JSON.stringify(err)}`)
throw err
const res = this.workspaces.get(workspace)
if (res !== undefined) {
if (res instanceof Promise) {
return await res
}
return res
}
try {
const client = WorkspaceClient.create(this.mongo, workspace, this)
this.workspaces.set(workspace, client)
const res = await client
this.workspaces.set(workspace, res)
return res
} catch (err) {
console.error(`Couldn't create workspace worker for ${workspace}, reason: ${JSON.stringify(err)}`)
throw err
}
return res
}
}

View File

@ -0,0 +1,309 @@
//
// Copyright © 2025 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 { generateId, PersonId } from '@hcengineering/core'
import type { Credentials, OAuth2Client } from 'google-auth-library'
import { calendar_v3, google } from 'googleapis'
import { Collection, Db } from 'mongodb'
import { encode64 } from './base64'
import { CalendarClient } from './calendar'
import config from './config'
import { RateLimiter } from './rateLimiter'
import { ProjectCredentials, State, Token, User, Watch, WatchBase } from './types'
export const DUMMY_RESOURCE = 'Dummy'
const SCOPES = [
'https://www.googleapis.com/auth/calendar.calendars.readonly',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/userinfo.email'
]
export class GoogleClient {
private me: string | undefined = undefined
private readonly credentials: ProjectCredentials
private readonly oAuth2Client: OAuth2Client
readonly calendar: calendar_v3.Calendar
private readonly tokens: Collection<Token>
private readonly watches: Collection<Watch>
private refreshTimer: NodeJS.Timeout | undefined = undefined
readonly rateLimiter = new RateLimiter(1000, 500)
constructor (
private readonly user: User,
mongo: Db,
private readonly calendarClient: CalendarClient
) {
this.tokens = mongo.collection<Token>('tokens')
this.credentials = JSON.parse(config.Credentials)
const { client_secret, client_id, redirect_uris } = this.credentials.web // eslint-disable-line
this.oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) // eslint-disable-line
this.calendar = google.calendar({ version: 'v3', auth: this.oAuth2Client })
this.watches = mongo.collection<WatchBase>('watch')
}
static getAuthUrl (redirectURL: string, workspace: string, userId: PersonId, token: string): string {
const credentials = JSON.parse(config.Credentials)
const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) // eslint-disable-line
const state: State = {
token,
redirectURL,
workspace: workspace as any, // TODO: FIXME
userId
}
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
state: encode64(JSON.stringify(state))
})
return authUrl
}
async signout (): Promise<void> {
// get watch controller and unsubscibe
await this.oAuth2Client.revokeCredentials()
await this.tokens.deleteOne({
userId: this.user.userId,
workspace: this.user.workspace
})
}
async init (token: Token): Promise<void> {
await this.setToken(token)
await this.refreshToken()
}
async authorize (code: string): Promise<string | undefined> {
const token = await this.oAuth2Client.getToken(code)
await this.setToken(token.tokens)
const me = await this.getMe()
const providedScopes = token.tokens.scope?.split(' ') ?? []
for (const scope of SCOPES) {
if (providedScopes.findIndex((p) => p === scope) === -1) {
console.error(`Not all scopes provided, provided: ${providedScopes.join(', ')} required: ${SCOPES.join(', ')}`)
return undefined
}
}
await this.refreshToken()
return me
}
close (): void {
if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer)
}
async getMe (): Promise<string> {
if (this.me !== undefined) {
return this.me
}
const info = await google.oauth2({ version: 'v2', auth: this.oAuth2Client }).userinfo.get()
this.me = info.data.email ?? ''
return this.me
}
private async setToken (token: Credentials): Promise<void> {
try {
this.oAuth2Client.setCredentials(token)
} catch (err: any) {
console.error('Set token error', this.user.workspace, this.user.userId, err)
await this.checkError(err)
throw err
}
}
async checkError (err: any): Promise<boolean> {
if (err?.response?.data?.error === 'invalid_grant') {
await this.calendarClient.cleanIntegration()
return true
}
return false
}
private async updateToken (token: Credentials): Promise<void> {
try {
const currentToken = await this.getCurrentToken()
if (currentToken != null) {
await this.updateCurrentToken(token)
} else {
await this.tokens.insertOne({
userId: this.user.userId,
workspace: this.user.workspace,
token: this.user.token,
...token
})
}
} catch (err) {
console.error('update token error', this.user.workspace, this.user.userId, err)
}
}
private async refreshToken (): Promise<void> {
try {
const res = await this.oAuth2Client.refreshAccessToken()
await this.updateToken(res.credentials)
this.refreshTimer = setTimeout(
() => {
void this.refreshToken()
},
30 * 60 * 1000
)
} catch (err: any) {
console.error("Couldn't refresh token, error:", err)
if (err?.response?.data?.error === 'invalid_grant' || err.message === 'No refresh token is set.') {
await this.calendarClient.cleanIntegration()
} else {
this.refreshTimer = setTimeout(
() => {
void this.refreshToken()
},
15 * 60 * 1000
)
}
throw err
}
}
private async getCurrentToken (): Promise<Token | null> {
return await this.tokens.findOne({
userId: this.user.userId,
workspace: this.user.workspace
})
}
private async updateCurrentToken (token: Credentials): Promise<void> {
await this.tokens.updateOne(
{
userId: this.user.userId,
workspace: this.user.workspace
},
{
$set: {
...token
}
}
)
}
async watchCalendar (): Promise<void> {
try {
const current = await this.watches.findOne({
userId: this.user.userId,
workspace: this.user.workspace,
calendarId: null
})
if (current != null) {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({ requestBody: { id: current.channelId, resourceId: current.resourceId } })
}
const channelId = generateId()
const me = await this.getMe()
const body = { id: channelId, address: config.WATCH_URL, type: 'webhook', token: `user=${me}&mode=calendar` }
await this.rateLimiter.take(1)
const res = await this.calendar.calendarList.watch({ requestBody: body })
if (res.data.expiration != null && res.data.resourceId !== null) {
if (current != null) {
await this.watches.updateOne(
{
userId: this.user.userId,
workspace: this.user.workspace,
calendarId: null
},
{
channelId,
expired: Number.parseInt(res.data.expiration),
resourceId: res.data.resourceId ?? ''
}
)
} else {
await this.watches.insertOne({
calendarId: null,
channelId,
expired: Number.parseInt(res.data.expiration),
resourceId: res.data.resourceId ?? '',
userId: this.user.userId,
workspace: this.user.workspace
})
}
}
} catch (err) {
console.error('Calendar watch error', err)
}
}
async watch (calendarId: string): Promise<boolean> {
try {
const current = await this.watches.findOne({
userId: this.user.userId,
workspace: this.user.workspace,
calendarId
})
if (current != null) {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({
requestBody: { id: current.channelId, resourceId: current.resourceId }
})
}
const channelId = generateId()
const me = await this.getMe()
const body = {
id: channelId,
address: config.WATCH_URL,
type: 'webhook',
token: `user=${me}&mode=events&calendarId=${calendarId}`
}
await this.rateLimiter.take(1)
const res = await this.calendar.events.watch({ calendarId, requestBody: body })
if (res.data.expiration != null && res.data.resourceId != null) {
if (current != null) {
await this.watches.updateOne(
{
userId: this.user.userId,
workspace: this.user.workspace,
calendarId
},
{
channelId,
expired: Number.parseInt(res.data.expiration),
resourceId: res.data.resourceId ?? ''
}
)
} else {
await this.watches.insertOne({
calendarId,
channelId,
expired: Number.parseInt(res.data.expiration),
resourceId: res.data.resourceId ?? '',
userId: this.user.userId,
workspace: this.user.workspace
})
}
}
return true
} catch (err: any) {
if (err?.errors?.[0]?.reason === 'pushNotSupportedForRequestedResource') {
return false
} else {
console.error('Watch error', err)
await this.checkError(err)
return false
}
}
}
}

View File

@ -15,7 +15,6 @@
import { type IncomingHttpHeaders } from 'http'
import { decode64 } from './base64'
import { CalendarClient } from './calendar'
import { CalendarController } from './calendarController'
import config from './config'
import { createServer, listen } from './server'
@ -24,6 +23,8 @@ import { type Endpoint, type State } from './types'
import { setMetadata } from '@hcengineering/platform'
import serverClient from '@hcengineering/server-client'
import serverToken, { decodeToken } from '@hcengineering/server-token'
import { GoogleClient } from './googleClient'
import { WatchController } from './watch'
const extractToken = (header: IncomingHttpHeaders): any => {
try {
@ -41,6 +42,8 @@ export const main = async (): Promise<void> => {
const db = await getDB()
const calendarController = CalendarController.getCalendarController(db)
await calendarController.startAll()
const watchController = WatchController.get(db)
watchController.startCheck()
const endpoints: Endpoint[] = [
{
endpoint: '/signin',
@ -57,10 +60,10 @@ export const main = async (): Promise<void> => {
const { account, workspace } = decodeToken(token)
const userId = await calendarController.getUserId(account, workspace)
const url = CalendarClient.getAuthUrl(redirectURL, workspace, userId, token)
const url = GoogleClient.getAuthUrl(redirectURL, workspace, userId, token)
res.send(url)
} catch (err) {
console.log('signin error', err)
console.error('signin error', err)
res.status(500).send()
}
}
@ -75,7 +78,7 @@ export const main = async (): Promise<void> => {
await calendarController.newClient(state, code)
res.redirect(state.redirectURL)
} catch (err) {
console.log(err)
console.error(err)
res.redirect(state.redirectURL)
}
}
@ -97,7 +100,7 @@ export const main = async (): Promise<void> => {
const { workspace } = decodeToken(token)
await calendarController.signout(workspace, value as any) // TODO: FIXME
} catch (err) {
console.log('signout error', err)
console.error('signout error', err)
}
res.send()
@ -122,9 +125,23 @@ export const main = async (): Promise<void> => {
res.status(400).send({ err: "'data' is missing" })
return
}
calendarController.push(data.user as any, data.mode as 'events' | 'calendar', data.calendarId) // TODO: FIXME
void calendarController.push(data.user as any, data.mode as 'events' | 'calendar', data.calendarId) // TODO: FIXME
}
res.send()
}
},
{
endpoint: '/event',
type: 'post',
handler: async (req, res) => {
const { event, workspace, type } = req.body
if (event === undefined || workspace === undefined || type === undefined) {
res.status(400).send({ err: "'event' or 'workspace' or 'type' is missing" })
return
}
void calendarController.pushEvent(workspace, event, type)
res.send()
}
}
@ -134,6 +151,7 @@ export const main = async (): Promise<void> => {
const shutdown = (): void => {
server.close(() => {
watchController.stop()
void calendarController
.close()
.then(async () => {

View File

@ -18,17 +18,28 @@ import type { PersonId, Timestamp, WorkspaceUuid } from '@hcengineering/core'
import type { NextFunction, Request, Response } from 'express'
import type { Credentials } from 'google-auth-library'
export interface Watch {
timer: NodeJS.Timeout
export interface WatchBase {
userId: PersonId
workspace: WorkspaceUuid
expired: Timestamp
channelId: string
resourceId: string
calendarId: string | null
}
export interface EventWatch {
export interface CalendarsWatch extends WatchBase {
calendarId: null
}
export interface EventWatch extends WatchBase {
calendarId: string
}
export type Watch = CalendarsWatch | EventWatch
export interface DummyWatch {
timer: NodeJS.Timeout
calendarId: string
channelId: string
resourceId: string
}
export type Token = User & Credentials

View File

@ -0,0 +1,231 @@
import { generateId, isActiveMode, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import { Credentials, OAuth2Client } from 'google-auth-library'
import { calendar_v3, google } from 'googleapis'
import { Collection, Db } from 'mongodb'
import config from './config'
import { RateLimiter } from './rateLimiter'
import { EventWatch, Token, Watch, WatchBase } from './types'
import { getAccountClient } from '@hcengineering/server-client'
export class WatchClient {
private readonly watches: Collection<Watch>
private readonly oAuth2Client: OAuth2Client
private readonly calendar: calendar_v3.Calendar
private readonly user: Token
private me: string = ''
readonly rateLimiter = new RateLimiter(1000, 500)
private constructor (mongo: Db, token: Token) {
this.user = token
this.watches = mongo.collection<WatchBase>('watch')
const credentials = JSON.parse(config.Credentials)
const { client_secret, client_id, redirect_uris } = credentials.web // eslint-disable-line
this.oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]) // eslint-disable-line
this.calendar = google.calendar({ version: 'v3', auth: this.oAuth2Client })
}
static async Create (mongo: Db, token: Token): Promise<WatchClient> {
const watchClient = new WatchClient(mongo, token)
await watchClient.init(token)
return watchClient
}
private async setToken (token: Credentials): Promise<void> {
try {
this.oAuth2Client.setCredentials(token)
const info = await google.oauth2({ version: 'v2', auth: this.oAuth2Client }).userinfo.get()
this.me = info.data.email ?? ''
} catch (err: any) {
console.error('Set token error', this.user.workspace, this.user.userId, err)
await this.checkError(err)
throw err
}
}
async checkError (err: any): Promise<void> {
if (err?.response?.data?.error === 'invalid_grant') {
await this.watches.deleteMany({ userId: this.user.userId, workspace: this.user.workspace })
}
}
private async init (token: Token): Promise<void> {
await this.setToken(token)
}
async subscribe (watches: Watch[]): Promise<void> {
for (const watch of watches) {
if (watch.calendarId == null) {
await this.watchCalendars(watch)
} else {
await this.watchCalendar(watch)
}
}
}
async unsubscribe (watches: Watch[]): Promise<void> {
for (const watch of watches) {
await this.unsubscribeWatch(watch)
}
}
private async unsubscribeWatch (current: Watch): Promise<void> {
await this.rateLimiter.take(1)
await this.calendar.channels.stop({ requestBody: { id: current.channelId, resourceId: current.resourceId } })
}
private async watchCalendars (current: Watch): Promise<void> {
try {
await this.unsubscribeWatch(current)
const channelId = generateId()
const body = { id: channelId, address: config.WATCH_URL, type: 'webhook', token: `user=${this.me}&mode=calendar` }
await this.rateLimiter.take(1)
const res = await this.calendar.calendarList.watch({ requestBody: body })
if (res.data.expiration != null && res.data.resourceId !== null) {
// eslint-disable-next-line
this.watches.updateOne(
{
userId: current.userId,
workspace: current.workspace,
calendarId: null
},
{
$set: {
channelId,
expired: Number.parseInt(res.data.expiration),
resourceId: res.data.resourceId ?? ''
}
}
)
}
} catch (err) {
console.error('Calendar watch error', err)
}
}
private async watchCalendar (current: EventWatch): Promise<void> {
try {
await this.unsubscribeWatch(current)
const channelId = generateId()
const body = {
id: channelId,
address: config.WATCH_URL,
type: 'webhook',
token: `user=${this.me}&mode=events&calendarId=${current.calendarId}`
}
await this.rateLimiter.take(1)
const res = await this.calendar.events.watch({ calendarId: current.calendarId, requestBody: body })
if (res.data.expiration != null && res.data.resourceId != null) {
// eslint-disable-next-line
this.watches.updateOne(
{
userId: current.userId,
workspace: current.workspace,
calendarId: current.calendarId
},
{
$set: {
channelId,
expired: Number.parseInt(res.data.expiration),
resourceId: res.data.resourceId ?? ''
}
}
)
}
} catch (err: any) {
await this.checkError(err)
}
}
}
// we have to refresh channels approx each week
export class WatchController {
private readonly watches: Collection<Watch>
private readonly tokens: Collection<Token>
private timer: NodeJS.Timeout | undefined = undefined
protected static _instance: WatchController
private constructor (private readonly mongo: Db) {
this.watches = mongo.collection<WatchBase>('watch')
this.tokens = mongo.collection<Token>('tokens')
console.log('watch started')
}
static get (mongo: Db): WatchController {
if (WatchController._instance !== undefined) {
return WatchController._instance
}
return new WatchController(mongo)
}
async unsubscribe (user: Token): Promise<void> {
const allWatches = await this.watches.find({ userId: user.userId, workspae: user.workspace }).toArray()
await this.watches.deleteMany({ userId: user.userId, workspae: user.workspace })
const token = this.tokens.findOne({ user: user.userId, workspace: user.workspace })
if (token == null) return
const watchClient = await WatchClient.Create(this.mongo, user)
await watchClient.unsubscribe(allWatches)
}
stop (): void {
if (this.timer !== undefined) {
clearInterval(this.timer)
}
}
startCheck (): void {
this.timer = setInterval(
() => {
void this.checkAll()
},
1000 * 60 * 60 * 24
)
void this.checkAll()
}
async checkAll (): Promise<void> {
const expired = Date.now() + 24 * 60 * 60 * 1000
const watches = await this.watches
.find({
expired: { $lt: expired }
})
.toArray()
console.log('watch, found for update', watches.length)
const groups = new Map<string, WatchBase[]>()
const workspaces = new Set<WorkspaceUuid>()
for (const watch of watches) {
workspaces.add(watch.workspace)
const key = `${watch.userId}:${watch.workspace}`
const group = groups.get(key)
if (group !== undefined) {
group.push(watch)
} else {
groups.set(key, [watch])
}
}
const token = generateToken(systemAccountUuid)
const ids = [...workspaces]
const infos = await getAccountClient(token).getWorkspacesInfo(ids)
const tokens = await this.tokens.find({ workspace: { $in: ids } }).toArray()
for (const group of groups.values()) {
try {
const userId = group[0].userId
const workspace = group[0].workspace
const token = tokens.find((p) => p.workspace === workspace && p.userId === userId)
if (token === undefined) {
await this.watches.deleteMany({ userId, workspace })
continue
}
const info = infos.find((p) => p.uuid === workspace)
if (info === undefined || isActiveMode(info.mode)) {
await this.watches.deleteMany({ userId, workspace })
continue
}
const watchClient = await WatchClient.Create(this.mongo, token)
await watchClient.subscribe(group)
} catch {}
}
console.log('watch check done')
}
}

View File

@ -28,6 +28,7 @@ import core, {
SocialIdType,
buildSocialIdString,
TxMixin,
RateLimiter,
TxOperations,
TxProcessor,
WorkspaceUuid,
@ -48,14 +49,20 @@ import { Collection, type Db } from 'mongodb'
import { CalendarClient } from './calendar'
import { CalendarController } from './calendarController'
import { getClient } from './client'
import { SyncHistory, type ProjectCredentials, type User } from './types'
import { SyncHistory, Token, type User } from './types'
import config from './config'
export class WorkspaceClient {
private readonly txHandlers: ((...tx: Tx[]) => Promise<void>)[] = []
private client!: Client
private readonly clients: Map<string, CalendarClient> = new Map<string, CalendarClient>()
client!: Client
private readonly clients: Map<string, CalendarClient | Promise<CalendarClient>> = new Map<
string,
CalendarClient | Promise<CalendarClient>
>()
private readonly syncHistory: Collection<SyncHistory>
private readonly tokens: Collection<Token>
private channels = new Map<Ref<Channel>, Channel>()
private readonly calendarsByGoogleId = new Map<PersonId, ExternalCalendar[]>()
readonly calendars = {
@ -75,48 +82,57 @@ export class WorkspaceClient {
}
private constructor (
private readonly credentials: ProjectCredentials,
private readonly mongo: Db,
private readonly workspace: WorkspaceUuid,
private readonly serviceController: CalendarController
) {
this.tokens = mongo.collection<Token>('tokens')
this.syncHistory = mongo.collection<SyncHistory>('syncHistories')
}
static async create (
credentials: ProjectCredentials,
mongo: Db,
workspace: WorkspaceUuid,
serviceController: CalendarController
): Promise<WorkspaceClient> {
const instance = new WorkspaceClient(credentials, mongo, workspace, serviceController)
const instance = new WorkspaceClient(mongo, workspace, serviceController)
await instance.initClient(workspace)
return instance
}
async createCalendarClient (user: User): Promise<CalendarClient> {
const current = this.getCalendarClient(user.userId)
if (current !== undefined) return current
const newClient = await CalendarClient.create(this.credentials, user, this.mongo, this.client, this)
if (current !== undefined) {
if (current instanceof Promise) {
return await current
}
return current
}
const newClient = CalendarClient.create(user, this.mongo, this.client, this)
this.clients.set(user.userId, newClient)
console.log('create new client', user.userId, this.workspace)
return newClient
const res = await newClient
this.clients.set(user.userId, res)
return res
}
async newCalendarClient (user: User, code: string): Promise<CalendarClient> {
const newClient = await CalendarClient.create(this.credentials, user, this.mongo, this.client, this)
const email = await newClient.authorize(code)
if (this.clients.has(email)) {
await newClient.close()
const newClient = await CalendarClient.create(user, this.mongo, this.client, this)
const userId = await newClient.authorize(code)
if (this.clients.has(userId)) {
newClient.close()
throw new Error('Client already exist')
}
this.clients.set(email, newClient)
this.clients.set(userId, newClient)
return newClient
}
async close (): Promise<void> {
for (const client of this.clients.values()) {
await client.close()
for (let client of this.clients.values()) {
if (client instanceof Promise) {
client = await client
}
client.close()
}
this.clients.clear()
await this.client?.close()
@ -138,9 +154,12 @@ export class WorkspaceClient {
}
async signout (personId: PersonId, byError: boolean = false): Promise<number> {
const client = this.clients.get(personId)
let client = this.clients.get(personId)
if (client !== undefined) {
await client.signout(byError)
if (client instanceof Promise) {
client = await client
}
await client.signout()
} else {
const integration = await this.client.findOne(setting.class.Integration, {
type: calendar.integrationType.Calendar,
@ -161,21 +180,39 @@ export class WorkspaceClient {
removeClient (personId: PersonId): void {
this.clients.delete(personId)
this.serviceController.removeClient(personId)
if (this.clients.size > 0) return
void this.close()
this.serviceController.removeWorkspace(this.workspace)
}
private getCalendarClient (personId: PersonId): CalendarClient | undefined {
private getCalendarClient (personId: PersonId): CalendarClient | Promise<CalendarClient> | undefined {
return this.clients.get(personId)
}
private getCalendarClientByCalendar (id: Ref<ExternalCalendar>): CalendarClient | undefined {
private async getCalendarClientByCalendar (
id: Ref<ExternalCalendar>,
create: boolean = false
): Promise<CalendarClient | undefined> {
const calendar = this.calendars.byId.get(id)
if (calendar === undefined) {
console.log("couldn't find calendar by id", id)
console.warn("couldn't find calendar by id", id)
return
}
return calendar != null ? this.clients.get(calendar.externalUser) : undefined
const client = this.clients.get(calendar.externalUser)
if (client instanceof Promise) {
return await client
}
if (client === undefined && create) {
const user = await this.tokens.findOne({
workspace: this.workspace,
access_token: { $exists: true },
email: calendar.externalUser
})
if (user != null) {
return await this.createCalendarClient(user)
}
}
return client
}
private async initClient (workspace: WorkspaceUuid): Promise<Client> {
@ -207,6 +244,15 @@ export class WorkspaceClient {
async sync (): Promise<void> {
await this.getNewEvents()
const limiter = new RateLimiter(config.InitLimit)
for (let client of this.clients.values()) {
void limiter.add(async () => {
if (client instanceof Promise) {
client = await client
}
await client.startSync()
})
}
}
// #region Events
@ -233,6 +279,20 @@ export class WorkspaceClient {
)
}
async pushEvent (event: Event, type: 'create' | 'update' | 'delete'): Promise<void> {
const client = await this.getCalendarClientByCalendar(event.calendar as Ref<ExternalCalendar>, true)
if (client === undefined) {
console.warn('Client not found', event.calendar, this.workspace)
return
}
if (type === 'delete') {
await client.removeEvent(event)
} else {
await client.syncMyEvent(event)
}
await this.updateSyncTime()
}
async getNewEvents (): Promise<void> {
const lastSync = await this.getSyncTime()
const query = lastSync !== undefined ? { modifiedOn: { $gt: lastSync } } : {}
@ -240,17 +300,16 @@ export class WorkspaceClient {
this.txHandlers.push(async (...tx: Tx[]) => {
await this.txEventHandler(...tx)
})
console.log('receive new events', this.workspace, newEvents.length)
for (const newEvent of newEvents) {
const client = this.getCalendarClientByCalendar(newEvent.calendar as Ref<ExternalCalendar>)
const client = await this.getCalendarClientByCalendar(newEvent.calendar as Ref<ExternalCalendar>)
if (client === undefined) {
console.log('Client not found', newEvent.calendar, this.workspace)
console.warn('Client not found', newEvent.calendar, this.workspace)
return
}
await client.syncMyEvent(newEvent)
await this.updateSyncTime()
}
console.log('all messages synced', this.workspace)
console.log('all outcoming messages synced', this.workspace)
}
private async txEventHandler (...txes: Tx[]): Promise<void> {
@ -276,7 +335,7 @@ export class WorkspaceClient {
if (hierarhy.isDerived(tx.objectClass, calendar.class.Event)) {
const doc = TxProcessor.createDoc2Doc(tx as TxCreateDoc<Event>)
if (doc.access !== 'owner') return
const client = this.getCalendarClientByCalendar(doc.calendar as Ref<ExternalCalendar>)
const client = await this.getCalendarClientByCalendar(doc.calendar as Ref<ExternalCalendar>)
if (client === undefined) {
return
}
@ -284,7 +343,7 @@ export class WorkspaceClient {
await client.createEvent(doc)
await this.updateSyncTime()
} catch (err) {
console.log(err)
console.error(err)
}
}
}
@ -301,7 +360,7 @@ export class WorkspaceClient {
const extracted = txes.filter((p) => p._id !== tx._id)
const ev = TxProcessor.buildDoc2Doc<Event>(extracted)
if (ev !== undefined) {
const oldClient = this.getCalendarClientByCalendar(ev.calendar as Ref<ExternalCalendar>)
const oldClient = await this.getCalendarClientByCalendar(ev.calendar as Ref<ExternalCalendar>)
if (oldClient !== undefined) {
const oldCalendar = this.calendars.byId.get(ev.calendar as Ref<ExternalCalendar>)
if (oldCalendar !== undefined) {
@ -310,16 +369,16 @@ export class WorkspaceClient {
}
}
} catch (err) {
console.log('Error on remove event', err)
console.error('Error on remove event', err)
}
try {
const client = this.getCalendarClientByCalendar(event.calendar as Ref<ExternalCalendar>)
const client = await this.getCalendarClientByCalendar(event.calendar as Ref<ExternalCalendar>)
if (client !== undefined) {
await client.syncMyEvent(event)
}
await this.updateSyncTime()
} catch (err) {
console.log('Error on move event', err)
console.error('Error on move event', err)
}
}
@ -335,15 +394,15 @@ export class WorkspaceClient {
return
}
if (event.access !== 'owner' && event.access !== 'writer') return
const client = this.getCalendarClientByCalendar(event.calendar as Ref<ExternalCalendar>)
const client = await this.getCalendarClientByCalendar(event.calendar as Ref<ExternalCalendar>)
if (client === undefined) {
return
}
try {
await client.updateEvent(event, tx)
await client.updateEvent(event)
await this.updateSyncTime()
} catch (err) {
console.log(err)
console.error(err)
}
}
}
@ -357,7 +416,7 @@ export class WorkspaceClient {
const ev = TxProcessor.buildDoc2Doc<Event>(txes)
if (ev === undefined) return
if (ev.access !== 'owner' && ev.access !== 'writer') return
const client = this.getCalendarClientByCalendar(ev?.calendar as Ref<ExternalCalendar>)
const client = await this.getCalendarClientByCalendar(ev?.calendar as Ref<ExternalCalendar>)
if (client === undefined) {
return
}