mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-13 16:14:07 -08:00
feat: Reintroduce collaboration feature (#10602)
This commit is contained in:
parent
35e6a87cba
commit
2ea2bfe762
|
@ -0,0 +1,96 @@
|
||||||
|
import { CollaborationState } from '../collaboration.state';
|
||||||
|
import type { CacheService } from '@/services/cache/cache.service';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
const origDate = global.Date;
|
||||||
|
|
||||||
|
const mockDateFactory = (currentDate: string) => {
|
||||||
|
return class CustomDate extends origDate {
|
||||||
|
constructor() {
|
||||||
|
super(currentDate);
|
||||||
|
}
|
||||||
|
} as DateConstructor;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CollaborationState', () => {
|
||||||
|
let collaborationState: CollaborationState;
|
||||||
|
let mockCacheService: jest.Mocked<CacheService>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCacheService = mock<CacheService>();
|
||||||
|
collaborationState = new CollaborationState(mockCacheService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.Date = origDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowId = 'workflow';
|
||||||
|
|
||||||
|
describe('addActiveWorkflowUser', () => {
|
||||||
|
it('should add workflow user with correct cache key and value', async () => {
|
||||||
|
// Arrange
|
||||||
|
global.Date = mockDateFactory('2023-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await collaborationState.addActiveWorkflowUser(workflowId, 'userId');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockCacheService.setHash).toHaveBeenCalledWith('collaboration:workflow', {
|
||||||
|
userId: '2023-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeActiveWorkflowUser', () => {
|
||||||
|
it('should remove workflow user with correct cache key', async () => {
|
||||||
|
// Act
|
||||||
|
await collaborationState.removeActiveWorkflowUser(workflowId, 'userId');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith(
|
||||||
|
'collaboration:workflow',
|
||||||
|
'userId',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActiveWorkflowUsers', () => {
|
||||||
|
it('should get workflows with correct cache key', async () => {
|
||||||
|
// Act
|
||||||
|
const users = await collaborationState.getActiveWorkflowUsers(workflowId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockCacheService.getHash).toHaveBeenCalledWith('collaboration:workflow');
|
||||||
|
expect(users).toBeEmptyArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get workflow users that are not expired', async () => {
|
||||||
|
// Arrange
|
||||||
|
const nowMinus16Minutes = new Date();
|
||||||
|
nowMinus16Minutes.setMinutes(nowMinus16Minutes.getMinutes() - 16);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
mockCacheService.getHash.mockResolvedValueOnce({
|
||||||
|
expiredUserId: nowMinus16Minutes.toISOString(),
|
||||||
|
notExpiredUserId: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const users = await collaborationState.getActiveWorkflowUsers(workflowId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(users).toEqual([
|
||||||
|
{
|
||||||
|
lastSeen: now,
|
||||||
|
userId: 'notExpiredUserId',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// removes expired users from the cache
|
||||||
|
expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith(
|
||||||
|
'collaboration:workflow',
|
||||||
|
'expiredUserId',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
35
packages/cli/src/collaboration/collaboration.message.ts
Normal file
35
packages/cli/src/collaboration/collaboration.message.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export type CollaborationMessage = WorkflowOpenedMessage | WorkflowClosedMessage;
|
||||||
|
|
||||||
|
export const workflowOpenedMessageSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('workflowOpened'),
|
||||||
|
workflowId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const workflowClosedMessageSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal('workflowClosed'),
|
||||||
|
workflowId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const workflowMessageSchema = z.discriminatedUnion('type', [
|
||||||
|
workflowOpenedMessageSchema,
|
||||||
|
workflowClosedMessageSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type WorkflowOpenedMessage = z.infer<typeof workflowOpenedMessageSchema>;
|
||||||
|
|
||||||
|
export type WorkflowClosedMessage = z.infer<typeof workflowClosedMessageSchema>;
|
||||||
|
|
||||||
|
export type WorkflowMessage = z.infer<typeof workflowMessageSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the given message and ensure it's of type WorkflowMessage
|
||||||
|
*/
|
||||||
|
export const parseWorkflowMessage = async (msg: unknown) => {
|
||||||
|
return await workflowMessageSchema.parseAsync(msg);
|
||||||
|
};
|
120
packages/cli/src/collaboration/collaboration.service.ts
Normal file
120
packages/cli/src/collaboration/collaboration.service.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
import { Push } from '../push';
|
||||||
|
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
|
||||||
|
import { parseWorkflowMessage } from './collaboration.message';
|
||||||
|
import type { IActiveWorkflowUsersChanged } from '../interfaces';
|
||||||
|
import type { OnPushMessage } from '@/push/types';
|
||||||
|
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { CollaborationState } from '@/collaboration/collaboration.state';
|
||||||
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
|
import { UserService } from '@/services/user.service';
|
||||||
|
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing collaboration feature between users. E.g. keeping
|
||||||
|
* track of active users for a workflow.
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class CollaborationService {
|
||||||
|
constructor(
|
||||||
|
private readonly push: Push,
|
||||||
|
private readonly state: CollaborationState,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.push.on('message', async (event: OnPushMessage) => {
|
||||||
|
try {
|
||||||
|
await this.handleUserMessage(event.userId, event.msg);
|
||||||
|
} catch (error) {
|
||||||
|
ErrorReporterProxy.error(
|
||||||
|
new ApplicationError('Error handling CollaborationService push message', {
|
||||||
|
extra: {
|
||||||
|
msg: event.msg,
|
||||||
|
userId: event.userId,
|
||||||
|
},
|
||||||
|
cause: error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUserMessage(userId: User['id'], msg: unknown) {
|
||||||
|
const workflowMessage = await parseWorkflowMessage(msg);
|
||||||
|
|
||||||
|
if (workflowMessage.type === 'workflowOpened') {
|
||||||
|
await this.handleWorkflowOpened(userId, workflowMessage);
|
||||||
|
} else if (workflowMessage.type === 'workflowClosed') {
|
||||||
|
await this.handleWorkflowClosed(userId, workflowMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWorkflowOpened(userId: User['id'], msg: WorkflowOpenedMessage) {
|
||||||
|
const { workflowId } = msg;
|
||||||
|
|
||||||
|
if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.state.addActiveWorkflowUser(workflowId, userId);
|
||||||
|
|
||||||
|
await this.sendWorkflowUsersChangedMessage(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWorkflowClosed(userId: User['id'], msg: WorkflowClosedMessage) {
|
||||||
|
const { workflowId } = msg;
|
||||||
|
|
||||||
|
if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.state.removeActiveWorkflowUser(workflowId, userId);
|
||||||
|
|
||||||
|
await this.sendWorkflowUsersChangedMessage(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) {
|
||||||
|
// We have already validated that all active workflow users
|
||||||
|
// have proper access to the workflow, so we don't need to validate it again
|
||||||
|
const activeWorkflowUsers = await this.state.getActiveWorkflowUsers(workflowId);
|
||||||
|
const workflowUserIds = activeWorkflowUsers.map((user) => user.userId);
|
||||||
|
|
||||||
|
if (workflowUserIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const users = await this.userRepository.getByIds(this.userRepository.manager, workflowUserIds);
|
||||||
|
|
||||||
|
const msgData: IActiveWorkflowUsersChanged = {
|
||||||
|
workflowId,
|
||||||
|
activeUsers: await Promise.all(
|
||||||
|
users.map(async (user) => ({
|
||||||
|
user: await this.userService.toPublic(user),
|
||||||
|
lastSeen: activeWorkflowUsers.find((activeUser) => activeUser.userId === user.id)!
|
||||||
|
.lastSeen,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.push.sendToUsers('activeWorkflowUsersChanged', msgData, workflowUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) {
|
||||||
|
const user = await this.userRepository.findOneBy({
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
|
||||||
|
'workflow:read',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return !!workflow;
|
||||||
|
}
|
||||||
|
}
|
110
packages/cli/src/collaboration/collaboration.state.ts
Normal file
110
packages/cli/src/collaboration/collaboration.state.ts
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import type { ActiveWorkflowUser } from '@/collaboration/collaboration.types';
|
||||||
|
import { Time } from '@/constants';
|
||||||
|
import type { Iso8601DateTimeString } from '@/interfaces';
|
||||||
|
import { CacheService } from '@/services/cache/cache.service';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { type Workflow } from 'n8n-workflow';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
type WorkflowCacheHash = Record<User['id'], Iso8601DateTimeString>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State management for the collaboration service. Workflow active
|
||||||
|
* users are stored in a hash in the following format:
|
||||||
|
* {
|
||||||
|
* [workflowId] -> {
|
||||||
|
* [userId] -> lastSeenAsIso8601String
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class CollaborationState {
|
||||||
|
/**
|
||||||
|
* After how many minutes of inactivity a user should be removed
|
||||||
|
* as being an active user of a workflow.
|
||||||
|
*/
|
||||||
|
public readonly inactivityCleanUpTime = 15 * Time.minutes.toMilliseconds;
|
||||||
|
|
||||||
|
constructor(private readonly cache: CacheService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark user active for given workflow
|
||||||
|
*/
|
||||||
|
async addActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) {
|
||||||
|
const cacheKey = this.formWorkflowCacheKey(workflowId);
|
||||||
|
const cacheEntry: WorkflowCacheHash = {
|
||||||
|
[userId]: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.cache.setHash(cacheKey, cacheEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove user from workflow's active users
|
||||||
|
*/
|
||||||
|
async removeActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) {
|
||||||
|
const cacheKey = this.formWorkflowCacheKey(workflowId);
|
||||||
|
|
||||||
|
await this.cache.deleteFromHash(cacheKey, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveWorkflowUsers(workflowId: Workflow['id']): Promise<ActiveWorkflowUser[]> {
|
||||||
|
const cacheKey = this.formWorkflowCacheKey(workflowId);
|
||||||
|
|
||||||
|
const cacheValue = await this.cache.getHash<Iso8601DateTimeString>(cacheKey);
|
||||||
|
if (!cacheValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowActiveUsers = this.cacheHashToWorkflowActiveUsers(cacheValue);
|
||||||
|
const [expired, stillActive] = this.splitToExpiredAndStillActive(workflowActiveUsers);
|
||||||
|
|
||||||
|
if (expired.length > 0) {
|
||||||
|
void this.removeExpiredUsersForWorkflow(workflowId, expired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stillActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formWorkflowCacheKey(workflowId: Workflow['id']) {
|
||||||
|
return `collaboration:${workflowId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private splitToExpiredAndStillActive(workflowUsers: ActiveWorkflowUser[]) {
|
||||||
|
const expired: ActiveWorkflowUser[] = [];
|
||||||
|
const stillActive: ActiveWorkflowUser[] = [];
|
||||||
|
|
||||||
|
for (const user of workflowUsers) {
|
||||||
|
if (this.hasUserExpired(user.lastSeen)) {
|
||||||
|
expired.push(user);
|
||||||
|
} else {
|
||||||
|
stillActive.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [expired, stillActive];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeExpiredUsersForWorkflow(
|
||||||
|
workflowId: Workflow['id'],
|
||||||
|
expiredUsers: ActiveWorkflowUser[],
|
||||||
|
) {
|
||||||
|
const cacheKey = this.formWorkflowCacheKey(workflowId);
|
||||||
|
await Promise.all(
|
||||||
|
expiredUsers.map(async (user) => await this.cache.deleteFromHash(cacheKey, user.userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheHashToWorkflowActiveUsers(workflowCacheEntry: WorkflowCacheHash) {
|
||||||
|
return Object.entries(workflowCacheEntry).map(([userId, lastSeen]) => ({
|
||||||
|
userId,
|
||||||
|
lastSeen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasUserExpired(lastSeenString: Iso8601DateTimeString) {
|
||||||
|
const expiryTime = new Date(lastSeenString).getTime() + this.inactivityCleanUpTime;
|
||||||
|
|
||||||
|
return Date.now() > expiryTime;
|
||||||
|
}
|
||||||
|
}
|
7
packages/cli/src/collaboration/collaboration.types.ts
Normal file
7
packages/cli/src/collaboration/collaboration.types.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import type { Iso8601DateTimeString } from '@/interfaces';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
|
||||||
|
export type ActiveWorkflowUser = {
|
||||||
|
userId: User['id'];
|
||||||
|
lastSeen: Iso8601DateTimeString;
|
||||||
|
};
|
|
@ -290,7 +290,13 @@ export type IPushData =
|
||||||
| PushDataWorkerStatusMessage
|
| PushDataWorkerStatusMessage
|
||||||
| PushDataWorkflowActivated
|
| PushDataWorkflowActivated
|
||||||
| PushDataWorkflowDeactivated
|
| PushDataWorkflowDeactivated
|
||||||
| PushDataWorkflowFailedToActivate;
|
| PushDataWorkflowFailedToActivate
|
||||||
|
| PushDataActiveWorkflowUsersChanged;
|
||||||
|
|
||||||
|
type PushDataActiveWorkflowUsersChanged = {
|
||||||
|
data: IActiveWorkflowUsersChanged;
|
||||||
|
type: 'activeWorkflowUsersChanged';
|
||||||
|
};
|
||||||
|
|
||||||
type PushDataWorkflowFailedToActivate = {
|
type PushDataWorkflowFailedToActivate = {
|
||||||
data: IWorkflowFailedToActivate;
|
data: IWorkflowFailedToActivate;
|
||||||
|
@ -362,6 +368,19 @@ export type PushDataNodeDescriptionUpdated = {
|
||||||
type: 'nodeDescriptionUpdated';
|
type: 'nodeDescriptionUpdated';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** DateTime in the Iso8601 format, e.g. 2024-10-31T00:00:00.123Z */
|
||||||
|
export type Iso8601DateTimeString = string;
|
||||||
|
|
||||||
|
export interface IActiveWorkflowUser {
|
||||||
|
user: PublicUser;
|
||||||
|
lastSeen: Iso8601DateTimeString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IActiveWorkflowUsersChanged {
|
||||||
|
workflowId: Workflow['id'];
|
||||||
|
activeUsers: IActiveWorkflowUser[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IActiveWorkflowAdded {
|
export interface IActiveWorkflowAdded {
|
||||||
workflowId: Workflow['id'];
|
workflowId: Workflow['id'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Logger } from '@/logger';
|
||||||
import type { PushDataExecutionRecovered } from '@/interfaces';
|
import type { PushDataExecutionRecovered } from '@/interfaces';
|
||||||
|
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
@ -27,6 +28,7 @@ const createMockWebSocket = () => new MockWebSocket() as unknown as jest.Mocked<
|
||||||
describe('WebSocketPush', () => {
|
describe('WebSocketPush', () => {
|
||||||
const pushRef1 = 'test-session1';
|
const pushRef1 = 'test-session1';
|
||||||
const pushRef2 = 'test-session2';
|
const pushRef2 = 'test-session2';
|
||||||
|
const userId: User['id'] = 'test-user';
|
||||||
|
|
||||||
mockInstance(Logger);
|
mockInstance(Logger);
|
||||||
const webSocketPush = Container.get(WebSocketPush);
|
const webSocketPush = Container.get(WebSocketPush);
|
||||||
|
@ -35,27 +37,31 @@ describe('WebSocketPush', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
|
mockWebSocket1.removeAllListeners();
|
||||||
|
mockWebSocket2.removeAllListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can add a connection', () => {
|
it('can add a connection', () => {
|
||||||
webSocketPush.add(pushRef1, mockWebSocket1);
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
|
|
||||||
expect(mockWebSocket1.listenerCount('close')).toBe(1);
|
expect(mockWebSocket1.listenerCount('close')).toBe(1);
|
||||||
expect(mockWebSocket1.listenerCount('pong')).toBe(1);
|
expect(mockWebSocket1.listenerCount('pong')).toBe(1);
|
||||||
|
expect(mockWebSocket1.listenerCount('message')).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes a connection', () => {
|
it('closes a connection', () => {
|
||||||
webSocketPush.add(pushRef1, mockWebSocket1);
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
|
|
||||||
mockWebSocket1.emit('close');
|
mockWebSocket1.emit('close');
|
||||||
|
|
||||||
|
expect(mockWebSocket1.listenerCount('message')).toBe(0);
|
||||||
expect(mockWebSocket1.listenerCount('close')).toBe(0);
|
expect(mockWebSocket1.listenerCount('close')).toBe(0);
|
||||||
expect(mockWebSocket1.listenerCount('pong')).toBe(0);
|
expect(mockWebSocket1.listenerCount('pong')).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends data to one connection', () => {
|
it('sends data to one connection', () => {
|
||||||
webSocketPush.add(pushRef1, mockWebSocket1);
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
webSocketPush.add(pushRef2, mockWebSocket2);
|
webSocketPush.add(pushRef2, userId, mockWebSocket2);
|
||||||
const data: PushDataExecutionRecovered = {
|
const data: PushDataExecutionRecovered = {
|
||||||
type: 'executionRecovered',
|
type: 'executionRecovered',
|
||||||
data: {
|
data: {
|
||||||
|
@ -80,8 +86,8 @@ describe('WebSocketPush', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends data to all connections', () => {
|
it('sends data to all connections', () => {
|
||||||
webSocketPush.add(pushRef1, mockWebSocket1);
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
webSocketPush.add(pushRef2, mockWebSocket2);
|
webSocketPush.add(pushRef2, userId, mockWebSocket2);
|
||||||
const data: PushDataExecutionRecovered = {
|
const data: PushDataExecutionRecovered = {
|
||||||
type: 'executionRecovered',
|
type: 'executionRecovered',
|
||||||
data: {
|
data: {
|
||||||
|
@ -105,12 +111,55 @@ describe('WebSocketPush', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pings all connections', () => {
|
it('pings all connections', () => {
|
||||||
webSocketPush.add(pushRef1, mockWebSocket1);
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
webSocketPush.add(pushRef2, mockWebSocket2);
|
webSocketPush.add(pushRef2, userId, mockWebSocket2);
|
||||||
|
|
||||||
jest.runOnlyPendingTimers();
|
jest.runOnlyPendingTimers();
|
||||||
|
|
||||||
expect(mockWebSocket1.ping).toHaveBeenCalled();
|
expect(mockWebSocket1.ping).toHaveBeenCalled();
|
||||||
expect(mockWebSocket2.ping).toHaveBeenCalled();
|
expect(mockWebSocket2.ping).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends data to all users connections', () => {
|
||||||
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
|
webSocketPush.add(pushRef2, userId, mockWebSocket2);
|
||||||
|
const data: PushDataExecutionRecovered = {
|
||||||
|
type: 'executionRecovered',
|
||||||
|
data: {
|
||||||
|
executionId: 'test-execution-id',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
webSocketPush.sendToUsers('executionRecovered', data, [userId]);
|
||||||
|
|
||||||
|
const expectedMsg = JSON.stringify({
|
||||||
|
type: 'executionRecovered',
|
||||||
|
data: {
|
||||||
|
type: 'executionRecovered',
|
||||||
|
data: {
|
||||||
|
executionId: 'test-execution-id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg);
|
||||||
|
expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits message event when connection receives data', () => {
|
||||||
|
const mockOnMessageReceived = jest.fn();
|
||||||
|
webSocketPush.on('message', mockOnMessageReceived);
|
||||||
|
webSocketPush.add(pushRef1, userId, mockWebSocket1);
|
||||||
|
webSocketPush.add(pushRef2, userId, mockWebSocket2);
|
||||||
|
|
||||||
|
const data = { test: 'data' };
|
||||||
|
const buffer = Buffer.from(JSON.stringify(data));
|
||||||
|
|
||||||
|
mockWebSocket1.emit('message', buffer);
|
||||||
|
|
||||||
|
expect(mockOnMessageReceived).toHaveBeenCalledWith({
|
||||||
|
msg: data,
|
||||||
|
pushRef: pushRef1,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
import { assert, jsonStringify } from 'n8n-workflow';
|
import { assert, jsonStringify } from 'n8n-workflow';
|
||||||
import type { IPushDataType } from '@/interfaces';
|
import type { IPushDataType } from '@/interfaces';
|
||||||
import type { Logger } from '@/logger';
|
import type { Logger } from '@/logger';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { TypedEmitter } from '@/typed-emitter';
|
||||||
|
import type { OnPushMessage } from '@/push/types';
|
||||||
|
|
||||||
|
export interface AbstractPushEvents {
|
||||||
|
message: OnPushMessage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract class for two-way push communication.
|
* Abstract class for two-way push communication.
|
||||||
|
@ -8,16 +15,20 @@ import type { Logger } from '@/logger';
|
||||||
*
|
*
|
||||||
* @emits message when a message is received from a client
|
* @emits message when a message is received from a client
|
||||||
*/
|
*/
|
||||||
export abstract class AbstractPush<T> {
|
export abstract class AbstractPush<T> extends TypedEmitter<AbstractPushEvents> {
|
||||||
protected connections: Record<string, T> = {};
|
protected connections: Record<string, T> = {};
|
||||||
|
|
||||||
|
protected userIdByPushRef: Record<string, string> = {};
|
||||||
|
|
||||||
protected abstract close(connection: T): void;
|
protected abstract close(connection: T): void;
|
||||||
protected abstract sendToOneConnection(connection: T, data: string): void;
|
protected abstract sendToOneConnection(connection: T, data: string): void;
|
||||||
|
|
||||||
constructor(protected readonly logger: Logger) {}
|
constructor(protected readonly logger: Logger) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
protected add(pushRef: string, connection: T) {
|
protected add(pushRef: string, userId: User['id'], connection: T) {
|
||||||
const { connections } = this;
|
const { connections, userIdByPushRef } = this;
|
||||||
this.logger.debug('Add editor-UI session', { pushRef });
|
this.logger.debug('Add editor-UI session', { pushRef });
|
||||||
|
|
||||||
const existingConnection = connections[pushRef];
|
const existingConnection = connections[pushRef];
|
||||||
|
@ -28,6 +39,15 @@ export abstract class AbstractPush<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
connections[pushRef] = connection;
|
connections[pushRef] = connection;
|
||||||
|
userIdByPushRef[pushRef] = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMessageReceived(pushRef: string, msg: unknown) {
|
||||||
|
this.logger.debug('Received message from editor-UI', { pushRef, msg });
|
||||||
|
|
||||||
|
const userId = this.userIdByPushRef[pushRef];
|
||||||
|
|
||||||
|
this.emit('message', { pushRef, userId, msg });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected remove(pushRef?: string) {
|
protected remove(pushRef?: string) {
|
||||||
|
@ -36,6 +56,7 @@ export abstract class AbstractPush<T> {
|
||||||
this.logger.debug('Removed editor-UI session', { pushRef });
|
this.logger.debug('Removed editor-UI session', { pushRef });
|
||||||
|
|
||||||
delete this.connections[pushRef];
|
delete this.connections[pushRef];
|
||||||
|
delete this.userIdByPushRef[pushRef];
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendTo(type: IPushDataType, data: unknown, pushRefs: string[]) {
|
private sendTo(type: IPushDataType, data: unknown, pushRefs: string[]) {
|
||||||
|
@ -66,6 +87,15 @@ export abstract class AbstractPush<T> {
|
||||||
this.sendTo(type, data, [pushRef]);
|
this.sendTo(type, data, [pushRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendToUsers(type: IPushDataType, data: unknown, userIds: Array<User['id']>) {
|
||||||
|
const { connections } = this;
|
||||||
|
const userPushRefs = Object.keys(connections).filter((pushRef) =>
|
||||||
|
userIds.includes(this.userIdByPushRef[pushRef]),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sendTo(type, data, userPushRefs);
|
||||||
|
}
|
||||||
|
|
||||||
closeAllConnections() {
|
closeAllConnections() {
|
||||||
for (const pushRef in this.connections) {
|
for (const pushRef in this.connections) {
|
||||||
// Signal the connection that we want to close it.
|
// Signal the connection that we want to close it.
|
||||||
|
|
|
@ -15,11 +15,13 @@ import { OrchestrationService } from '@/services/orchestration.service';
|
||||||
|
|
||||||
import { SSEPush } from './sse.push';
|
import { SSEPush } from './sse.push';
|
||||||
import { WebSocketPush } from './websocket.push';
|
import { WebSocketPush } from './websocket.push';
|
||||||
import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
import type { OnPushMessage, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
||||||
import { TypedEmitter } from '@/typed-emitter';
|
import { TypedEmitter } from '@/typed-emitter';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
|
||||||
type PushEvents = {
|
type PushEvents = {
|
||||||
editorUiConnected: string;
|
editorUiConnected: string;
|
||||||
|
message: OnPushMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useWebSockets = config.getEnv('push.backend') === 'websocket';
|
const useWebSockets = config.getEnv('push.backend') === 'websocket';
|
||||||
|
@ -33,16 +35,21 @@ const useWebSockets = config.getEnv('push.backend') === 'websocket';
|
||||||
*/
|
*/
|
||||||
@Service()
|
@Service()
|
||||||
export class Push extends TypedEmitter<PushEvents> {
|
export class Push extends TypedEmitter<PushEvents> {
|
||||||
|
public isBidirectional = useWebSockets;
|
||||||
|
|
||||||
private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush);
|
private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush);
|
||||||
|
|
||||||
constructor(private readonly orchestrationService: OrchestrationService) {
|
constructor(private readonly orchestrationService: OrchestrationService) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRequest(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) {
|
handleRequest(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) {
|
||||||
const {
|
const {
|
||||||
ws,
|
ws,
|
||||||
query: { pushRef },
|
query: { pushRef },
|
||||||
|
user,
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
if (!pushRef) {
|
if (!pushRef) {
|
||||||
|
@ -55,9 +62,9 @@ export class Push extends TypedEmitter<PushEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.ws) {
|
if (req.ws) {
|
||||||
(this.backend as WebSocketPush).add(pushRef, req.ws);
|
(this.backend as WebSocketPush).add(pushRef, user.id, req.ws);
|
||||||
} else if (!useWebSockets) {
|
} else if (!useWebSockets) {
|
||||||
(this.backend as SSEPush).add(pushRef, { req, res });
|
(this.backend as SSEPush).add(pushRef, user.id, { req, res });
|
||||||
} else {
|
} else {
|
||||||
res.status(401).send('Unauthorized');
|
res.status(401).send('Unauthorized');
|
||||||
return;
|
return;
|
||||||
|
@ -90,6 +97,10 @@ export class Push extends TypedEmitter<PushEvents> {
|
||||||
return this.backend;
|
return this.backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendToUsers(type: IPushDataType, data: unknown, userIds: Array<User['id']>) {
|
||||||
|
this.backend.sendToUsers(type, data, userIds);
|
||||||
|
}
|
||||||
|
|
||||||
@OnShutdown()
|
@OnShutdown()
|
||||||
onShutdown() {
|
onShutdown() {
|
||||||
this.backend.closeAllConnections();
|
this.backend.closeAllConnections();
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Logger } from '@/logger';
|
||||||
|
|
||||||
import { AbstractPush } from './abstract.push';
|
import { AbstractPush } from './abstract.push';
|
||||||
import type { PushRequest, PushResponse } from './types';
|
import type { PushRequest, PushResponse } from './types';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
|
||||||
type Connection = { req: PushRequest; res: PushResponse };
|
type Connection = { req: PushRequest; res: PushResponse };
|
||||||
|
|
||||||
|
@ -22,8 +23,8 @@ export class SSEPush extends AbstractPush<Connection> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
add(pushRef: string, connection: Connection) {
|
add(pushRef: string, userId: User['id'], connection: Connection) {
|
||||||
super.add(pushRef, connection);
|
super.add(pushRef, userId, connection);
|
||||||
this.channel.addClient(connection.req, connection.res);
|
this.channel.addClient(connection.req, connection.res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { Response } from 'express';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
import type { AuthenticatedRequest } from '@/requests';
|
import type { AuthenticatedRequest } from '@/requests';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
|
||||||
// TODO: move all push related types here
|
// TODO: move all push related types here
|
||||||
|
|
||||||
|
@ -11,3 +12,9 @@ export type SSEPushRequest = PushRequest & { ws: undefined };
|
||||||
export type WebSocketPushRequest = PushRequest & { ws: WebSocket };
|
export type WebSocketPushRequest = PushRequest & { ws: WebSocket };
|
||||||
|
|
||||||
export type PushResponse = Response & { req: PushRequest };
|
export type PushResponse = Response & { req: PushRequest };
|
||||||
|
|
||||||
|
export interface OnPushMessage {
|
||||||
|
pushRef: string;
|
||||||
|
userId: User['id'];
|
||||||
|
msg: unknown;
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ import type WebSocket from 'ws';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
import { Logger } from '@/logger';
|
import { Logger } from '@/logger';
|
||||||
import { AbstractPush } from './abstract.push';
|
import { AbstractPush } from './abstract.push';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
|
||||||
|
|
||||||
function heartbeat(this: WebSocket) {
|
function heartbeat(this: WebSocket) {
|
||||||
this.isAlive = true;
|
this.isAlive = true;
|
||||||
|
@ -16,17 +18,43 @@ export class WebSocketPush extends AbstractPush<WebSocket> {
|
||||||
setInterval(() => this.pingAll(), 60 * 1000);
|
setInterval(() => this.pingAll(), 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(pushRef: string, connection: WebSocket) {
|
add(pushRef: string, userId: User['id'], connection: WebSocket) {
|
||||||
connection.isAlive = true;
|
connection.isAlive = true;
|
||||||
connection.on('pong', heartbeat);
|
connection.on('pong', heartbeat);
|
||||||
|
|
||||||
super.add(pushRef, connection);
|
super.add(pushRef, userId, connection);
|
||||||
|
|
||||||
|
const onMessage = (data: WebSocket.RawData) => {
|
||||||
|
try {
|
||||||
|
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
|
||||||
|
|
||||||
|
this.onMessageReceived(pushRef, JSON.parse(buffer.toString('utf8')));
|
||||||
|
} catch (error) {
|
||||||
|
ErrorReporterProxy.error(
|
||||||
|
new ApplicationError('Error parsing push message', {
|
||||||
|
extra: {
|
||||||
|
userId,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
cause: error,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.error("Couldn't parse message from editor-UI", {
|
||||||
|
error: error as unknown,
|
||||||
|
pushRef,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Makes sure to remove the session if the connection is closed
|
// Makes sure to remove the session if the connection is closed
|
||||||
connection.once('close', () => {
|
connection.once('close', () => {
|
||||||
connection.off('pong', heartbeat);
|
connection.off('pong', heartbeat);
|
||||||
|
connection.off('message', onMessage);
|
||||||
this.remove(pushRef);
|
this.remove(pushRef);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.on('message', onMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected close(connection: WebSocket): void {
|
protected close(connection: WebSocket): void {
|
||||||
|
|
|
@ -27,7 +27,7 @@ import type { ICredentialsOverwrite } from '@/interfaces';
|
||||||
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
import { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||||
import * as ResponseHelper from '@/response-helper';
|
import * as ResponseHelper from '@/response-helper';
|
||||||
import { setupPushServer, setupPushHandler } from '@/push';
|
import { setupPushServer, setupPushHandler, Push } from '@/push';
|
||||||
import { isLdapEnabled } from '@/ldap/helpers.ee';
|
import { isLdapEnabled } from '@/ldap/helpers.ee';
|
||||||
import { AbstractServer } from '@/abstract-server';
|
import { AbstractServer } from '@/abstract-server';
|
||||||
import { PostHogClient } from '@/posthog';
|
import { PostHogClient } from '@/posthog';
|
||||||
|
@ -212,6 +212,18 @@ export class Server extends AbstractServer {
|
||||||
const { restEndpoint, app } = this;
|
const { restEndpoint, app } = this;
|
||||||
setupPushHandler(restEndpoint, app);
|
setupPushHandler(restEndpoint, app);
|
||||||
|
|
||||||
|
const push = Container.get(Push);
|
||||||
|
if (push.isBidirectional) {
|
||||||
|
const { CollaborationService } = await import('@/collaboration/collaboration.service');
|
||||||
|
|
||||||
|
const collaborationService = Container.get(CollaborationService);
|
||||||
|
collaborationService.init();
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
'Collaboration features are disabled because push is configured unidirectional. Use N8N_PUSH_BACKEND=websocket environment variable to enable them.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.getEnv('executions.mode') === 'queue') {
|
if (config.getEnv('executions.mode') === 'queue') {
|
||||||
const { ScalingService } = await import('@/scaling/scaling.service');
|
const { ScalingService } = await import('@/scaling/scaling.service');
|
||||||
await Container.get(ScalingService).setupQueue();
|
await Container.get(ScalingService).setupQueue();
|
||||||
|
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { CollaborationService } from '@/collaboration/collaboration.service';
|
||||||
|
import { Push } from '@/push';
|
||||||
|
import { CacheService } from '@/services/cache/cache.service';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import * as testDb from '../shared/test-db';
|
||||||
|
import Container from 'typedi';
|
||||||
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { createMember, createOwner } from '@test-integration/db/users';
|
||||||
|
import type {
|
||||||
|
WorkflowClosedMessage,
|
||||||
|
WorkflowOpenedMessage,
|
||||||
|
} from '@/collaboration/collaboration.message';
|
||||||
|
import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows';
|
||||||
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
import { mockInstance } from '@test/mocking';
|
||||||
|
import { UserService } from '@/services/user.service';
|
||||||
|
|
||||||
|
describe('CollaborationService', () => {
|
||||||
|
mockInstance(Push, new Push(mock()));
|
||||||
|
let pushService: Push;
|
||||||
|
let collaborationService: CollaborationService;
|
||||||
|
let owner: User;
|
||||||
|
let memberWithoutAccess: User;
|
||||||
|
let memberWithAccess: User;
|
||||||
|
let workflow: WorkflowEntity;
|
||||||
|
let userService: UserService;
|
||||||
|
let cacheService: CacheService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await testDb.init();
|
||||||
|
|
||||||
|
pushService = Container.get(Push);
|
||||||
|
collaborationService = Container.get(CollaborationService);
|
||||||
|
userService = Container.get(UserService);
|
||||||
|
cacheService = Container.get(CacheService);
|
||||||
|
|
||||||
|
await cacheService.init();
|
||||||
|
|
||||||
|
[owner, memberWithAccess, memberWithoutAccess] = await Promise.all([
|
||||||
|
createOwner(),
|
||||||
|
createMember(),
|
||||||
|
createMember(),
|
||||||
|
]);
|
||||||
|
workflow = await createWorkflow({}, owner);
|
||||||
|
await shareWorkflowWithUsers(workflow, [memberWithAccess]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
await cacheService.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendWorkflowOpenedMessage = async (workflowId: string, userId: string) => {
|
||||||
|
const openMessage: WorkflowOpenedMessage = {
|
||||||
|
type: 'workflowOpened',
|
||||||
|
workflowId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await collaborationService.handleUserMessage(userId, openMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendWorkflowClosedMessage = async (workflowId: string, userId: string) => {
|
||||||
|
const openMessage: WorkflowClosedMessage = {
|
||||||
|
type: 'workflowClosed',
|
||||||
|
workflowId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await collaborationService.handleUserMessage(userId, openMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('workflow opened message', () => {
|
||||||
|
it('should emit activeWorkflowUsersChanged after workflowOpened', async () => {
|
||||||
|
// Arrange
|
||||||
|
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, owner.id);
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(sendToUsersSpy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'activeWorkflowUsersChanged',
|
||||||
|
{
|
||||||
|
activeUsers: [
|
||||||
|
{
|
||||||
|
lastSeen: expect.any(String),
|
||||||
|
user: {
|
||||||
|
...(await userService.toPublic(owner)),
|
||||||
|
isPending: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflowId: workflow.id,
|
||||||
|
},
|
||||||
|
[owner.id],
|
||||||
|
);
|
||||||
|
expect(sendToUsersSpy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'activeWorkflowUsersChanged',
|
||||||
|
{
|
||||||
|
activeUsers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
lastSeen: expect.any(String),
|
||||||
|
user: expect.objectContaining({
|
||||||
|
id: owner.id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
lastSeen: expect.any(String),
|
||||||
|
user: expect.objectContaining({
|
||||||
|
id: memberWithAccess.id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
workflowId: workflow.id,
|
||||||
|
},
|
||||||
|
[owner.id, memberWithAccess.id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit activeWorkflowUsersChanged if user don't have access to the workflow", async () => {
|
||||||
|
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, memberWithoutAccess.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(sendToUsersSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workflow closed message', () => {
|
||||||
|
it('should not emit activeWorkflowUsersChanged after workflowClosed when there are no active users', async () => {
|
||||||
|
// Arrange
|
||||||
|
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, owner.id);
|
||||||
|
sendToUsersSpy.mockClear();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sendWorkflowClosedMessage(workflow.id, owner.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(sendToUsersSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit activeWorkflowUsersChanged after workflowClosed when there are active users', async () => {
|
||||||
|
// Arrange
|
||||||
|
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, owner.id);
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id);
|
||||||
|
sendToUsersSpy.mockClear();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sendWorkflowClosedMessage(workflow.id, owner.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(sendToUsersSpy).toHaveBeenCalledWith(
|
||||||
|
'activeWorkflowUsersChanged',
|
||||||
|
{
|
||||||
|
activeUsers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
lastSeen: expect.any(String),
|
||||||
|
user: expect.objectContaining({
|
||||||
|
id: memberWithAccess.id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
workflowId: workflow.id,
|
||||||
|
},
|
||||||
|
[memberWithAccess.id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not emit activeWorkflowUsersChanged if user don't have access to the workflow", async () => {
|
||||||
|
// Arrange
|
||||||
|
const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers');
|
||||||
|
await sendWorkflowOpenedMessage(workflow.id, owner.id);
|
||||||
|
sendToUsersSpy.mockClear();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await sendWorkflowClosedMessage(workflow.id, memberWithoutAccess.id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(sendToUsersSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -422,6 +422,16 @@ export interface IExecutionDeleteFilter {
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PushDataUsersForWorkflow = {
|
||||||
|
workflowId: string;
|
||||||
|
activeUsers: Array<{ user: IUser; lastSeen: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PushDataWorkflowUsersChanged = {
|
||||||
|
data: PushDataUsersForWorkflow;
|
||||||
|
type: 'activeWorkflowUsersChanged';
|
||||||
|
};
|
||||||
|
|
||||||
export type IPushData =
|
export type IPushData =
|
||||||
| PushDataExecutionFinished
|
| PushDataExecutionFinished
|
||||||
| PushDataExecutionStarted
|
| PushDataExecutionStarted
|
||||||
|
@ -436,6 +446,7 @@ export type IPushData =
|
||||||
| PushDataWorkerStatusMessage
|
| PushDataWorkerStatusMessage
|
||||||
| PushDataActiveWorkflowAdded
|
| PushDataActiveWorkflowAdded
|
||||||
| PushDataActiveWorkflowRemoved
|
| PushDataActiveWorkflowRemoved
|
||||||
|
| PushDataWorkflowUsersChanged
|
||||||
| PushDataWorkflowFailedToActivate;
|
| PushDataWorkflowFailedToActivate;
|
||||||
|
|
||||||
export type PushDataActiveWorkflowAdded = {
|
export type PushDataActiveWorkflowAdded = {
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||||
|
import { onBeforeUnmount, onMounted, computed, ref } from 'vue';
|
||||||
|
import { TIME } from '@/constants';
|
||||||
|
import { isUserGlobalOwner } from '@/utils/userUtils';
|
||||||
|
|
||||||
|
const collaborationStore = useCollaborationStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE;
|
||||||
|
const heartbeatTimer = ref<number | null>(null);
|
||||||
|
|
||||||
|
const activeUsersSorted = computed(() => {
|
||||||
|
const currentWorkflowUsers = (collaborationStore.getUsersForCurrentWorkflow ?? []).map(
|
||||||
|
(userInfo) => userInfo.user,
|
||||||
|
);
|
||||||
|
const owner = currentWorkflowUsers.find(isUserGlobalOwner);
|
||||||
|
return {
|
||||||
|
defaultGroup: owner
|
||||||
|
? [owner, ...currentWorkflowUsers.filter((user) => user.id !== owner.id)]
|
||||||
|
: currentWorkflowUsers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUserEmail = computed(() => {
|
||||||
|
return usersStore.currentUser?.email;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
if (heartbeatTimer.value !== null) {
|
||||||
|
clearInterval(heartbeatTimer.value);
|
||||||
|
heartbeatTimer.value = null;
|
||||||
|
}
|
||||||
|
heartbeatTimer.value = window.setInterval(() => {
|
||||||
|
collaborationStore.notifyWorkflowOpened(workflowsStore.workflow.id);
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatTimer.value !== null) {
|
||||||
|
clearInterval(heartbeatTimer.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
stopHeartbeat();
|
||||||
|
} else {
|
||||||
|
startHeartbeat();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
collaborationStore.initialize();
|
||||||
|
startHeartbeat();
|
||||||
|
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||||
|
stopHeartbeat();
|
||||||
|
collaborationStore.terminate();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="`collaboration-pane-container ${$style.container}`"
|
||||||
|
data-test-id="collaboration-pane"
|
||||||
|
>
|
||||||
|
<n8n-user-stack :users="activeUsersSorted" :current-user-email="currentUserEmail" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
margin: 0 var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -22,6 +22,7 @@ import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
|
||||||
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
||||||
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
||||||
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
|
||||||
|
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
|
||||||
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
@ -675,6 +676,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||||
</span>
|
</span>
|
||||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
|
||||||
<div :class="$style.group">
|
<div :class="$style.group">
|
||||||
|
<CollaborationPane />
|
||||||
<N8nButton
|
<N8nButton
|
||||||
type="secondary"
|
type="secondary"
|
||||||
data-test-id="workflow-share-button"
|
data-test-id="workflow-share-button"
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { merge } from 'lodash-es';
|
||||||
|
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
|
||||||
|
import { ROLE, STORES } from '@/constants';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import CollaborationPane from '@/components//MainHeader/CollaborationPane.vue';
|
||||||
|
import type { RenderOptions } from '@/__tests__/render';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
|
||||||
|
const OWNER_USER = {
|
||||||
|
createdAt: '2023-11-22T10:17:12.246Z',
|
||||||
|
id: 'aaaaaa',
|
||||||
|
email: 'owner@user.com',
|
||||||
|
firstName: 'Owner',
|
||||||
|
lastName: 'User',
|
||||||
|
role: ROLE.Owner,
|
||||||
|
disabled: false,
|
||||||
|
isPending: false,
|
||||||
|
fullName: 'Owner User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEMBER_USER = {
|
||||||
|
createdAt: '2023-11-22T10:17:12.246Z',
|
||||||
|
id: 'aaabbb',
|
||||||
|
email: 'member@user.com',
|
||||||
|
firstName: 'Member',
|
||||||
|
lastName: 'User',
|
||||||
|
role: ROLE.Member,
|
||||||
|
disabled: false,
|
||||||
|
isPending: false,
|
||||||
|
fullName: 'Member User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEMBER_USER_2 = {
|
||||||
|
createdAt: '2023-11-22T10:17:12.246Z',
|
||||||
|
id: 'aaaccc',
|
||||||
|
email: 'member2@user.com',
|
||||||
|
firstName: 'Another Member',
|
||||||
|
lastName: 'User',
|
||||||
|
role: ROLE.Member,
|
||||||
|
disabled: false,
|
||||||
|
isPending: false,
|
||||||
|
fullName: 'Another Member User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
[STORES.SETTINGS]: {
|
||||||
|
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||||
|
},
|
||||||
|
[STORES.WORKFLOWS]: {
|
||||||
|
workflow: {
|
||||||
|
id: 'w1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[STORES.USERS]: {
|
||||||
|
currentUserId: 'aaaaaa',
|
||||||
|
users: {
|
||||||
|
aaaaaa: OWNER_USER,
|
||||||
|
aaabbb: MEMBER_USER,
|
||||||
|
aaaccc: MEMBER_USER_2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[STORES.COLLABORATION]: {
|
||||||
|
usersForWorkflows: {
|
||||||
|
w1: [
|
||||||
|
{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER },
|
||||||
|
{ lastSeen: '2023-11-22T10:17:12.246Z', user: OWNER_USER },
|
||||||
|
],
|
||||||
|
w2: [{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER_2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultRenderOptions: RenderOptions = {
|
||||||
|
pinia: createTestingPinia({ initialState }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(CollaborationPane, defaultRenderOptions);
|
||||||
|
|
||||||
|
describe('CollaborationPane', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show only current workflow users', async () => {
|
||||||
|
const { getByTestId, queryByTestId } = renderComponent();
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
expect(getByTestId('collaboration-pane')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('user-stack-avatars')).toBeInTheDocument();
|
||||||
|
expect(getByTestId(`user-stack-avatar-${OWNER_USER.id}`)).toBeInTheDocument();
|
||||||
|
expect(getByTestId(`user-stack-avatar-${MEMBER_USER.id}`)).toBeInTheDocument();
|
||||||
|
expect(queryByTestId(`user-stack-avatar-${MEMBER_USER_2.id}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always render owner first in the list', async () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
await waitAllPromises();
|
||||||
|
const firstAvatar = getByTestId('user-stack-avatars').querySelector('.n8n-avatar');
|
||||||
|
// Owner is second in the store but should be rendered first
|
||||||
|
expect(firstAvatar).toHaveAttribute('data-test-id', `user-stack-avatar-${OWNER_USER.id}`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,9 +1,11 @@
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { VIEWS } from '@/constants';
|
import { TIME, VIEWS } from '@/constants';
|
||||||
import type { useRoute } from 'vue-router';
|
import type { useRoute } from 'vue-router';
|
||||||
|
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to handle the beforeunload event in canvas views.
|
* Composable to handle the beforeunload event in canvas views.
|
||||||
|
@ -15,19 +17,31 @@ import type { useRoute } from 'vue-router';
|
||||||
export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute> }) {
|
export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute> }) {
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
|
const collaborationStore = useCollaborationStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const unloadTimeout = ref<NodeJS.Timeout | null>(null);
|
||||||
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
|
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
|
||||||
|
|
||||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
if (isDemoRoute.value || window.preventNodeViewBeforeUnload) {
|
if (isDemoRoute.value || window.preventNodeViewBeforeUnload) {
|
||||||
return;
|
return;
|
||||||
} else if (uiStore.stateIsDirty) {
|
} else if (uiStore.stateIsDirty) {
|
||||||
|
// A bit hacky solution to detecting users leaving the page after prompt:
|
||||||
|
// 1. Notify that workflow is closed straight away
|
||||||
|
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
|
||||||
|
// 2. If user decided to stay on the page we notify that the workflow is opened again
|
||||||
|
unloadTimeout.value = setTimeout(() => {
|
||||||
|
collaborationStore.notifyWorkflowOpened(workflowsStore.workflowId);
|
||||||
|
}, 5 * TIME.SECOND);
|
||||||
|
|
||||||
e.returnValue = true; //Gecko + IE
|
e.returnValue = true; //Gecko + IE
|
||||||
return true; //Gecko + Webkit, Safari, Chrome etc.
|
return true; //Gecko + Webkit, Safari, Chrome etc.
|
||||||
} else {
|
} else {
|
||||||
canvasStore.startLoading(i18n.baseText('nodeView.redirecting'));
|
canvasStore.startLoading(i18n.baseText('nodeView.redirecting'));
|
||||||
|
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,6 +51,12 @@ export function useBeforeUnload({ route }: { route: ReturnType<typeof useRoute>
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBeforeUnloadEventBindings() {
|
function removeBeforeUnloadEventBindings() {
|
||||||
|
collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
|
||||||
|
|
||||||
|
if (unloadTimeout.value) {
|
||||||
|
clearTimeout(unloadTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -639,6 +639,7 @@ export const enum STORES {
|
||||||
CLOUD_PLAN = 'cloudPlan',
|
CLOUD_PLAN = 'cloudPlan',
|
||||||
RBAC = 'rbac',
|
RBAC = 'rbac',
|
||||||
PUSH = 'push',
|
PUSH = 'push',
|
||||||
|
COLLABORATION = 'collaboration',
|
||||||
ASSISTANT = 'assistant',
|
ASSISTANT = 'assistant',
|
||||||
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
|
BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator',
|
||||||
PROJECTS = 'projects',
|
PROJECTS = 'projects',
|
||||||
|
|
86
packages/editor-ui/src/stores/collaboration.store.ts
Normal file
86
packages/editor-ui/src/stores/collaboration.store.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import type { IUser } from '@/Interface';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
|
||||||
|
type ActiveUsersForWorkflows = {
|
||||||
|
[workflowId: string]: Array<{ user: IUser; lastSeen: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for tracking active users for workflows. I.e. to show
|
||||||
|
* who is collaboratively viewing/editing the workflow at the same time.
|
||||||
|
*/
|
||||||
|
export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
|
||||||
|
const pushStore = usePushConnectionStore();
|
||||||
|
const workflowStore = useWorkflowsStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
|
||||||
|
const usersForWorkflows = ref<ActiveUsersForWorkflows>({});
|
||||||
|
const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const getUsersForCurrentWorkflow = computed(() => {
|
||||||
|
return usersForWorkflows.value[workflowStore.workflowId] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
if (pushStoreEventListenerRemovalFn.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushStoreEventListenerRemovalFn.value = pushStore.addEventListener((event) => {
|
||||||
|
if (event.type === 'activeWorkflowUsersChanged') {
|
||||||
|
const workflowId = event.data.workflowId;
|
||||||
|
usersForWorkflows.value[workflowId] = event.data.activeUsers;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminate() {
|
||||||
|
if (typeof pushStoreEventListenerRemovalFn.value === 'function') {
|
||||||
|
pushStoreEventListenerRemovalFn.value();
|
||||||
|
pushStoreEventListenerRemovalFn.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowUsersUpdated(data: ActiveUsersForWorkflows) {
|
||||||
|
usersForWorkflows.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionRemoveCurrentUserFromActiveUsers(workflowId: string) {
|
||||||
|
const workflowUsers = usersForWorkflows.value[workflowId];
|
||||||
|
if (!workflowUsers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersForWorkflows.value[workflowId] = workflowUsers.filter(
|
||||||
|
(activeUser) => activeUser.user.id !== usersStore.currentUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyWorkflowOpened(workflowId: string) {
|
||||||
|
pushStore.send({
|
||||||
|
type: 'workflowOpened',
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyWorkflowClosed(workflowId: string) {
|
||||||
|
pushStore.send({ type: 'workflowClosed', workflowId });
|
||||||
|
|
||||||
|
functionRemoveCurrentUserFromActiveUsers(workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
usersForWorkflows,
|
||||||
|
initialize,
|
||||||
|
terminate,
|
||||||
|
notifyWorkflowOpened,
|
||||||
|
notifyWorkflowClosed,
|
||||||
|
workflowUsersUpdated,
|
||||||
|
getUsersForCurrentWorkflow,
|
||||||
|
};
|
||||||
|
});
|
|
@ -101,6 +101,7 @@ import { createEventBus } from 'n8n-design-system';
|
||||||
import type { PinDataSource } from '@/composables/usePinnedData';
|
import type { PinDataSource } from '@/composables/usePinnedData';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
import { useBeforeUnload } from '@/composables/useBeforeUnload';
|
||||||
|
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue';
|
import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue';
|
||||||
|
|
||||||
|
@ -134,6 +135,7 @@ const credentialsStore = useCredentialsStore();
|
||||||
const environmentsStore = useEnvironmentsStore();
|
const environmentsStore = useEnvironmentsStore();
|
||||||
const externalSecretsStore = useExternalSecretsStore();
|
const externalSecretsStore = useExternalSecretsStore();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
const collaborationStore = useCollaborationStore();
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const npsSurveyStore = useNpsSurveyStore();
|
const npsSurveyStore = useNpsSurveyStore();
|
||||||
|
@ -338,6 +340,8 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
|
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
|
||||||
|
|
||||||
|
collaborationStore.notifyWorkflowOpened(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
|
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
|
||||||
|
|
||||||
|
@ -1456,6 +1460,7 @@ watch(
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
if (!isDemoRoute.value) {
|
if (!isDemoRoute.value) {
|
||||||
pushConnectionStore.pushConnect();
|
pushConnectionStore.pushConnect();
|
||||||
|
collaborationStore.initialize();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1509,6 +1514,7 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
removeBeforeUnloadEventBindings();
|
removeBeforeUnloadEventBindings();
|
||||||
|
collaborationStore.terminate();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue