Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko 2025-05-21 11:22:21 +07:00
commit 2acb183f58
19 changed files with 155 additions and 70 deletions

View File

@ -391,10 +391,27 @@ export function createModel (builder: Builder): void {
_class: contact.mixin.Employee, _class: contact.mixin.Employee,
icon: contact.icon.Person, icon: contact.icon.Person,
label: contact.string.Employee, label: contact.string.Employee,
baseQuery: {
role: { $ne: 'GUEST' }
},
createLabel: contact.string.CreateEmployee, createLabel: contact.string.CreateEmployee,
createComponent: contact.component.CreateEmployee createComponent: contact.component.CreateEmployee
} }
}, },
{
id: 'guests',
component: workbench.component.SpecialView,
icon: contact.icon.Person,
label: contact.string.Guest,
componentProps: {
_class: contact.mixin.Employee,
icon: contact.icon.Person,
label: contact.string.Guest,
baseQuery: {
role: 'GUEST'
}
}
},
{ {
id: 'persons', id: 'persons',
component: workbench.component.SpecialView, component: workbench.component.SpecialView,

View File

@ -320,7 +320,8 @@ input.search {
.flex-presenter, .inline-presenter { .flex-presenter, .inline-presenter {
flex-wrap: nowrap; flex-wrap: nowrap;
min-width: 0; min-width: 0;
cursor: pointer;
&:not(.no-pointer) { cursor: pointer; }
.icon { .icon {
color: var(--theme-dark-color); color: var(--theme-dark-color);

View File

@ -128,18 +128,7 @@
flex-shrink: 0; flex-shrink: 0;
aspect-ratio: 1; aspect-ratio: 1;
background-color: var(--theme-button-default); background-color: var(--theme-button-default);
pointer-events: none; // pointer-events: none;
&.clickable {
cursor: pointer;
pointer-events: auto;
& > * {
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
}
&.withStatus { &.withStatus {
mask-repeat: no-repeat; mask-repeat: no-repeat;
@ -211,6 +200,12 @@
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
} }
& > * {
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
} }
/* Avatar sizes */ /* Avatar sizes */

View File

@ -115,6 +115,7 @@
"Everyone": "Všichni", "Everyone": "Všichni",
"Here": "Zde", "Here": "Zde",
"EveryoneDescription": "Upozornit všechny v tomto {title}", "EveryoneDescription": "Upozornit všechny v tomto {title}",
"HereDescription": "Upozornit všechny v tomto {title}" "HereDescription": "Upozornit všechny v tomto {title}",
"Guest": "Host"
} }
} }

View File

@ -115,6 +115,7 @@
"Everyone": "Jeder", "Everyone": "Jeder",
"Here": "Hier", "Here": "Hier",
"EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}", "EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}",
"HereDescription": "Benachrichtigen Sie alle in diesem {title}" "HereDescription": "Benachrichtigen Sie alle in diesem {title}",
"Guest": "Gast"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "Everyone", "Everyone": "Everyone",
"Here": "Here", "Here": "Here",
"EveryoneDescription": "Notify everyone in this {title}", "EveryoneDescription": "Notify everyone in this {title}",
"HereDescription": "Notify every online member in this {title}" "HereDescription": "Notify every online member in this {title}",
"Guest": "Guest"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "Todos", "Everyone": "Todos",
"Here": "Aquí", "Here": "Aquí",
"EveryoneDescription": "Notificar a todos en este {title}", "EveryoneDescription": "Notificar a todos en este {title}",
"HereDescription": "Notificar a todos en este {title}" "HereDescription": "Notificar a todos en este {title}",
"Guest": "Invitado"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "Tout le monde", "Everyone": "Tout le monde",
"Here": "Ici", "Here": "Ici",
"EveryoneDescription": "Notifier tout le monde dans ce {title}", "EveryoneDescription": "Notifier tout le monde dans ce {title}",
"HereDescription": "Notifier tout le monde dans ce {title}" "HereDescription": "Notifier tout le monde dans ce {title}",
"Guest": "Invité"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "Tutti", "Everyone": "Tutti",
"Here": "Qui", "Here": "Qui",
"EveryoneDescription": "Notifica tutti in questo {title}", "EveryoneDescription": "Notifica tutti in questo {title}",
"HereDescription": "Notifica tutti in questo {title}" "HereDescription": "Notifica tutti in questo {title}",
"Guest": "Ospite"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "全員", "Everyone": "全員",
"Here": "ここ", "Here": "ここ",
"EveryoneDescription": "この{title}のすべてに通知する", "EveryoneDescription": "この{title}のすべてに通知する",
"HereDescription": "この{title}のすべてに通知する" "HereDescription": "この{title}のすべてに通知する",
"Guest": "ゲスト"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "Todos", "Everyone": "Todos",
"Here": "Aqui", "Here": "Aqui",
"EveryoneDescription": "Notificar todos neste {title}", "EveryoneDescription": "Notificar todos neste {title}",
"HereDescription": "Notificar todos neste {title}" "HereDescription": "Notificar todos neste {title}",
"Guest": "Convidado"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "Все", "Everyone": "Все",
"Here": "Здесь", "Here": "Здесь",
"EveryoneDescription": "Уведомить всех в этом {title}", "EveryoneDescription": "Уведомить всех в этом {title}",
"HereDescription": "Уведомить всех в этом{title}" "HereDescription": "Уведомить всех в этом{title}",
"Guest": "Гость"
} }
} }

View File

@ -119,6 +119,7 @@
"Everyone": "所有人", "Everyone": "所有人",
"Here": "这里", "Here": "这里",
"EveryoneDescription": "通知所有人在此{title}", "EveryoneDescription": "通知所有人在此{title}",
"HereDescription": "通知所有人在此{title}" "HereDescription": "通知所有人在此{title}",
"Guest": "访客"
} }
} }

View File

@ -123,7 +123,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex-presenter" use:tooltip={getPreviewPopup(person, showPreview)} on:click> <div class="flex-presenter" class:no-pointer={!clickable} use:tooltip={getPreviewPopup(person, showPreview)} on:click>
{#if showStatus && person} {#if showStatus && person}
<div class="relative"> <div class="relative">
<AvatarInstance <AvatarInstance
@ -140,7 +140,6 @@
{adaptiveName} {adaptiveName}
{disabled} {disabled}
{style} {style}
{clickable}
withStatus withStatus
/> />
<div <div
@ -164,13 +163,6 @@
{adaptiveName} {adaptiveName}
{disabled} {disabled}
{style} {style}
{clickable}
/> />
{/if} {/if}
</div> </div>
<style>
.flex-presenter {
cursor: pointer;
}
</style>

View File

@ -34,7 +34,6 @@
export let adaptiveName: boolean = false export let adaptiveName: boolean = false
export let disabled: boolean = false export let disabled: boolean = false
export let style: 'modern' | undefined = undefined export let style: 'modern' | undefined = undefined
export let clickable: boolean = false
function handleClick (): void { function handleClick (): void {
dispatch('click') dispatch('click')
@ -73,7 +72,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
bind:this={element} bind:this={element}
class="hulyAvatar-container hulyAvatarSize-{size} {variant} {style} {clickable ? 'clickable' : ''}" class="hulyAvatar-container hulyAvatarSize-{size} {variant} {style}"
class:no-img={!hasImg && color} class:no-img={!hasImg && color}
class:bordered={!hasImg && color === undefined} class:bordered={!hasImg && color === undefined}
class:border={bColor !== undefined} class:border={bColor !== undefined}
@ -97,7 +96,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
bind:this={element} bind:this={element}
class="hulyAvatar-container hulyAvatarSize-{size} {variant} {style} {clickable ? 'clickable' : ''}" class="hulyAvatar-container hulyAvatarSize-{size} {variant} {style}"
class:no-img={!hasImg && color} class:no-img={!hasImg && color}
class:bordered={!hasImg && color === undefined} class:bordered={!hasImg && color === undefined}
class:border={bColor !== undefined} class:border={bColor !== undefined}
@ -131,17 +130,3 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<style>
.clickable {
cursor: pointer;
}
.clickable > * {
pointer-events: none;
}
.clickable img {
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
</style>

View File

@ -346,7 +346,8 @@ export const contactPlugin = plugin(contactId, {
Everyone: '' as IntlString, Everyone: '' as IntlString,
Here: '' as IntlString, Here: '' as IntlString,
EveryoneDescription: '' as IntlString, EveryoneDescription: '' as IntlString,
HereDescription: '' as IntlString HereDescription: '' as IntlString,
Guest: '' as IntlString
}, },
viewlet: { viewlet: {
TableMember: '' as Ref<Viewlet>, TableMember: '' as Ref<Viewlet>,

View File

@ -392,7 +392,7 @@ export async function fetchWorkspace (): Promise<[Status, WorkspaceInfoWithStatu
} }
try { try {
const workspaceWithStatus = await getAccountClient(token).getWorkspaceInfo() const workspaceWithStatus = await getAccountClient(token).getWorkspaceInfo(true)
Analytics.handleEvent('Fetch workspace') Analytics.handleEvent('Fetch workspace')
Analytics.setWorkspace(workspaceWithStatus.url) Analytics.setWorkspace(workspaceWithStatus.url)

View File

@ -154,6 +154,82 @@ describe('ChannelCache', () => {
// Verify the cache doesn't contain the failed lookup // Verify the cache doesn't contain the failed lookup
expect((channelCache as any).cache.has(`${spaceId}:${emailAccount}`)).toBe(false) expect((channelCache as any).cache.has(`${spaceId}:${emailAccount}`)).toBe(false)
}) })
it('should not create duplicate channels for email addresses with different case', async () => {
// Arrange
const existingChannelId = 'mixed-case-channel-id'
const lowerCaseEmail = 'mixedcase@example.com'
const upperCaseEmail = 'MixedCase@Example.com'
// Mock first findOne to return null (channel doesn't exist yet)
// and second findOne to return the channel (after creation)
mockClient.findOne
.mockResolvedValueOnce(undefined) // First call: channel doesn't exist
.mockResolvedValueOnce({
// Second call after creation with different case
_id: existingChannelId as any,
title: lowerCaseEmail
} as any)
mockClient.createDoc.mockResolvedValueOnce(existingChannelId as any)
// Act - First create a channel with lowercase email
const channelId1 = await channelCache.getOrCreateChannel(spaceId, participants, lowerCaseEmail, personId)
// Clear cache to simulate a fresh lookup
channelCache.clearCache(spaceId, lowerCaseEmail)
// Act - Then try to create with uppercase email
const channelId2 = await channelCache.getOrCreateChannel(spaceId, participants, upperCaseEmail, personId)
// Assert
expect(mockClient.findOne).toHaveBeenNthCalledWith(1, mail.tag.MailChannel, { title: lowerCaseEmail })
expect(mockClient.findOne).toHaveBeenNthCalledWith(2, mail.tag.MailChannel, { title: lowerCaseEmail })
// Should only create doc once
expect(mockClient.createDoc).toHaveBeenCalledTimes(1)
expect(mockClient.createMixin).toHaveBeenCalledTimes(1)
// Both should return the same channel ID
expect(channelId1).toBe(existingChannelId)
expect(channelId2).toBe(existingChannelId)
expect(channelId1).toBe(channelId2)
})
it('should handle race conditions when creating channels', async () => {
// Arrange - Simulate a race condition where channel is created between mutex lock and double-check
const raceChannelId = 'race-condition-channel-id'
// Mock behavior:
// 1. First findOne returns null (channel doesn't exist)
// 2. Second findOne (after mutex) returns a channel (someone else created it)
mockClient.findOne.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
_id: raceChannelId,
title: 'race@example.com'
} as any)
// Act
const channelId = await channelCache.getOrCreateChannel(spaceId, participants, 'race@example.com', personId)
// Assert
expect(mockClient.findOne).toHaveBeenCalledTimes(2)
expect(mockClient.createDoc).not.toHaveBeenCalled() // Should not create doc because of race check
expect(channelId).toBe(raceChannelId)
})
it('should normalize email addresses to lowercase before lookup and creation', async () => {
// Arrange - This test verifies that email normalization to lowercase is implemented
const mixedCaseEmail = 'MiXeD@ExAmPlE.com'
// Act
await channelCache.getOrCreateChannel(spaceId, participants, mixedCaseEmail, personId)
// Assert - If email normalization is implemented, this would pass
expect(mockClient.findOne).toHaveBeenCalledWith(mail.tag.MailChannel, {
title: expect.stringMatching(/mixed@example\.com/i)
})
})
}) })
describe('clearCache', () => { describe('clearCache', () => {

View File

@ -25,7 +25,7 @@ const createMutex = new SyncMutex()
* Caches channel references to reduce calls to create mail channels * Caches channel references to reduce calls to create mail channels
*/ */
export class ChannelCache { export class ChannelCache {
// Key is `${spaceId}:${emailAccount}` // Key is `${spaceId}:${normalizedEmail}`
private readonly cache = new Map<string, Ref<Doc>>() private readonly cache = new Map<string, Ref<Doc>>()
constructor ( constructor (
@ -40,17 +40,18 @@ export class ChannelCache {
async getOrCreateChannel ( async getOrCreateChannel (
spaceId: Ref<PersonSpace>, spaceId: Ref<PersonSpace>,
participants: PersonId[], participants: PersonId[],
emailAccount: string, email: string,
owner: PersonId owner: PersonId
): Promise<Ref<Doc> | undefined> { ): Promise<Ref<Doc> | undefined> {
const cacheKey = `${spaceId}:${emailAccount}` const normalizedEmail = normalizeEmail(email)
const cacheKey = `${spaceId}:${normalizedEmail}`
let channel = this.cache.get(cacheKey) let channel = this.cache.get(cacheKey)
if (channel != null) { if (channel != null) {
return channel return channel
} }
channel = await this.fetchOrCreateChannel(spaceId, participants, emailAccount, owner) channel = await this.fetchOrCreateChannel(spaceId, participants, normalizedEmail, owner)
if (channel != null) { if (channel != null) {
this.cache.set(cacheKey, channel) this.cache.set(cacheKey, channel)
} }
@ -58,8 +59,9 @@ export class ChannelCache {
return channel return channel
} }
clearCache (spaceId: Ref<PersonSpace>, emailAccount: string): void { clearCache (spaceId: Ref<PersonSpace>, email: string): void {
this.cache.delete(`${spaceId}:${emailAccount}`) const normalizedEmail = normalizeEmail(email)
this.cache.delete(`${spaceId}:${normalizedEmail}`)
} }
clearAllCache (): void { clearAllCache (): void {
@ -73,29 +75,30 @@ export class ChannelCache {
private async fetchOrCreateChannel ( private async fetchOrCreateChannel (
space: Ref<PersonSpace>, space: Ref<PersonSpace>,
participants: PersonId[], participants: PersonId[],
emailAccount: string, email: string,
personId: PersonId personId: PersonId
): Promise<Ref<Doc> | undefined> { ): Promise<Ref<Doc> | undefined> {
const normalizedEmail = normalizeEmail(email)
try { try {
// First try to find existing channel // First try to find existing channel
const channel = await this.client.findOne(mail.tag.MailChannel, { title: emailAccount }) const channel = await this.client.findOne(mail.tag.MailChannel, { title: normalizedEmail })
if (channel != null) { if (channel != null) {
this.ctx.info('Using existing channel', { me: emailAccount, space, channel: channel._id }) this.ctx.info('Using existing channel', { me: normalizedEmail, space, channel: channel._id })
return channel._id return channel._id
} }
return await this.createNewChannel(space, participants, emailAccount, personId) return await this.createNewChannel(space, participants, normalizedEmail, personId)
} catch (err) { } catch (err) {
this.ctx.error('Failed to create channel', { this.ctx.error('Failed to create channel', {
me: emailAccount, me: normalizedEmail,
space, space,
workspace: this.workspace, workspace: this.workspace,
error: err instanceof Error ? err.message : String(err) error: err instanceof Error ? err.message : String(err)
}) })
// Remove failed lookup from cache // Remove failed lookup from cache
this.cache.delete(`${space}:${emailAccount}`) this.cache.delete(`${space}:${normalizedEmail}`)
return undefined return undefined
} }
@ -104,18 +107,19 @@ export class ChannelCache {
private async createNewChannel ( private async createNewChannel (
space: Ref<PersonSpace>, space: Ref<PersonSpace>,
participants: PersonId[], participants: PersonId[],
emailAccount: string, email: string,
personId: PersonId personId: PersonId
): Promise<Ref<Doc> | undefined> { ): Promise<Ref<Doc> | undefined> {
const mutexKey = `channel:${this.workspace}:${space}:${emailAccount}` const normalizedEmail = normalizeEmail(email)
const mutexKey = `channel:${this.workspace}:${space}:${normalizedEmail}`
const releaseLock = await createMutex.lock(mutexKey) const releaseLock = await createMutex.lock(mutexKey)
try { try {
// Double-check that channel doesn't exist after acquiring lock // Double-check that channel doesn't exist after acquiring lock
const existingChannel = await this.client.findOne(mail.tag.MailChannel, { title: emailAccount }) const existingChannel = await this.client.findOne(mail.tag.MailChannel, { title: normalizedEmail })
if (existingChannel != null) { if (existingChannel != null) {
this.ctx.info('Using existing channel (found after mutex lock)', { this.ctx.info('Using existing channel (found after mutex lock)', {
me: emailAccount, me: normalizedEmail,
space, space,
channel: existingChannel._id channel: existingChannel._id
}) })
@ -123,12 +127,12 @@ export class ChannelCache {
} }
// Create new channel if it doesn't exist // Create new channel if it doesn't exist
this.ctx.info('Creating new channel', { me: emailAccount, space, personId }) this.ctx.info('Creating new channel', { me: normalizedEmail, space, personId })
const channelId = await this.client.createDoc( const channelId = await this.client.createDoc(
chat.masterTag.Channel, chat.masterTag.Channel,
space, space,
{ {
title: emailAccount, title: normalizedEmail,
private: true, private: true,
members: participants, members: participants,
archived: false, archived: false,
@ -140,7 +144,7 @@ export class ChannelCache {
personId personId
) )
this.ctx.info('Creating mixin', { me: emailAccount, space, personId, channelId }) this.ctx.info('Creating mixin', { me: normalizedEmail, space, personId, channelId })
await this.client.createMixin( await this.client.createMixin(
channelId, channelId,
chat.masterTag.Channel, chat.masterTag.Channel,
@ -187,3 +191,7 @@ export const ChannelCacheFactory = {
return ChannelCacheFactory.instances.size return ChannelCacheFactory.instances.size
} }
} }
function normalizeEmail (email: string): string {
return email.toLowerCase().trim()
}