fix(server): event handler bindings (#10165)

This commit is contained in:
forehalo
2025-02-14 11:29:02 +00:00
parent 42e0563d2e
commit 3dde47dd08
18 changed files with 486 additions and 260 deletions

View File

@@ -31,7 +31,6 @@
"@nestjs/apollo": "^12.2.2",
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/event-emitter": "^2.1.1",
"@nestjs/graphql": "^12.2.2",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-socket.io": "^10.4.15",
@@ -62,6 +61,7 @@
"@socket.io/redis-adapter": "^8.3.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"eventemitter2": "^6.4.9",
"eventsource-parser": "^3.0.0",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.0",

View File

@@ -51,20 +51,32 @@ test('should broadcast event to cluster instances', async t => {
// app 2 for broadcasting
const eventbus2 = app2.get(EventBus);
const cls = ClsServiceManager.getClsService();
cls.run(() => {
cls.set(CLS_ID, 'test-request-id');
eventbus2.broadcast('__test__.event', { count: 0, requestId: cls.getId() });
});
eventbus2.broadcast('__test__.event', { count: 0 });
// cause the cross instances broadcasting is asynchronization calling
// we should wait for the event's arriving before asserting
await eventbus1.waitFor('__test__.event');
t.true(listener.calledOnceWith({ count: 0, requestId: 'test-request-id' }));
t.true(
runtimeListener.calledOnceWith({ count: 0, requestId: 'test-request-id' })
);
t.true(listener.calledOnceWith({ count: 0 }));
t.true(runtimeListener.calledOnceWith({ count: 0 }));
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'));
});

View File

@@ -1,5 +1,6 @@
import { TestingModule } from '@nestjs/testing';
import ava, { TestFn } from 'ava';
import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
import Sinon from 'sinon';
import { EventBus, metrics } from '../../base';
@@ -9,7 +10,7 @@ import { Listeners } from './provider';
export const test = ava as TestFn<{
module: TestingModule;
eventbus: EventBus;
listener: Sinon.SinonSpy;
listeners: Sinon.SinonSpiedInstance<Listeners>;
}>;
test.before(async t => {
@@ -19,30 +20,20 @@ test.before(async t => {
const eventbus = m.get(EventBus);
t.context.module = m;
t.context.eventbus = eventbus;
t.context.listener = Sinon.spy(m.get(Listeners), 'onTestEvent');
});
test.afterEach(() => {
Sinon.reset();
test.beforeEach(t => {
Sinon.restore();
const { module } = t.context;
t.context.listeners = Sinon.spy(module.get(Listeners));
});
test.after(async t => {
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 => {
const { eventbus, listener } = t.context;
const { eventbus, listeners } = t.context;
const runtimeListener = Sinon.stub();
const off = eventbus.on('__test__.event', runtimeListener);
@@ -50,29 +41,53 @@ test('should dispatch event listener', t => {
const payload = { count: 0 };
eventbus.emit('__test__.event', payload);
t.true(listener.calledOnceWithExactly(payload));
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
t.true(runtimeListener.calledOnceWithExactly(payload));
off();
});
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 payload = { count: 0 };
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.deepEqual(returns, [{ count: 1 }, { count: 2 }]);
t.deepEqual(returns, [payload, payload, payload]);
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 => {
const { eventbus } = t.context;
const timerStub = Sinon.stub(
@@ -86,26 +101,103 @@ test('should record event handler call metrics', async t => {
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',
event: '__test__.event',
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,
});
t.deepEqual(counterStub.getCall(0).args[1], {
t.true(counterStub.calledTwice);
t.deepEqual(counterStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.event',
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',
event: '__test__.throw',
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,
});
});
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'));
});

View File

@@ -1,31 +1,40 @@
import { Injectable } from '@nestjs/common';
import { ClsServiceManager } from 'nestjs-cls';
import { OnEvent } from '../../base';
import { genRequestId, OnEvent } from '../../base';
declare global {
interface Events {
'__test__.event': { count: number; requestId?: string };
'__test__.event': { count: number };
'__test__.event2': { count: number };
'__test__.throw': { count: number };
'__test__.requestId': {};
}
}
@Injectable()
export class Listeners {
@OnEvent('__test__.event')
onTestEvent({ count, requestId }: Events['__test__.event']) {
return requestId
? {
count: count + 1,
requestId,
}
: {
count: count + 1,
};
onTestEvent(payload: Events['__test__.event']) {
return payload;
}
@OnEvent('__test__.event')
@OnEvent('__test__.event2')
onTestEventAndEvent2(
payload: Events['__test__.event'] | Events['__test__.event2']
) {
return payload;
}
@OnEvent('__test__.throw')
onThrow() {
throw new Error('Error in event handler');
}
@OnEvent('__test__.requestId')
onRequestId() {
const cls = ClsServiceManager.getClsService();
return cls.getId() ?? genRequestId('event');
}
}

View File

@@ -1,59 +1,44 @@
import {
applyDecorators,
Injectable,
Logger,
OnApplicationBootstrap,
OnModuleInit,
} from '@nestjs/common';
import {
EventEmitter2,
EventEmitterReadinessWatcher,
OnEvent as RawOnEvent,
OnEventMetadata,
} from '@nestjs/event-emitter';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} 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 { CallMetric } from '../metrics';
import { wrapCallMetric } from '../metrics';
import { PushMetadata, sliceMetadata } from '../nestjs';
import { genRequestId } from '../utils';
import type { EventName } from './def';
const EventHandlerWrapper = (event: EventName): MethodDecorator => {
// @ts-expect-error allow
return (
_target,
key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
}
const EVENT_LISTENER_METADATA = Symbol('event_listener');
interface EventHandlerMetadata {
namespace: string;
event: EventName;
opts?: OnOptions;
}
desc.value = function (...args: any[]) {
new Logger(EventBus.name).log(
`Event handler: ${event} (${key.toString()})`
);
return originalMethod.apply(this, args);
};
};
};
interface EventOptions extends OnOptions {
prepend?: boolean;
name?: string;
suppressError?: boolean;
}
export const OnEvent = (
event: EventName,
opts?: OnEventMetadata['options']
) => {
export const OnEvent = (event: EventName, opts?: EventOptions) => {
const namespace = event.split('.')[0];
return applyDecorators(
EventHandlerWrapper(event),
CallMetric('event', 'event_handler', undefined, { event, namespace }),
RawOnEvent(event, opts)
);
return PushMetadata<EventHandlerMetadata>(EVENT_LISTENER_METADATA, {
namespace,
event,
opts,
});
};
/**
@@ -63,7 +48,9 @@ export const OnEvent = (
namespace: 's2s',
})
@Injectable()
export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
export class EventBus
implements OnGatewayConnection, OnApplicationBootstrap, OnModuleInit
{
private readonly logger = new Logger(EventBus.name);
@WebSocketServer()
@@ -71,8 +58,9 @@ export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
constructor(
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) {
@@ -83,27 +71,21 @@ export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
client.disconnect();
}
async onModuleInit() {
this.bindEventHandlers();
}
async onApplicationBootstrap() {
this.watcher
.waitUntilReady()
.then(() => {
const events = this.emitter.eventNames() as EventName[];
events.forEach(event => {
// Proxy all events received from server(trigger by `server.serverSideEmit`)
// to internal event system
this.server?.on(event, (payload, requestId?: string) => {
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
// Proxy all events received from server(trigger by `server.serverSideEmit`)
// to internal event system
this.server?.on('broadcast', (event, payload, requestId?: string) => {
this.cls.run(() => {
requestId = requestId ?? genRequestId('event');
this.cls.set(CLS_ID, requestId);
this.logger.log(`Server Event: ${event} (Received)`);
this.emit(event, payload);
});
});
}
/**
@@ -127,22 +109,122 @@ export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
*/
broadcast<T extends EventName>(event: T, payload: Events[T]) {
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>(
event: T,
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 () => {
this.emitter.off(event, listener as any);
this.emitter.off(event, handler as any);
};
}
waitFor<T extends EventName>(name: T, timeout?: number) {
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,
}
);
});
});
});
}
}

View File

@@ -1,12 +1,18 @@
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';
const EmitProvider = {
provide: EventEmitter2,
useValue: new EventEmitter2(),
};
@Global()
@Module({
imports: [EventEmitterModule.forRoot({ global: false })],
providers: [EventBus],
imports: [DiscoveryModule],
providers: [EventBus, EmitProvider],
exports: [EventBus],
})
export class EventModule {}

View File

@@ -28,14 +28,7 @@ export { AFFiNELogger } from './logger';
export { MailService } from './mailer';
export { CallMetric, metrics } from './metrics';
export { Lock, Locker, Mutex, RequestMutex } from './mutex';
export {
GatewayErrorWrapper,
getOptionalModuleMetadata,
GlobalExceptionFilter,
mapAnyError,
mapSseError,
OptionalModule,
} from './nestjs';
export * from './nestjs';
export { type PrismaTransaction } from './prisma';
export { Runtime } from './runtime';
export * from './storage';

View File

@@ -1,5 +1,6 @@
import type { Attributes } from '@opentelemetry/api';
import { makeMethodDecorator } from '../nestjs/decorator';
import { type KnownMetricScopes, metrics } from './metrics';
/**
@@ -9,57 +10,41 @@ import { type KnownMetricScopes, metrics } from './metrics';
* @param attrs attributes
* @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,
name: string,
record?: { timer?: boolean; count?: boolean; error?: boolean },
attrs?: Attributes
): MethodDecorator => {
// @ts-expect-error allow
return (
_target,
_key,
desc: TypedPropertyDescriptor<(...args: any[]) => any>
) => {
const originalMethod = desc.value;
if (!originalMethod) {
return desc;
) {
return async function (this: any, ...args: any[]) {
const start = Date.now();
let error = false;
try {
return await fn.call(this, ...args);
} catch (err) {
error = true;
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;
};
};
}

View 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) || [];
}

View File

@@ -1,3 +1,4 @@
import './config';
export * from './decorator';
export * from './exception';
export * from './optional-module';

View File

@@ -87,11 +87,11 @@ export function parseCookies(
* - `graphql`: graphql request
* - `http`: http request
* - `ws`: websocket request
* - `se`: server event
* - `event`: event
* - `job`: cron job
* - `rpc`: rpc request
*/
export type RequestType = GqlContextType | 'se' | 'job';
export type RequestType = GqlContextType | 'event' | 'job';
export function genRequestId(type: RequestType) {
return `${AFFiNE.flavor.type}:${type}/${randomUUID()}`;

View File

@@ -34,7 +34,7 @@ import { DocID } from '../utils/doc';
const SubscribeMessage = (event: string) =>
applyDecorators(
GatewayErrorWrapper(event),
CallMetric('socketio', 'event_duration', undefined, { event }),
CallMetric('socketio', 'event_duration', { event }),
RawSubscribeMessage(event)
);

View 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,
});
}
}
}

View File

@@ -8,6 +8,7 @@ import { QuotaModule } from '../quota';
import { StorageModule } from '../storage';
import { UserModule } from '../user';
import { WorkspacesController } from './controller';
import { WorkspaceEvents } from './event';
import {
DocHistoryResolver,
DocResolver,
@@ -37,6 +38,7 @@ import {
DocHistoryResolver,
WorkspaceBlobResolver,
WorkspaceService,
WorkspaceEvents,
],
exports: [WorkspaceService],
})

View File

@@ -14,7 +14,6 @@ import {
Cache,
EventBus,
MemberNotFoundInSpace,
OnEvent,
RequestMutex,
TooManyRequest,
URLHelper,
@@ -350,72 +349,4 @@ export class TeamWorkspaceResolver {
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,
});
}
}
}

View File

@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import { EventBus } from '../../base';
import { EventBus, OnEvent } from '../../base';
import {
SubscriptionPlan,
SubscriptionRecurring,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { OnEvent } from '../../base';
import { PermissionService } from '../../core/permission';
import { WorkspaceService } from '../../core/workspaces/resolvers';
import { Models } from '../../models';