Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions server-plugins/love-resources/src/__tests__/onEmployee.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//
// Copyright © 2026 TraceX.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import contact, { type Employee, type Person } from '@hcengineering/contact'
import core, {
generateId,
TxFactory,
type Class,
type Doc,
type DocumentQuery,
type Ref,
type Tx,
type TxMixin,
type TxUpdateDoc
} from '@hcengineering/core'
import love, { type Office } from '@hcengineering/love'
import { type TriggerControl } from '@hcengineering/server-core'

import { aiBotEmailSocialKey, OnEmployee } from '../index'

interface MockData {
employees: Array<{ _id: Ref<Employee>, role: string }>
offices: Array<{ _id: Ref<Office>, person: Ref<Employee> | null }>
botIdentities: Array<{ attachedTo: Ref<Person>, key: string }>
}

function createControl (data: MockData): TriggerControl {
const findAll = jest.fn(
async (_ctx: any, _class: Ref<Class<Doc>>, query: DocumentQuery<Doc>, _options?: any): Promise<any[]> => {
if (_class === contact.mixin.Employee) {
return data.employees.filter((it) => it._id === (query as any)._id)
}
if (_class === love.class.Office) {
const person = (query as any).person
return data.offices
.filter((it) => (person === null ? it.person === null : it.person === person))
.map((it) => ({ ...it, _class: love.class.Office, space: core.space.Workspace }))
}
if (_class === contact.class.SocialIdentity) {
return data.botIdentities.filter((it) => it.key === (query as any).key)
}
return []
}
)
return {
ctx: {} as any,
findAll,
txFactory: new TxFactory(core.account.System),
cache: new Map<string, any>()
} as unknown as TriggerControl
}

function employeeTx (person: Ref<Employee>, active: boolean): Tx {
const tx: Partial<TxMixin<Person, Employee>> = {
_id: generateId(),
_class: core.class.TxMixin,
space: core.space.Tx,
objectId: person,
objectClass: contact.class.Person,
objectSpace: contact.space.Contacts,
mixin: contact.mixin.Employee,
attributes: { active },
modifiedBy: core.account.System,
modifiedOn: Date.now()
}
return tx as Tx
}

function socialIdentityQueries (control: TriggerControl): number {
return (control.findAll as jest.Mock).mock.calls.filter((call) => call[1] === contact.class.SocialIdentity).length
}

describe('OnEmployee', () => {
const person = generateId<Employee>()
const bot = generateId<Employee>()

it('assigns a free office to an activated employee', async () => {
const office = generateId<Office>()
const control = createControl({
employees: [{ _id: person, role: 'USER' }],
offices: [{ _id: office, person: null }],
botIdentities: []
})

const result = await OnEmployee([employeeTx(person, true)], control)

expect(result).toHaveLength(1)
const update = result[0] as TxUpdateDoc<Office>
expect(update._class).toBe(core.class.TxUpdateDoc)
expect(update.objectId).toBe(office)
expect(update.operations.person).toBe(person)
})

it('does not assign an office if the person already has one', async () => {
const control = createControl({
employees: [{ _id: person, role: 'USER' }],
offices: [
{ _id: generateId(), person },
{ _id: generateId(), person: null }
],
botIdentities: []
})

const result = await OnEmployee([employeeTx(person, true)], control)

expect(result).toHaveLength(0)
})

it('does not assign an office to the AI bot', async () => {
const control = createControl({
employees: [{ _id: bot, role: 'USER' }],
offices: [{ _id: generateId(), person: null }],
botIdentities: [{ attachedTo: bot, key: aiBotEmailSocialKey }]
})

const result = await OnEmployee([employeeTx(bot, true)], control)

expect(result).toHaveLength(0)
})

it('skips guests', async () => {
const control = createControl({
employees: [{ _id: person, role: 'GUEST' }],
offices: [{ _id: generateId(), person: null }],
botIdentities: []
})

const result = await OnEmployee([employeeTx(person, true)], control)

expect(result).toHaveLength(0)
})

it('releases all offices held by a deactivated employee', async () => {
const office1 = generateId<Office>()
const office2 = generateId<Office>()
const control = createControl({
employees: [{ _id: person, role: 'USER' }],
offices: [
{ _id: office1, person },
{ _id: office2, person }
],
botIdentities: []
})

const result = await OnEmployee([employeeTx(person, false)], control)

expect(result).toHaveLength(2)
const updates = result as Array<TxUpdateDoc<Office>>
expect(updates.map((it) => it.objectId).sort()).toEqual([office1, office2].sort())
for (const update of updates) {
expect(update.operations.person).toBeNull()
}
})

it('does not assign the same free office twice within one batch', async () => {
const person2 = generateId<Employee>()
const office1 = generateId<Office>()
const office2 = generateId<Office>()
const control = createControl({
employees: [
{ _id: person, role: 'USER' },
{ _id: person2, role: 'USER' }
],
offices: [
{ _id: office1, person: null },
{ _id: office2, person: null }
],
botIdentities: []
})

const result = await OnEmployee([employeeTx(person, true), employeeTx(person2, true)], control)

expect(result).toHaveLength(2)
const updates = result as Array<TxUpdateDoc<Office>>
expect(new Set(updates.map((it) => it.objectId)).size).toBe(2)
})

it('caches the AI bot person between invocations', async () => {
const control = createControl({
employees: [{ _id: bot, role: 'USER' }],
offices: [{ _id: generateId(), person: null }],
botIdentities: [{ attachedTo: bot, key: aiBotEmailSocialKey }]
})

await OnEmployee([employeeTx(bot, true)], control)
await OnEmployee([employeeTx(bot, true)], control)

expect(socialIdentityQueries(control)).toBe(1)
})

it('does not cache the negative AI bot lookup', async () => {
const control = createControl({
employees: [{ _id: person, role: 'USER' }],
offices: [{ _id: generateId<Office>(), person: null }],
botIdentities: []
})

await OnEmployee([employeeTx(person, true)], control)
// The bot may join the workspace later, so the lookup should be repeated
await OnEmployee([employeeTx(person, true)], control)

expect(socialIdentityQueries(control)).toBe(2)
})
})
53 changes: 48 additions & 5 deletions server-plugins/love-resources/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
import contact, { Employee, getName, Person } from '@hcengineering/contact'
import core, {
type AccountUuid,
buildSocialIdString,
combineAttributes,
concatLink,
Doc,
generateId,
Ref,
SocialIdType,
Timestamp,
Tx,
TxCreateDoc,
Expand Down Expand Up @@ -48,6 +50,7 @@ import { workbenchId } from '@hcengineering/workbench'

export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const result: Tx[] = []
const assigned = new Set<Ref<Office>>()
for (const tx of txes) {
const actualTx = tx as TxMixin<Person, Employee>
if (actualTx._class !== core.class.TxMixin) {
Expand All @@ -70,17 +73,32 @@ export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise<
continue
}
if (val) {
const freeRoom = (await control.findAll(control.ctx, love.class.Office, { person: null }))[0]
// AI bot does not need an office
if (await isAiBotPerson(actualTx.objectId, control)) {
continue
}
// Do not assign one more office if the person already has one
const existingOffice = (
await control.findAll(control.ctx, love.class.Office, { person: actualTx.objectId }, { limit: 1 })
)[0]
if (existingOffice !== undefined) {
continue
}
const freeRoom = (await control.findAll(control.ctx, love.class.Office, { person: null })).find(
(it) => !assigned.has(it._id)
)
if (freeRoom !== undefined) {
return [
assigned.add(freeRoom._id)
result.push(
control.txFactory.createTxUpdateDoc(freeRoom._class, freeRoom.space, freeRoom._id, {
person: actualTx.objectId
})
]
)
}
} else {
const room = (await control.findAll(control.ctx, love.class.Office, { person: actualTx.objectId }))[0]
if (room !== undefined) {
// Release all offices held by the person
const rooms = await control.findAll(control.ctx, love.class.Office, { person: actualTx.objectId })
for (const room of rooms) {
result.push(
control.txFactory.createTxUpdateDoc(room._class, room.space, room._id, {
person: null
Expand All @@ -92,6 +110,31 @@ export async function OnEmployee (txes: Tx[], control: TriggerControl): Promise<
return result
}

// Keep in sync with aiBotAccountEmail from @hcengineering/ai-bot
// (not imported to avoid adding the package to server bundles)
export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering'

export const aiBotEmailSocialKey = buildSocialIdString({
type: SocialIdType.EMAIL,
value: aiBotAccountEmail
})

const aiBotPersonsCacheKey = 'love:ai-bot-persons'

async function isAiBotPerson (person: Ref<Person>, control: TriggerControl): Promise<boolean> {
let persons = control.cache.get(aiBotPersonsCacheKey) as Set<Ref<Person>> | undefined
if (persons === undefined) {
const identities = await control.findAll(control.ctx, contact.class.SocialIdentity, {
key: aiBotEmailSocialKey
})
// Do not cache the negative result: the bot may join the workspace later
if (identities.length === 0) return false
persons = new Set(identities.map((it) => it.attachedTo))
control.cache.set(aiBotPersonsCacheKey, persons)
}
return persons.has(person)
}

async function createUserInfo (user: AccountUuid, control: TriggerControl): Promise<Tx[]> {
const person = (await control.findAll(control.ctx, contact.class.Person, { personUuid: user }))[0]
if (person === undefined) return []
Expand Down
Loading