fix(server): event handler bindings (#10165)
This commit is contained in:
@@ -31,7 +31,6 @@
|
|||||||
"@nestjs/apollo": "^12.2.2",
|
"@nestjs/apollo": "^12.2.2",
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
"@nestjs/event-emitter": "^2.1.1",
|
|
||||||
"@nestjs/graphql": "^12.2.2",
|
"@nestjs/graphql": "^12.2.2",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/platform-socket.io": "^10.4.15",
|
"@nestjs/platform-socket.io": "^10.4.15",
|
||||||
@@ -62,6 +61,7 @@
|
|||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
"eventemitter2": "^6.4.9",
|
||||||
"eventsource-parser": "^3.0.0",
|
"eventsource-parser": "^3.0.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"fast-xml-parser": "^4.5.0",
|
"fast-xml-parser": "^4.5.0",
|
||||||
|
|||||||
@@ -51,20 +51,32 @@ test('should broadcast event to cluster instances', async t => {
|
|||||||
|
|
||||||
// app 2 for broadcasting
|
// app 2 for broadcasting
|
||||||
const eventbus2 = app2.get(EventBus);
|
const eventbus2 = app2.get(EventBus);
|
||||||
const cls = ClsServiceManager.getClsService();
|
eventbus2.broadcast('__test__.event', { count: 0 });
|
||||||
cls.run(() => {
|
|
||||||
cls.set(CLS_ID, 'test-request-id');
|
|
||||||
eventbus2.broadcast('__test__.event', { count: 0, requestId: cls.getId() });
|
|
||||||
});
|
|
||||||
|
|
||||||
// cause the cross instances broadcasting is asynchronization calling
|
// cause the cross instances broadcasting is asynchronization calling
|
||||||
// we should wait for the event's arriving before asserting
|
// we should wait for the event's arriving before asserting
|
||||||
await eventbus1.waitFor('__test__.event');
|
await eventbus1.waitFor('__test__.event');
|
||||||
|
|
||||||
t.true(listener.calledOnceWith({ count: 0, requestId: 'test-request-id' }));
|
t.true(listener.calledOnceWith({ count: 0 }));
|
||||||
t.true(
|
t.true(runtimeListener.calledOnceWith({ count: 0 }));
|
||||||
runtimeListener.calledOnceWith({ count: 0, requestId: 'test-request-id' })
|
|
||||||
);
|
|
||||||
|
|
||||||
off();
|
off();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should continuously use the same request id', async t => {
|
||||||
|
const { app1, app2 } = t.context;
|
||||||
|
|
||||||
|
const eventbus1 = app1.get(EventBus);
|
||||||
|
const eventbus2 = app2.get(EventBus);
|
||||||
|
|
||||||
|
const listener = Sinon.spy(app1.get(Listeners), 'onRequestId');
|
||||||
|
|
||||||
|
const cls = ClsServiceManager.getClsService();
|
||||||
|
cls.run(() => {
|
||||||
|
cls.set(CLS_ID, 'test-request-id');
|
||||||
|
eventbus2.broadcast('__test__.requestId', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
await eventbus1.waitFor('__test__.requestId');
|
||||||
|
t.true(listener.lastCall.returned('test-request-id'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TestingModule } from '@nestjs/testing';
|
import { TestingModule } from '@nestjs/testing';
|
||||||
import ava, { TestFn } from 'ava';
|
import ava, { TestFn } from 'ava';
|
||||||
|
import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { EventBus, metrics } from '../../base';
|
import { EventBus, metrics } from '../../base';
|
||||||
@@ -9,7 +10,7 @@ import { Listeners } from './provider';
|
|||||||
export const test = ava as TestFn<{
|
export const test = ava as TestFn<{
|
||||||
module: TestingModule;
|
module: TestingModule;
|
||||||
eventbus: EventBus;
|
eventbus: EventBus;
|
||||||
listener: Sinon.SinonSpy;
|
listeners: Sinon.SinonSpiedInstance<Listeners>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
@@ -19,30 +20,20 @@ test.before(async t => {
|
|||||||
const eventbus = m.get(EventBus);
|
const eventbus = m.get(EventBus);
|
||||||
t.context.module = m;
|
t.context.module = m;
|
||||||
t.context.eventbus = eventbus;
|
t.context.eventbus = eventbus;
|
||||||
t.context.listener = Sinon.spy(m.get(Listeners), 'onTestEvent');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(() => {
|
test.beforeEach(t => {
|
||||||
Sinon.reset();
|
Sinon.restore();
|
||||||
|
const { module } = t.context;
|
||||||
|
t.context.listeners = Sinon.spy(module.get(Listeners));
|
||||||
});
|
});
|
||||||
|
|
||||||
test.after(async t => {
|
test.after(async t => {
|
||||||
await t.context.module.close();
|
await t.context.module.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should register event listener', t => {
|
|
||||||
const { eventbus } = t.context;
|
|
||||||
|
|
||||||
// @ts-expect-error private member
|
|
||||||
t.true(eventbus.emitter.eventNames().includes('__test__.event'));
|
|
||||||
|
|
||||||
eventbus.on('__test__.event2', () => {});
|
|
||||||
// @ts-expect-error private member
|
|
||||||
t.true(eventbus.emitter.eventNames().includes('__test__.event2'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should dispatch event listener', t => {
|
test('should dispatch event listener', t => {
|
||||||
const { eventbus, listener } = t.context;
|
const { eventbus, listeners } = t.context;
|
||||||
|
|
||||||
const runtimeListener = Sinon.stub();
|
const runtimeListener = Sinon.stub();
|
||||||
const off = eventbus.on('__test__.event', runtimeListener);
|
const off = eventbus.on('__test__.event', runtimeListener);
|
||||||
@@ -50,29 +41,53 @@ test('should dispatch event listener', t => {
|
|||||||
const payload = { count: 0 };
|
const payload = { count: 0 };
|
||||||
eventbus.emit('__test__.event', payload);
|
eventbus.emit('__test__.event', payload);
|
||||||
|
|
||||||
t.true(listener.calledOnceWithExactly(payload));
|
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
|
||||||
t.true(runtimeListener.calledOnceWithExactly(payload));
|
t.true(runtimeListener.calledOnceWithExactly(payload));
|
||||||
|
|
||||||
off();
|
off();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch async event listener', async t => {
|
test('should dispatch async event listener', async t => {
|
||||||
const { eventbus, listener } = t.context;
|
const { eventbus, listeners } = t.context;
|
||||||
|
|
||||||
const runtimeListener = Sinon.stub().returns({ count: 2 });
|
const runtimeListener = Sinon.stub().returnsArg(0);
|
||||||
const off = eventbus.on('__test__.event', runtimeListener);
|
const off = eventbus.on('__test__.event', runtimeListener);
|
||||||
|
|
||||||
const payload = { count: 0 };
|
const payload = { count: 0 };
|
||||||
const returns = await eventbus.emitAsync('__test__.event', payload);
|
const returns = await eventbus.emitAsync('__test__.event', payload);
|
||||||
|
|
||||||
t.true(listener.calledOnceWithExactly(payload));
|
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
|
||||||
|
t.true(listeners.onTestEventAndEvent2.calledOnceWithExactly(payload));
|
||||||
t.true(runtimeListener.calledOnceWithExactly(payload));
|
t.true(runtimeListener.calledOnceWithExactly(payload));
|
||||||
|
|
||||||
t.deepEqual(returns, [{ count: 1 }, { count: 2 }]);
|
t.deepEqual(returns, [payload, payload, payload]);
|
||||||
|
|
||||||
off();
|
off();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should dispatch multiple event handlers with same name', async t => {
|
||||||
|
const { eventbus, listeners } = t.context;
|
||||||
|
|
||||||
|
const payload = { count: 0 };
|
||||||
|
await eventbus.emitAsync('__test__.event', payload);
|
||||||
|
|
||||||
|
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
|
||||||
|
t.true(listeners.onTestEventAndEvent2.calledOnceWithExactly(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should dispatch event listener with multiple event names', async t => {
|
||||||
|
const { eventbus, listeners } = t.context;
|
||||||
|
|
||||||
|
const payload = { count: 0 };
|
||||||
|
await eventbus.emitAsync('__test__.event', payload);
|
||||||
|
|
||||||
|
t.like(listeners.onTestEventAndEvent2.lastCall.args[0], payload);
|
||||||
|
|
||||||
|
await eventbus.emitAsync('__test__.event2', payload);
|
||||||
|
|
||||||
|
t.like(listeners.onTestEventAndEvent2.lastCall.args[0], payload);
|
||||||
|
});
|
||||||
|
|
||||||
test('should record event handler call metrics', async t => {
|
test('should record event handler call metrics', async t => {
|
||||||
const { eventbus } = t.context;
|
const { eventbus } = t.context;
|
||||||
const timerStub = Sinon.stub(
|
const timerStub = Sinon.stub(
|
||||||
@@ -86,26 +101,103 @@ test('should record event handler call metrics', async t => {
|
|||||||
|
|
||||||
await eventbus.emitAsync('__test__.event', { count: 0 });
|
await eventbus.emitAsync('__test__.event', { count: 0 });
|
||||||
|
|
||||||
t.deepEqual(timerStub.getCall(0).args[1], {
|
t.true(timerStub.calledTwice);
|
||||||
|
t.deepEqual(timerStub.firstCall.args[1], {
|
||||||
name: 'event_handler',
|
name: 'event_handler',
|
||||||
event: '__test__.event',
|
event: '__test__.event',
|
||||||
namespace: '__test__',
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onTestEvent',
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
t.deepEqual(timerStub.lastCall.args[1], {
|
||||||
|
name: 'event_handler',
|
||||||
|
event: '__test__.event',
|
||||||
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onTestEventAndEvent2',
|
||||||
error: false,
|
error: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
t.deepEqual(counterStub.getCall(0).args[1], {
|
t.true(counterStub.calledTwice);
|
||||||
|
t.deepEqual(counterStub.firstCall.args[1], {
|
||||||
|
name: 'event_handler',
|
||||||
event: '__test__.event',
|
event: '__test__.event',
|
||||||
namespace: '__test__',
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onTestEvent',
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
t.deepEqual(counterStub.lastCall.args[1], {
|
||||||
|
name: 'event_handler',
|
||||||
|
event: '__test__.event',
|
||||||
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onTestEventAndEvent2',
|
||||||
|
error: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
Sinon.reset();
|
timerStub.reset();
|
||||||
|
counterStub.reset();
|
||||||
|
await eventbus.emitAsync('__test__.event2', { count: 0 });
|
||||||
|
|
||||||
await eventbus.emitAsync('__test__.throw', { count: 0 });
|
t.true(timerStub.calledOnce);
|
||||||
|
t.deepEqual(timerStub.firstCall.args[1], {
|
||||||
|
name: 'event_handler',
|
||||||
|
event: '__test__.event2',
|
||||||
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onTestEventAndEvent2',
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
|
||||||
t.deepEqual(timerStub.getCall(0).args[1], {
|
t.true(counterStub.calledOnce);
|
||||||
|
t.deepEqual(counterStub.firstCall.args[1], {
|
||||||
|
name: 'event_handler',
|
||||||
|
event: '__test__.event2',
|
||||||
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onTestEventAndEvent2',
|
||||||
|
error: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
timerStub.reset();
|
||||||
|
counterStub.reset();
|
||||||
|
try {
|
||||||
|
await eventbus.emitAsync('__test__.throw', { count: 0 });
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
t.true(timerStub.calledOnce);
|
||||||
|
t.deepEqual(timerStub.firstCall.args[1], {
|
||||||
name: 'event_handler',
|
name: 'event_handler',
|
||||||
event: '__test__.throw',
|
event: '__test__.throw',
|
||||||
namespace: '__test__',
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onThrow',
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.true(counterStub.calledOnce);
|
||||||
|
t.deepEqual(counterStub.firstCall.args[1], {
|
||||||
|
name: 'event_handler',
|
||||||
|
event: '__test__.throw',
|
||||||
|
namespace: '__test__',
|
||||||
|
handler: 'Listeners.onThrow',
|
||||||
error: true,
|
error: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should generate request id for event', async t => {
|
||||||
|
const { eventbus, listeners } = t.context;
|
||||||
|
|
||||||
|
await eventbus.emitAsync('__test__.requestId', {});
|
||||||
|
|
||||||
|
t.true(listeners.onRequestId.lastCall.returnValue.includes(':event/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should continuously use the same request id', async t => {
|
||||||
|
const { eventbus, listeners } = t.context;
|
||||||
|
|
||||||
|
const cls = ClsServiceManager.getClsService();
|
||||||
|
await cls.run(async () => {
|
||||||
|
cls.set(CLS_ID, 'test-request-id');
|
||||||
|
await eventbus.emitAsync('__test__.requestId', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
t.true(listeners.onRequestId.lastCall.returned('test-request-id'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,31 +1,40 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ClsServiceManager } from 'nestjs-cls';
|
||||||
|
|
||||||
import { OnEvent } from '../../base';
|
import { genRequestId, OnEvent } from '../../base';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Events {
|
interface Events {
|
||||||
'__test__.event': { count: number; requestId?: string };
|
'__test__.event': { count: number };
|
||||||
'__test__.event2': { count: number };
|
'__test__.event2': { count: number };
|
||||||
'__test__.throw': { count: number };
|
'__test__.throw': { count: number };
|
||||||
|
'__test__.requestId': {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class Listeners {
|
export class Listeners {
|
||||||
@OnEvent('__test__.event')
|
@OnEvent('__test__.event')
|
||||||
onTestEvent({ count, requestId }: Events['__test__.event']) {
|
onTestEvent(payload: Events['__test__.event']) {
|
||||||
return requestId
|
return payload;
|
||||||
? {
|
}
|
||||||
count: count + 1,
|
|
||||||
requestId,
|
@OnEvent('__test__.event')
|
||||||
}
|
@OnEvent('__test__.event2')
|
||||||
: {
|
onTestEventAndEvent2(
|
||||||
count: count + 1,
|
payload: Events['__test__.event'] | Events['__test__.event2']
|
||||||
};
|
) {
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('__test__.throw')
|
@OnEvent('__test__.throw')
|
||||||
onThrow() {
|
onThrow() {
|
||||||
throw new Error('Error in event handler');
|
throw new Error('Error in event handler');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent('__test__.requestId')
|
||||||
|
onRequestId() {
|
||||||
|
const cls = ClsServiceManager.getClsService();
|
||||||
|
return cls.getId() ?? genRequestId('event');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,44 @@
|
|||||||
import {
|
import {
|
||||||
applyDecorators,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
OnApplicationBootstrap,
|
OnApplicationBootstrap,
|
||||||
|
OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
|
||||||
EventEmitter2,
|
|
||||||
EventEmitterReadinessWatcher,
|
|
||||||
OnEvent as RawOnEvent,
|
|
||||||
OnEventMetadata,
|
|
||||||
} from '@nestjs/event-emitter';
|
|
||||||
import {
|
import {
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { CLS_ID, ClsService } from 'nestjs-cls';
|
import EventEmitter2, { type OnOptions } from 'eventemitter2';
|
||||||
|
import { CLS_ID, ClsService, ClsServiceManager } from 'nestjs-cls';
|
||||||
import type { Server, Socket } from 'socket.io';
|
import type { Server, Socket } from 'socket.io';
|
||||||
|
|
||||||
import { CallMetric } from '../metrics';
|
import { wrapCallMetric } from '../metrics';
|
||||||
|
import { PushMetadata, sliceMetadata } from '../nestjs';
|
||||||
import { genRequestId } from '../utils';
|
import { genRequestId } from '../utils';
|
||||||
import type { EventName } from './def';
|
import type { EventName } from './def';
|
||||||
|
|
||||||
const EventHandlerWrapper = (event: EventName): MethodDecorator => {
|
const EVENT_LISTENER_METADATA = Symbol('event_listener');
|
||||||
// @ts-expect-error allow
|
interface EventHandlerMetadata {
|
||||||
return (
|
namespace: string;
|
||||||
_target,
|
event: EventName;
|
||||||
key,
|
opts?: OnOptions;
|
||||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
}
|
||||||
) => {
|
|
||||||
const originalMethod = desc.value;
|
|
||||||
if (!originalMethod) {
|
|
||||||
return desc;
|
|
||||||
}
|
|
||||||
|
|
||||||
desc.value = function (...args: any[]) {
|
interface EventOptions extends OnOptions {
|
||||||
new Logger(EventBus.name).log(
|
prepend?: boolean;
|
||||||
`Event handler: ${event} (${key.toString()})`
|
name?: string;
|
||||||
);
|
suppressError?: boolean;
|
||||||
return originalMethod.apply(this, args);
|
}
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OnEvent = (
|
export const OnEvent = (event: EventName, opts?: EventOptions) => {
|
||||||
event: EventName,
|
|
||||||
opts?: OnEventMetadata['options']
|
|
||||||
) => {
|
|
||||||
const namespace = event.split('.')[0];
|
const namespace = event.split('.')[0];
|
||||||
|
return PushMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, {
|
||||||
return applyDecorators(
|
namespace,
|
||||||
EventHandlerWrapper(event),
|
event,
|
||||||
CallMetric('event', 'event_handler', undefined, { event, namespace }),
|
opts,
|
||||||
RawOnEvent(event, opts)
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +48,9 @@ export const OnEvent = (
|
|||||||
namespace: 's2s',
|
namespace: 's2s',
|
||||||
})
|
})
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
|
export class EventBus
|
||||||
|
implements OnGatewayConnection, OnApplicationBootstrap, OnModuleInit
|
||||||
|
{
|
||||||
private readonly logger = new Logger(EventBus.name);
|
private readonly logger = new Logger(EventBus.name);
|
||||||
|
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
@@ -71,8 +58,9 @@ export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly emitter: EventEmitter2,
|
private readonly emitter: EventEmitter2,
|
||||||
private readonly watcher: EventEmitterReadinessWatcher,
|
private readonly cls: ClsService,
|
||||||
private readonly cls: ClsService
|
private readonly discovery: DiscoveryService,
|
||||||
|
private readonly scanner: MetadataScanner
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
@@ -83,27 +71,21 @@ export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
|
|||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.bindEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
this.watcher
|
// Proxy all events received from server(trigger by `server.serverSideEmit`)
|
||||||
.waitUntilReady()
|
// to internal event system
|
||||||
.then(() => {
|
this.server?.on('broadcast', (event, payload, requestId?: string) => {
|
||||||
const events = this.emitter.eventNames() as EventName[];
|
this.cls.run(() => {
|
||||||
events.forEach(event => {
|
requestId = requestId ?? genRequestId('event');
|
||||||
// Proxy all events received from server(trigger by `server.serverSideEmit`)
|
this.cls.set(CLS_ID, requestId);
|
||||||
// to internal event system
|
this.logger.log(`Server Event: ${event} (Received)`);
|
||||||
this.server?.on(event, (payload, requestId?: string) => {
|
this.emit(event, payload);
|
||||||
this.cls.run(() => {
|
|
||||||
requestId = requestId ?? genRequestId('se');
|
|
||||||
this.cls.set(CLS_ID, requestId);
|
|
||||||
this.logger.log(`Server Event: ${event} (Received)`);
|
|
||||||
this.emit(event, payload);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// startup time promise, never throw at runtime
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,22 +109,122 @@ export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
|
|||||||
*/
|
*/
|
||||||
broadcast<T extends EventName>(event: T, payload: Events[T]) {
|
broadcast<T extends EventName>(event: T, payload: Events[T]) {
|
||||||
this.logger.log(`Server Event: ${event} (Send)`);
|
this.logger.log(`Server Event: ${event} (Send)`);
|
||||||
this.server?.serverSideEmit(event, payload, this.cls.getId());
|
this.server?.serverSideEmit('broadcast', event, payload, this.cls.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
on<T extends EventName>(
|
on<T extends EventName>(
|
||||||
event: T,
|
event: T,
|
||||||
listener: (payload: Events[T]) => void | Promise<any>,
|
listener: (payload: Events[T]) => void | Promise<any>,
|
||||||
opts?: OnEventMetadata['options']
|
opts: EventOptions = {}
|
||||||
) {
|
) {
|
||||||
this.emitter.on(event, listener as any, opts);
|
const namespace = event.split('.')[0];
|
||||||
|
const { name, prepend, suppressError } = opts;
|
||||||
|
let signature = name ?? listener.name ?? 'anonymous fn';
|
||||||
|
|
||||||
|
const add = prepend ? this.emitter.prependListener : this.emitter.on;
|
||||||
|
|
||||||
|
const handler = wrapCallMetric(
|
||||||
|
async (payload: any) => {
|
||||||
|
this.logger.verbose(`Handle event [${event}] (${signature})`);
|
||||||
|
|
||||||
|
const cls = ClsServiceManager.getClsService();
|
||||||
|
return await cls.run({ ifNested: 'reuse' }, async () => {
|
||||||
|
const requestId = cls.getId();
|
||||||
|
if (!requestId) {
|
||||||
|
cls.set(CLS_ID, genRequestId('event'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await listener(payload);
|
||||||
|
} catch (e) {
|
||||||
|
if (suppressError) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error happened when handling event [${event}] (${signature})`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'event',
|
||||||
|
'event_handler',
|
||||||
|
{
|
||||||
|
event,
|
||||||
|
namespace,
|
||||||
|
handler: signature,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
add.call(this.emitter, event, handler as any, opts);
|
||||||
|
|
||||||
|
this.logger.verbose(
|
||||||
|
`Event handler for [${event}] registered ${name ? `in [${name}]` : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
this.emitter.off(event, listener as any);
|
this.emitter.off(event, handler as any);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
waitFor<T extends EventName>(name: T, timeout?: number) {
|
waitFor<T extends EventName>(name: T, timeout?: number) {
|
||||||
return this.emitter.waitFor(name, timeout);
|
return this.emitter.waitFor(name, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bindEventHandlers() {
|
||||||
|
// make sure all our job handlers defined in [Providers] to make the code organization clean.
|
||||||
|
// const providers = [...this.discovery.getProviders(), this.discovery.getControllers()]
|
||||||
|
const providers = this.discovery.getProviders();
|
||||||
|
|
||||||
|
providers.forEach(wrapper => {
|
||||||
|
const { instance, name } = wrapper;
|
||||||
|
if (!instance || wrapper.isAlias) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = Object.getPrototypeOf(instance);
|
||||||
|
const methods = this.scanner.getAllMethodNames(proto);
|
||||||
|
|
||||||
|
methods.forEach(method => {
|
||||||
|
const fn = instance[method];
|
||||||
|
|
||||||
|
let defs = sliceMetadata<EventHandlerMetadata>(
|
||||||
|
EVENT_LISTENER_METADATA,
|
||||||
|
fn
|
||||||
|
);
|
||||||
|
|
||||||
|
if (defs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = `${name}.${method}`;
|
||||||
|
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new Error(`Event handler [${signature}] is not a function.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wrapper.isDependencyTreeStatic()) {
|
||||||
|
throw new Error(
|
||||||
|
`Provider [${name}] could not be RequestScoped or TransientScoped injectable if it contains event handlers.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
defs.forEach(({ event, opts }) => {
|
||||||
|
this.on(
|
||||||
|
event,
|
||||||
|
(payload: any) => {
|
||||||
|
// NOTE(@forehalo):
|
||||||
|
// we might create spies on the event handlers when testing,
|
||||||
|
// avoid reusing `fn` variable to fail the spies or stubs
|
||||||
|
return instance[method](payload);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
name: signature,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { DiscoveryModule } from '@nestjs/core';
|
||||||
|
import EventEmitter2 from 'eventemitter2';
|
||||||
|
|
||||||
import { EventBus, OnEvent } from './eventbus';
|
import { EventBus, OnEvent } from './eventbus';
|
||||||
|
|
||||||
|
const EmitProvider = {
|
||||||
|
provide: EventEmitter2,
|
||||||
|
useValue: new EventEmitter2(),
|
||||||
|
};
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [EventEmitterModule.forRoot({ global: false })],
|
imports: [DiscoveryModule],
|
||||||
providers: [EventBus],
|
providers: [EventBus, EmitProvider],
|
||||||
exports: [EventBus],
|
exports: [EventBus],
|
||||||
})
|
})
|
||||||
export class EventModule {}
|
export class EventModule {}
|
||||||
|
|||||||
@@ -28,14 +28,7 @@ export { AFFiNELogger } from './logger';
|
|||||||
export { MailService } from './mailer';
|
export { MailService } from './mailer';
|
||||||
export { CallMetric, metrics } from './metrics';
|
export { CallMetric, metrics } from './metrics';
|
||||||
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
|
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
|
||||||
export {
|
export * from './nestjs';
|
||||||
GatewayErrorWrapper,
|
|
||||||
getOptionalModuleMetadata,
|
|
||||||
GlobalExceptionFilter,
|
|
||||||
mapAnyError,
|
|
||||||
mapSseError,
|
|
||||||
OptionalModule,
|
|
||||||
} from './nestjs';
|
|
||||||
export { type PrismaTransaction } from './prisma';
|
export { type PrismaTransaction } from './prisma';
|
||||||
export { Runtime } from './runtime';
|
export { Runtime } from './runtime';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Attributes } from '@opentelemetry/api';
|
import type { Attributes } from '@opentelemetry/api';
|
||||||
|
|
||||||
|
import { makeMethodDecorator } from '../nestjs/decorator';
|
||||||
import { type KnownMetricScopes, metrics } from './metrics';
|
import { type KnownMetricScopes, metrics } from './metrics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,57 +10,41 @@ import { type KnownMetricScopes, metrics } from './metrics';
|
|||||||
* @param attrs attributes
|
* @param attrs attributes
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const CallMetric = (
|
export const CallMetric = makeMethodDecorator(
|
||||||
|
(scope: KnownMetricScopes, name: string, attrs?: Attributes) => {
|
||||||
|
return (_target, _key, fn) => {
|
||||||
|
return wrapCallMetric(fn, scope, name, attrs);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function wrapCallMetric<Fn extends (...args: any[]) => any>(
|
||||||
|
fn: Fn,
|
||||||
scope: KnownMetricScopes,
|
scope: KnownMetricScopes,
|
||||||
name: string,
|
name: string,
|
||||||
record?: { timer?: boolean; count?: boolean; error?: boolean },
|
|
||||||
attrs?: Attributes
|
attrs?: Attributes
|
||||||
): MethodDecorator => {
|
) {
|
||||||
// @ts-expect-error allow
|
return async function (this: any, ...args: any[]) {
|
||||||
return (
|
const start = Date.now();
|
||||||
_target,
|
let error = false;
|
||||||
_key,
|
|
||||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
try {
|
||||||
) => {
|
return await fn.call(this, ...args);
|
||||||
const originalMethod = desc.value;
|
} catch (err) {
|
||||||
if (!originalMethod) {
|
error = true;
|
||||||
return desc;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
const count = metrics[scope].counter('function_calls', {
|
||||||
|
description: 'function call counter',
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = metrics[scope].histogram('function_timer', {
|
||||||
|
description: 'function call time costs',
|
||||||
|
unit: 'ms',
|
||||||
|
});
|
||||||
|
|
||||||
|
count.add(1, { ...attrs, name, error });
|
||||||
|
timer.record(Date.now() - start, { ...attrs, name, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = metrics[scope].histogram('function_timer', {
|
|
||||||
description: 'function call time costs',
|
|
||||||
unit: 'ms',
|
|
||||||
});
|
|
||||||
const count = metrics[scope].counter('function_calls', {
|
|
||||||
description: 'function call counter',
|
|
||||||
});
|
|
||||||
|
|
||||||
desc.value = async function (...args: any[]) {
|
|
||||||
const start = Date.now();
|
|
||||||
let error = false;
|
|
||||||
|
|
||||||
const end = () => {
|
|
||||||
timer?.record(Date.now() - start, { ...attrs, name, error });
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!record || !!record.count) {
|
|
||||||
count.add(1, attrs);
|
|
||||||
}
|
|
||||||
return await originalMethod.apply(this, args);
|
|
||||||
} catch (err) {
|
|
||||||
if (!record || !!record.error) {
|
|
||||||
error = true;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
count.add(1, { ...attrs, name, error });
|
|
||||||
if (!record || !!record.timer) {
|
|
||||||
end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return desc;
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|||||||
45
packages/backend/server/src/base/nestjs/decorator.ts
Normal file
45
packages/backend/server/src/base/nestjs/decorator.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export function makeMethodDecorator<
|
||||||
|
T extends any[],
|
||||||
|
Fn extends (...args: any[]) => any,
|
||||||
|
>(
|
||||||
|
decorator: (...args: T) => (target: any, key: string | symbol, fn: Fn) => Fn
|
||||||
|
) {
|
||||||
|
return (...args: T) => {
|
||||||
|
return (
|
||||||
|
target: any,
|
||||||
|
key: string | symbol,
|
||||||
|
desc: TypedPropertyDescriptor<any>
|
||||||
|
) => {
|
||||||
|
const originalFn = desc.value;
|
||||||
|
if (!originalFn || typeof originalFn !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`MethodDecorator must be applied to a function but got ${typeof originalFn}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoratedFn = decorator(...args)(target, key, originalFn);
|
||||||
|
desc.value = decoratedFn;
|
||||||
|
return desc;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushMetadata<T>(key: string | symbol, value: T) {
|
||||||
|
const decorator: ClassDecorator | MethodDecorator = (
|
||||||
|
target,
|
||||||
|
_,
|
||||||
|
descriptor
|
||||||
|
) => {
|
||||||
|
const metadataTarget = descriptor?.value ?? target;
|
||||||
|
|
||||||
|
const metadataArray = Reflect.getMetadata(key, metadataTarget) || [];
|
||||||
|
metadataArray.push(value);
|
||||||
|
Reflect.defineMetadata(key, metadataArray, metadataTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
return decorator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sliceMetadata<T>(key: string | symbol, target: any): T[] {
|
||||||
|
return Reflect.getMetadata(key, target) || [];
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import './config';
|
import './config';
|
||||||
|
export * from './decorator';
|
||||||
export * from './exception';
|
export * from './exception';
|
||||||
export * from './optional-module';
|
export * from './optional-module';
|
||||||
|
|||||||
@@ -87,11 +87,11 @@ export function parseCookies(
|
|||||||
* - `graphql`: graphql request
|
* - `graphql`: graphql request
|
||||||
* - `http`: http request
|
* - `http`: http request
|
||||||
* - `ws`: websocket request
|
* - `ws`: websocket request
|
||||||
* - `se`: server event
|
* - `event`: event
|
||||||
* - `job`: cron job
|
* - `job`: cron job
|
||||||
* - `rpc`: rpc request
|
* - `rpc`: rpc request
|
||||||
*/
|
*/
|
||||||
export type RequestType = GqlContextType | 'se' | 'job';
|
export type RequestType = GqlContextType | 'event' | 'job';
|
||||||
|
|
||||||
export function genRequestId(type: RequestType) {
|
export function genRequestId(type: RequestType) {
|
||||||
return `${AFFiNE.flavor.type}:${type}/${randomUUID()}`;
|
return `${AFFiNE.flavor.type}:${type}/${randomUUID()}`;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { DocID } from '../utils/doc';
|
|||||||
const SubscribeMessage = (event: string) =>
|
const SubscribeMessage = (event: string) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
GatewayErrorWrapper(event),
|
GatewayErrorWrapper(event),
|
||||||
CallMetric('socketio', 'event_duration', undefined, { event }),
|
CallMetric('socketio', 'event_duration', { event }),
|
||||||
RawSubscribeMessage(event)
|
RawSubscribeMessage(event)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
81
packages/backend/server/src/core/workspaces/event.ts
Normal file
81
packages/backend/server/src/core/workspaces/event.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { OnEvent } from '../../base';
|
||||||
|
import { Models } from '../../models';
|
||||||
|
import { WorkspaceService } from './resolvers/service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceEvents {
|
||||||
|
constructor(
|
||||||
|
private readonly workspaceService: WorkspaceService,
|
||||||
|
private readonly models: Models
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent('workspace.members.reviewRequested')
|
||||||
|
async onReviewRequested({
|
||||||
|
inviteId,
|
||||||
|
}: Events['workspace.members.reviewRequested']) {
|
||||||
|
// send review request mail to owner and admin
|
||||||
|
await this.workspaceService.sendReviewRequestedEmail(inviteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('workspace.members.requestApproved')
|
||||||
|
async onApproveRequest({
|
||||||
|
inviteId,
|
||||||
|
}: Events['workspace.members.requestApproved']) {
|
||||||
|
// send approve mail
|
||||||
|
await this.workspaceService.sendReviewApproveEmail(inviteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('workspace.members.requestDeclined')
|
||||||
|
async onDeclineRequest({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
}: Events['workspace.members.requestDeclined']) {
|
||||||
|
const user = await this.models.user.getPublicUser(userId);
|
||||||
|
// send decline mail
|
||||||
|
await this.workspaceService.sendReviewDeclinedEmail(
|
||||||
|
user?.email,
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('workspace.members.roleChanged')
|
||||||
|
async onRoleChanged({
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
permission,
|
||||||
|
}: Events['workspace.members.roleChanged']) {
|
||||||
|
// send role changed mail
|
||||||
|
await this.workspaceService.sendRoleChangedEmail(userId, {
|
||||||
|
id: workspaceId,
|
||||||
|
role: permission,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('workspace.members.ownershipTransferred')
|
||||||
|
async onOwnerTransferred({
|
||||||
|
workspaceId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: Events['workspace.members.ownershipTransferred']) {
|
||||||
|
// send ownership transferred mail
|
||||||
|
const fromUser = await this.models.user.getPublicUser(from);
|
||||||
|
const toUser = await this.models.user.getPublicUser(to);
|
||||||
|
|
||||||
|
if (fromUser) {
|
||||||
|
await this.workspaceService.sendOwnershipTransferredEmail(
|
||||||
|
fromUser.email,
|
||||||
|
{
|
||||||
|
id: workspaceId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toUser) {
|
||||||
|
await this.workspaceService.sendOwnershipReceivedEmail(toUser.email, {
|
||||||
|
id: workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { QuotaModule } from '../quota';
|
|||||||
import { StorageModule } from '../storage';
|
import { StorageModule } from '../storage';
|
||||||
import { UserModule } from '../user';
|
import { UserModule } from '../user';
|
||||||
import { WorkspacesController } from './controller';
|
import { WorkspacesController } from './controller';
|
||||||
|
import { WorkspaceEvents } from './event';
|
||||||
import {
|
import {
|
||||||
DocHistoryResolver,
|
DocHistoryResolver,
|
||||||
DocResolver,
|
DocResolver,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
DocHistoryResolver,
|
DocHistoryResolver,
|
||||||
WorkspaceBlobResolver,
|
WorkspaceBlobResolver,
|
||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
|
WorkspaceEvents,
|
||||||
],
|
],
|
||||||
exports: [WorkspaceService],
|
exports: [WorkspaceService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Cache,
|
Cache,
|
||||||
EventBus,
|
EventBus,
|
||||||
MemberNotFoundInSpace,
|
MemberNotFoundInSpace,
|
||||||
OnEvent,
|
|
||||||
RequestMutex,
|
RequestMutex,
|
||||||
TooManyRequest,
|
TooManyRequest,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
@@ -350,72 +349,4 @@ export class TeamWorkspaceResolver {
|
|||||||
return new TooManyRequest();
|
return new TooManyRequest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('workspace.members.reviewRequested')
|
|
||||||
async onReviewRequested({
|
|
||||||
inviteId,
|
|
||||||
}: Events['workspace.members.reviewRequested']) {
|
|
||||||
// send review request mail to owner and admin
|
|
||||||
await this.workspaceService.sendReviewRequestedEmail(inviteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('workspace.members.requestApproved')
|
|
||||||
async onApproveRequest({
|
|
||||||
inviteId,
|
|
||||||
}: Events['workspace.members.requestApproved']) {
|
|
||||||
// send approve mail
|
|
||||||
await this.workspaceService.sendReviewApproveEmail(inviteId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('workspace.members.requestDeclined')
|
|
||||||
async onDeclineRequest({
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
}: Events['workspace.members.requestDeclined']) {
|
|
||||||
const user = await this.models.user.getPublicUser(userId);
|
|
||||||
// send decline mail
|
|
||||||
await this.workspaceService.sendReviewDeclinedEmail(
|
|
||||||
user?.email,
|
|
||||||
workspaceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('workspace.members.roleChanged')
|
|
||||||
async onRoleChanged({
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
permission,
|
|
||||||
}: Events['workspace.members.roleChanged']) {
|
|
||||||
// send role changed mail
|
|
||||||
await this.workspaceService.sendRoleChangedEmail(userId, {
|
|
||||||
id: workspaceId,
|
|
||||||
role: permission,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent('workspace.members.ownershipTransferred')
|
|
||||||
async onOwnerTransferred({
|
|
||||||
workspaceId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
}: Events['workspace.members.ownershipTransferred']) {
|
|
||||||
// send ownership transferred mail
|
|
||||||
const fromUser = await this.models.user.getPublicUser(from);
|
|
||||||
const toUser = await this.models.user.getPublicUser(to);
|
|
||||||
|
|
||||||
if (fromUser) {
|
|
||||||
await this.workspaceService.sendOwnershipTransferredEmail(
|
|
||||||
fromUser.email,
|
|
||||||
{
|
|
||||||
id: workspaceId,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toUser) {
|
|
||||||
await this.workspaceService.sendOwnershipReceivedEmail(toUser.email, {
|
|
||||||
id: workspaceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus } from '../../base';
|
import { EventBus, OnEvent } from '../../base';
|
||||||
import {
|
import {
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
|
|
||||||
|
import { OnEvent } from '../../base';
|
||||||
import { PermissionService } from '../../core/permission';
|
import { PermissionService } from '../../core/permission';
|
||||||
import { WorkspaceService } from '../../core/workspaces/resolvers';
|
import { WorkspaceService } from '../../core/workspaces/resolvers';
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
|
|||||||
16
yarn.lock
16
yarn.lock
@@ -786,7 +786,6 @@ __metadata:
|
|||||||
"@nestjs/apollo": "npm:^12.2.2"
|
"@nestjs/apollo": "npm:^12.2.2"
|
||||||
"@nestjs/common": "npm:^10.4.15"
|
"@nestjs/common": "npm:^10.4.15"
|
||||||
"@nestjs/core": "npm:^10.4.15"
|
"@nestjs/core": "npm:^10.4.15"
|
||||||
"@nestjs/event-emitter": "npm:^2.1.1"
|
|
||||||
"@nestjs/graphql": "npm:^12.2.2"
|
"@nestjs/graphql": "npm:^12.2.2"
|
||||||
"@nestjs/platform-express": "npm:^10.4.15"
|
"@nestjs/platform-express": "npm:^10.4.15"
|
||||||
"@nestjs/platform-socket.io": "npm:^10.4.15"
|
"@nestjs/platform-socket.io": "npm:^10.4.15"
|
||||||
@@ -834,6 +833,7 @@ __metadata:
|
|||||||
cookie-parser: "npm:^1.4.7"
|
cookie-parser: "npm:^1.4.7"
|
||||||
cross-env: "npm:^7.0.3"
|
cross-env: "npm:^7.0.3"
|
||||||
dotenv: "npm:^16.4.7"
|
dotenv: "npm:^16.4.7"
|
||||||
|
eventemitter2: "npm:^6.4.9"
|
||||||
eventsource-parser: "npm:^3.0.0"
|
eventsource-parser: "npm:^3.0.0"
|
||||||
express: "npm:^4.21.2"
|
express: "npm:^4.21.2"
|
||||||
fast-xml-parser: "npm:^4.5.0"
|
fast-xml-parser: "npm:^4.5.0"
|
||||||
@@ -8852,18 +8852,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@nestjs/event-emitter@npm:^2.1.1":
|
|
||||||
version: 2.1.1
|
|
||||||
resolution: "@nestjs/event-emitter@npm:2.1.1"
|
|
||||||
dependencies:
|
|
||||||
eventemitter2: "npm:6.4.9"
|
|
||||||
peerDependencies:
|
|
||||||
"@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0
|
|
||||||
"@nestjs/core": ^8.0.0 || ^9.0.0 || ^10.0.0
|
|
||||||
checksum: 10/79b1ae873515a11b577d9e57b2c605b6aa9830ba60017cc54097c49e543dc23abcfe625425e375fa3525998b87319b1b1c0437a75deabdedf7ebeb02ab487e0c
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@nestjs/graphql@npm:^12.2.2":
|
"@nestjs/graphql@npm:^12.2.2":
|
||||||
version: 12.2.2
|
version: 12.2.2
|
||||||
resolution: "@nestjs/graphql@npm:12.2.2"
|
resolution: "@nestjs/graphql@npm:12.2.2"
|
||||||
@@ -21181,7 +21169,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"eventemitter2@npm:6.4.9, eventemitter2@npm:^6.4.9":
|
"eventemitter2@npm:^6.4.9":
|
||||||
version: 6.4.9
|
version: 6.4.9
|
||||||
resolution: "eventemitter2@npm:6.4.9"
|
resolution: "eventemitter2@npm:6.4.9"
|
||||||
checksum: 10/b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08
|
checksum: 10/b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08
|
||||||
|
|||||||
Reference in New Issue
Block a user