mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(server): cluster level event system (#9884)
This commit is contained in:
62
packages/backend/server/src/__tests__/event/cluster.spec.ts
Normal file
62
packages/backend/server/src/__tests__/event/cluster.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EventBus } from '../../base';
|
||||
import { SocketIoAdapter } from '../../base/websocket';
|
||||
import { createTestingModule } from '../utils';
|
||||
import { Listeners } from './provider';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app1: INestApplication;
|
||||
app2: INestApplication;
|
||||
}>;
|
||||
async function createApp() {
|
||||
const m = await createTestingModule(
|
||||
{
|
||||
providers: [Listeners],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const app = m.createNestApplication({ logger: false });
|
||||
|
||||
app.useWebSocketAdapter(new SocketIoAdapter(app));
|
||||
await app.init();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
test.before(async t => {
|
||||
t.context.app1 = await createApp();
|
||||
t.context.app2 = await createApp();
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.app1.close();
|
||||
await t.context.app2.close();
|
||||
});
|
||||
|
||||
test('should broadcast event to cluster instances', async t => {
|
||||
const { app1, app2 } = t.context;
|
||||
|
||||
// app 1 for listening
|
||||
const eventbus1 = app1.get(EventBus);
|
||||
|
||||
const listener = Sinon.spy(app1.get(Listeners), 'onTestEvent');
|
||||
const runtimeListener = Sinon.stub().returns({ count: 2 });
|
||||
const off = eventbus1.on('__test__.event', runtimeListener);
|
||||
|
||||
// app 2 for broadcasting
|
||||
const eventbus2 = app2.get(EventBus);
|
||||
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 }));
|
||||
t.true(runtimeListener.calledOnceWith({ count: 0 }));
|
||||
|
||||
off();
|
||||
});
|
||||
111
packages/backend/server/src/__tests__/event/eventbus.spec.ts
Normal file
111
packages/backend/server/src/__tests__/event/eventbus.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EventBus, metrics } from '../../base';
|
||||
import { createTestingModule } from '../utils';
|
||||
import { Listeners } from './provider';
|
||||
|
||||
export const test = ava as TestFn<{
|
||||
module: TestingModule;
|
||||
eventbus: EventBus;
|
||||
listener: Sinon.SinonSpy;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const m = await createTestingModule({
|
||||
providers: [Listeners],
|
||||
});
|
||||
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.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 runtimeListener = Sinon.stub();
|
||||
const off = eventbus.on('__test__.event', runtimeListener);
|
||||
|
||||
const payload = { count: 0 };
|
||||
eventbus.emit('__test__.event', payload);
|
||||
|
||||
t.true(listener.calledOnceWithExactly(payload));
|
||||
t.true(runtimeListener.calledOnceWithExactly(payload));
|
||||
|
||||
off();
|
||||
});
|
||||
|
||||
test('should dispatch async event listener', async t => {
|
||||
const { eventbus, listener } = t.context;
|
||||
|
||||
const runtimeListener = Sinon.stub().returns({ count: 2 });
|
||||
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(runtimeListener.calledOnceWithExactly(payload));
|
||||
|
||||
t.deepEqual(returns, [{ count: 1 }, { count: 2 }]);
|
||||
|
||||
off();
|
||||
});
|
||||
|
||||
test('should record event handler call metrics', async t => {
|
||||
const { eventbus } = t.context;
|
||||
const timerStub = Sinon.stub(
|
||||
metrics.event.histogram('function_timer'),
|
||||
'record'
|
||||
);
|
||||
const counterStub = Sinon.stub(
|
||||
metrics.event.counter('function_calls'),
|
||||
'add'
|
||||
);
|
||||
|
||||
await eventbus.emitAsync('__test__.event', { count: 0 });
|
||||
|
||||
t.deepEqual(timerStub.getCall(0).args[1], {
|
||||
name: 'event_handler',
|
||||
event: '__test__.event',
|
||||
namespace: '__test__',
|
||||
error: false,
|
||||
});
|
||||
|
||||
t.deepEqual(counterStub.getCall(0).args[1], {
|
||||
event: '__test__.event',
|
||||
namespace: '__test__',
|
||||
});
|
||||
|
||||
Sinon.reset();
|
||||
|
||||
await eventbus.emitAsync('__test__.throw', { count: 0 });
|
||||
|
||||
t.deepEqual(timerStub.getCall(0).args[1], {
|
||||
name: 'event_handler',
|
||||
event: '__test__.throw',
|
||||
namespace: '__test__',
|
||||
error: true,
|
||||
});
|
||||
});
|
||||
26
packages/backend/server/src/__tests__/event/provider.ts
Normal file
26
packages/backend/server/src/__tests__/event/provider.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'__test__.event': { count: number };
|
||||
'__test__.event2': { count: number };
|
||||
'__test__.throw': { count: number };
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class Listeners {
|
||||
@OnEvent('__test__.event')
|
||||
onTestEvent({ count }: Events['__test__.event']) {
|
||||
return {
|
||||
count: count + 1,
|
||||
};
|
||||
}
|
||||
|
||||
@OnEvent('__test__.throw')
|
||||
onThrow() {
|
||||
throw new Error('Error in event handler');
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EmailAlreadyUsed } from '../../base';
|
||||
import { EmailAlreadyUsed, EventBus } from '../../base';
|
||||
import { Permission } from '../../models/common';
|
||||
import { UserModel } from '../../models/user';
|
||||
import { WorkspaceMemberStatus } from '../../models/workspace';
|
||||
@@ -46,7 +45,7 @@ test('should create a new user', async t => {
|
||||
});
|
||||
|
||||
test('should trigger user.created event', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const spy = Sinon.spy();
|
||||
event.on('user.created', spy);
|
||||
|
||||
@@ -117,7 +116,7 @@ test('should not update email to an existing one', async t => {
|
||||
});
|
||||
|
||||
test('should trigger user.updated event', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const spy = Sinon.spy();
|
||||
event.on('user.updated', spy);
|
||||
|
||||
@@ -217,7 +216,7 @@ test('should fulfill user', async t => {
|
||||
});
|
||||
|
||||
test('should trigger user.updated event when fulfilling user', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const createSpy = Sinon.spy();
|
||||
const updateSpy = Sinon.spy();
|
||||
event.on('user.created', createSpy);
|
||||
@@ -250,7 +249,7 @@ test('should delete user', async t => {
|
||||
});
|
||||
|
||||
test('should trigger user.deleted event', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const spy = Sinon.spy();
|
||||
event.on('user.deleted', spy);
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Config } from '../../base/config';
|
||||
import { Config, EventBus } from '../../base';
|
||||
import { Permission } from '../../models/common';
|
||||
import { UserModel } from '../../models/user';
|
||||
import { WorkspaceModel } from '../../models/workspace';
|
||||
@@ -305,7 +304,7 @@ test('should grant member with read permission and Pending status by default', a
|
||||
email: 'test2@affine.pro',
|
||||
});
|
||||
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const updatedSpy = Sinon.spy();
|
||||
event.on('workspace.members.updated', updatedSpy);
|
||||
const member1 = await t.context.workspace.grantMember(
|
||||
@@ -829,7 +828,7 @@ test('should delete workspace member in Pending, Accepted status', async t => {
|
||||
);
|
||||
t.is(member.status, WorkspaceMemberStatus.Pending);
|
||||
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const updatedSpy = Sinon.spy();
|
||||
event.on('workspace.members.updated', updatedSpy);
|
||||
let success = await t.context.workspace.deleteMember(
|
||||
@@ -880,7 +879,7 @@ test('should trigger workspace.members.requestDeclined event when delete workspa
|
||||
);
|
||||
t.is(member.status, WorkspaceMemberStatus.UnderReview);
|
||||
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const updatedSpy = Sinon.spy();
|
||||
const requestDeclinedSpy = Sinon.spy();
|
||||
event.on('workspace.members.updated', updatedSpy);
|
||||
@@ -925,7 +924,7 @@ test('should trigger workspace.members.requestDeclined event when delete workspa
|
||||
);
|
||||
t.is(member.status, WorkspaceMemberStatus.NeedMoreSeatAndReview);
|
||||
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const event = t.context.module.get(EventBus);
|
||||
const updatedSpy = Sinon.spy();
|
||||
const requestDeclinedSpy = Sinon.spy();
|
||||
event.on('workspace.members.updated', updatedSpy);
|
||||
|
||||
@@ -105,18 +105,21 @@ function gql(app: INestApplication, query: string) {
|
||||
.expect(200);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
test.before(async ({ context }) => {
|
||||
const { app } = await createTestingApp({
|
||||
providers: [TestResolver, TestGateway],
|
||||
controllers: [TestController],
|
||||
});
|
||||
|
||||
context.logger = Sinon.stub(new Logger().localInstance);
|
||||
|
||||
context.app = app;
|
||||
});
|
||||
|
||||
test.afterEach.always(async ctx => {
|
||||
test.beforeEach(() => {
|
||||
Sinon.reset();
|
||||
});
|
||||
|
||||
test.after.always(async ctx => {
|
||||
await ctx.context.app.close();
|
||||
});
|
||||
|
||||
@@ -131,6 +134,7 @@ test('should be able to handle known user error in graphql query', async t => {
|
||||
t.is(err.message, 'You do not have permission to access this resource.');
|
||||
t.is(err.extensions.status, HttpStatus.FORBIDDEN);
|
||||
t.is(err.extensions.name, 'ACCESS_DENIED');
|
||||
// console.log(t.context.logger.error.getCalls());
|
||||
t.true(t.context.logger.error.notCalled);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import Sinon from 'sinon';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { EventEmitter, Runtime } from '../../base';
|
||||
import { EventBus, Runtime } from '../../base';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
@@ -158,7 +158,7 @@ const test = ava as TestFn<{
|
||||
db: PrismaClient;
|
||||
app: INestApplication;
|
||||
service: SubscriptionService;
|
||||
event: Sinon.SinonStubbedInstance<EventEmitter>;
|
||||
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||
feature: Sinon.SinonStubbedInstance<FeatureManagementService>;
|
||||
runtime: Sinon.SinonStubbedInstance<Runtime>;
|
||||
stripe: {
|
||||
@@ -203,14 +203,12 @@ test.before(async t => {
|
||||
m.overrideProvider(FeatureManagementService).useValue(
|
||||
Sinon.createStubInstance(FeatureManagementService)
|
||||
);
|
||||
m.overrideProvider(EventEmitter).useValue(
|
||||
Sinon.createStubInstance(EventEmitter)
|
||||
);
|
||||
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
|
||||
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
|
||||
},
|
||||
});
|
||||
|
||||
t.context.event = app.get(EventEmitter);
|
||||
t.context.event = app.get(EventBus);
|
||||
t.context.service = app.get(SubscriptionService);
|
||||
t.context.feature = app.get(FeatureManagementService);
|
||||
t.context.runtime = app.get(Runtime);
|
||||
|
||||
@@ -10,7 +10,7 @@ import ava from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { EventEmitter } from '../base';
|
||||
import { EventBus } from '../base';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { DocContentService } from '../core/doc-renderer';
|
||||
import { Permission, PermissionService } from '../core/permission';
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
auth: AuthService;
|
||||
event: Sinon.SinonStubbedInstance<EventEmitter>;
|
||||
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||
quota: QuotaService;
|
||||
quotaManager: QuotaManagementService;
|
||||
permissions: PermissionService;
|
||||
@@ -52,8 +52,8 @@ test.beforeEach(async t => {
|
||||
imports: [AppModule],
|
||||
tapModule: module => {
|
||||
module
|
||||
.overrideProvider(EventEmitter)
|
||||
.useValue(Sinon.createStubInstance(EventEmitter));
|
||||
.overrideProvider(EventBus)
|
||||
.useValue(Sinon.createStubInstance(EventBus));
|
||||
module.overrideProvider(DocContentService).useValue({
|
||||
getWorkspaceContent() {
|
||||
return {
|
||||
@@ -67,7 +67,7 @@ test.beforeEach(async t => {
|
||||
|
||||
t.context.app = app;
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.event = app.get(EventEmitter);
|
||||
t.context.event = app.get(EventBus);
|
||||
t.context.quota = app.get(QuotaService);
|
||||
t.context.quotaManager = app.get(QuotaManagementService);
|
||||
t.context.permissions = app.get(PermissionService);
|
||||
|
||||
@@ -112,7 +112,12 @@ export async function createTestingModule(
|
||||
|
||||
if (init) {
|
||||
await m.init();
|
||||
|
||||
// we got a lot smoking tests try to break nestjs
|
||||
// can't tolerate the noisy logs
|
||||
// @ts-expect-error private
|
||||
m.applyLogger({
|
||||
logger: ['fatal'],
|
||||
});
|
||||
const runtime = m.get(Runtime);
|
||||
// by pass password min length validation
|
||||
await runtime.set('auth/password.min', 1);
|
||||
@@ -128,7 +133,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
|
||||
cors: true,
|
||||
bodyParser: true,
|
||||
rawBody: true,
|
||||
logger: ['warn'],
|
||||
logger: ['fatal'],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||
|
||||
@@ -97,6 +97,7 @@ export const FunctionalityModules = [
|
||||
HelpersModule,
|
||||
ErrorModule,
|
||||
LoggerModule,
|
||||
WebSocketModule,
|
||||
];
|
||||
|
||||
function filterOptionalModule(
|
||||
@@ -197,7 +198,6 @@ export function buildAppModule() {
|
||||
// basic
|
||||
.use(...FunctionalityModules)
|
||||
.use(ModelsModule)
|
||||
.useIf(config => config.flavor.sync, WebSocketModule)
|
||||
|
||||
// auth
|
||||
.use(UserModule, AuthModule, PermissionModule)
|
||||
|
||||
@@ -45,10 +45,8 @@ export async function createApp() {
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||
app.use(cookieParser());
|
||||
|
||||
if (AFFiNE.flavor.sync) {
|
||||
const adapter = new SocketIoAdapter(app);
|
||||
app.useWebSocketAdapter(adapter);
|
||||
}
|
||||
const adapter = new SocketIoAdapter(app);
|
||||
app.useWebSocketAdapter(adapter);
|
||||
|
||||
if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) {
|
||||
const mixpanel = await import('mixpanel');
|
||||
|
||||
@@ -1,71 +1,18 @@
|
||||
import type { Snapshot, User, Workspace } from '@prisma/client';
|
||||
|
||||
import { Flatten, Payload } from './types';
|
||||
|
||||
export interface WorkspaceEvents {
|
||||
members: {
|
||||
reviewRequested: Payload<{ inviteId: string }>;
|
||||
requestDeclined: Payload<{
|
||||
userId: User['id'];
|
||||
workspaceId: Workspace['id'];
|
||||
}>;
|
||||
requestApproved: Payload<{ inviteId: string }>;
|
||||
roleChanged: Payload<{
|
||||
userId: User['id'];
|
||||
workspaceId: Workspace['id'];
|
||||
permission: number;
|
||||
}>;
|
||||
ownershipTransferred: Payload<{
|
||||
from: User['id'];
|
||||
to: User['id'];
|
||||
workspaceId: Workspace['id'];
|
||||
}>;
|
||||
ownershipReceived: Payload<{ workspaceId: Workspace['id'] }>;
|
||||
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
|
||||
leave: Payload<{
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
workspaceId: Workspace['id'];
|
||||
}>;
|
||||
removed: Payload<{ workspaceId: Workspace['id']; userId: User['id'] }>;
|
||||
};
|
||||
deleted: Payload<Workspace['id']>;
|
||||
blob: {
|
||||
deleted: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
key: string;
|
||||
}>;
|
||||
sync: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
key: string;
|
||||
}>;
|
||||
};
|
||||
declare global {
|
||||
/**
|
||||
* Event definitions can be extended by
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* declare global {
|
||||
* interface Events {
|
||||
* 'user.subscription.created': {
|
||||
* userId: User['id'];
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
interface Events {}
|
||||
}
|
||||
|
||||
export interface DocEvents {
|
||||
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
||||
updated: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event definitions can be extended by
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* declare module './event/def' {
|
||||
* interface UserEvents {
|
||||
* created: Payload<User>;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* assert<Event, 'user.created'>()
|
||||
*/
|
||||
export interface EventDefinitions {
|
||||
workspace: WorkspaceEvents;
|
||||
snapshot: DocEvents;
|
||||
}
|
||||
|
||||
export type EventKV = Flatten<EventDefinitions>;
|
||||
|
||||
export type Event = keyof EventKV;
|
||||
export type EventPayload<E extends Event> = EventKV[E];
|
||||
export type { Payload };
|
||||
export type EventName = keyof Events;
|
||||
|
||||
141
packages/backend/server/src/base/event/eventbus.ts
Normal file
141
packages/backend/server/src/base/event/eventbus.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
applyDecorators,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
EventEmitter2,
|
||||
EventEmitterReadinessWatcher,
|
||||
OnEvent as RawOnEvent,
|
||||
OnEventMetadata,
|
||||
} from '@nestjs/event-emitter';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
|
||||
import { CallMetric } from '../metrics';
|
||||
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;
|
||||
}
|
||||
|
||||
desc.value = function (...args: any[]) {
|
||||
new Logger(EventBus.name).log(
|
||||
`Event handler: ${event} (${key.toString()})`
|
||||
);
|
||||
return originalMethod.apply(this, args);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const OnEvent = (
|
||||
event: EventName,
|
||||
opts?: OnEventMetadata['options']
|
||||
) => {
|
||||
const namespace = event.split('.')[0];
|
||||
|
||||
return applyDecorators(
|
||||
EventHandlerWrapper(event),
|
||||
CallMetric('event', 'event_handler', undefined, { event, namespace }),
|
||||
RawOnEvent(event, opts)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* We use socket.io system to auto pub/sub on server to server broadcast events
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
namespace: 's2s',
|
||||
})
|
||||
@Injectable()
|
||||
export class EventBus implements OnGatewayConnection, OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(EventBus.name);
|
||||
|
||||
@WebSocketServer()
|
||||
private readonly server?: Server;
|
||||
|
||||
constructor(
|
||||
private readonly emitter: EventEmitter2,
|
||||
private readonly watcher: EventEmitterReadinessWatcher
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
// for internal usage only, disallow any connection from client
|
||||
this.logger.warn(
|
||||
`EventBus get suspicious connection from client ${client.id}, disconnecting...`
|
||||
);
|
||||
client.disconnect();
|
||||
}
|
||||
|
||||
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 => {
|
||||
this.logger.log(`Server Event: ${event} (Received)`);
|
||||
this.emit(event, payload);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// startup time promise, never throw at runtime
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to trigger all listeners on current instance
|
||||
*/
|
||||
async emitAsync<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.log(`Dispatch event: ${event} (async)`);
|
||||
return await this.emitter.emitAsync(event, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to trigger all listeners on current instance
|
||||
*/
|
||||
emit<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.log(`Dispatch event: ${event}`);
|
||||
return this.emitter.emit(event, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to trigger all listeners on all instance in cluster
|
||||
*/
|
||||
broadcast<T extends EventName>(event: T, payload: Events[T]) {
|
||||
this.logger.log(`Server Event: ${event} (Send)`);
|
||||
this.server?.serverSideEmit(event, payload);
|
||||
}
|
||||
|
||||
on<T extends EventName>(
|
||||
event: T,
|
||||
listener: (payload: Events[T]) => void | Promise<any>,
|
||||
opts?: OnEventMetadata['options']
|
||||
) {
|
||||
this.emitter.on(event, listener as any, opts);
|
||||
|
||||
return () => {
|
||||
this.emitter.off(event, listener as any);
|
||||
};
|
||||
}
|
||||
|
||||
waitFor<T extends EventName>(name: T, timeout?: number) {
|
||||
return this.emitter.waitFor(name, timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,14 @@
|
||||
import { Global, Injectable, Module } from '@nestjs/common';
|
||||
import {
|
||||
EventEmitter2,
|
||||
EventEmitterModule,
|
||||
OnEvent as RawOnEvent,
|
||||
} from '@nestjs/event-emitter';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
import type { Event, EventPayload } from './def';
|
||||
|
||||
@Injectable()
|
||||
export class EventEmitter {
|
||||
constructor(private readonly emitter: EventEmitter2) {}
|
||||
|
||||
emit<E extends Event>(event: E, payload: EventPayload<E>) {
|
||||
return this.emitter.emit(event, payload);
|
||||
}
|
||||
|
||||
emitAsync<E extends Event>(event: E, payload: EventPayload<E>) {
|
||||
return this.emitter.emitAsync(event, payload);
|
||||
}
|
||||
|
||||
on<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
|
||||
return this.emitter.on(event, handler);
|
||||
}
|
||||
|
||||
once<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
|
||||
return this.emitter.once(event, handler);
|
||||
}
|
||||
}
|
||||
|
||||
export const OnEvent = RawOnEvent as (
|
||||
event: Event,
|
||||
opts?: Parameters<typeof RawOnEvent>[1]
|
||||
) => MethodDecorator;
|
||||
import { EventBus, OnEvent } from './eventbus';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
providers: [EventEmitter],
|
||||
exports: [EventEmitter],
|
||||
imports: [EventEmitterModule.forRoot({ global: false })],
|
||||
providers: [EventBus],
|
||||
exports: [EventBus],
|
||||
})
|
||||
export class EventModule {}
|
||||
export { Event, EventPayload };
|
||||
|
||||
export { EventBus, OnEvent };
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Join, PathType } from '../utils/types';
|
||||
|
||||
export type Payload<T> = {
|
||||
__payload: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type Leaves<T, P extends string = ''> =
|
||||
T extends Record<string, any>
|
||||
? {
|
||||
[K in keyof T]: K extends string
|
||||
? T[K] extends Payload<any>
|
||||
? K
|
||||
: Join<K, Leaves<T[K], P>>
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export type Flatten<T extends Record<string, any>> = {
|
||||
// @ts-expect-error allow
|
||||
[K in Leaves<T>]: PathType<T, K> extends Payload<infer U> ? U : never;
|
||||
};
|
||||
@@ -14,7 +14,7 @@ export {
|
||||
getAFFiNEConfigModifier,
|
||||
} from './config';
|
||||
export * from './error';
|
||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||
export { EventBus, OnEvent } from './event';
|
||||
export type { GraphqlContext } from './graphql';
|
||||
export * from './guard';
|
||||
export { CryptoHelper, URLHelper } from './helpers';
|
||||
|
||||
@@ -37,7 +37,8 @@ export type KnownMetricScopes =
|
||||
| 'doc'
|
||||
| 'sse'
|
||||
| 'mail'
|
||||
| 'ai';
|
||||
| 'ai'
|
||||
| 'event';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Redis as IORedis, RedisOptions } from 'ioredis';
|
||||
|
||||
import { Config } from '../../base/config';
|
||||
|
||||
class Redis extends IORedis implements OnModuleDestroy {
|
||||
class Redis extends IORedis implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(this.constructor.name);
|
||||
constructor(opts: RedisOptions) {
|
||||
super(opts);
|
||||
}
|
||||
|
||||
errorHandler = (err: Error) => {
|
||||
this.logger.error(err);
|
||||
};
|
||||
|
||||
onModuleInit() {
|
||||
this.on('error', this.errorHandler);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
override duplicate(override?: Partial<RedisOptions>): IORedis {
|
||||
const client = super.duplicate(override);
|
||||
client.on('error', this.errorHandler);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { FlattenedAppRuntimeConfig } from '../config/types';
|
||||
import { OnEvent } from '../event';
|
||||
import { Payload } from '../event/def';
|
||||
|
||||
declare module '../event/def' {
|
||||
interface EventDefinitions {
|
||||
runtime: {
|
||||
[K in keyof FlattenedAppRuntimeConfig]: {
|
||||
changed: Payload<FlattenedAppRuntimeConfig[K]>;
|
||||
};
|
||||
};
|
||||
declare global {
|
||||
interface Events {
|
||||
'runtime.changed__NOT_IMPLEMENTED__': Partial<FlattenedAppRuntimeConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* not implemented yet
|
||||
*/
|
||||
export const OnRuntimeConfigChange_DO_NOT_USE = (
|
||||
nameWithModule: keyof FlattenedAppRuntimeConfig
|
||||
) => {
|
||||
return OnEvent(`runtime.${nameWithModule}.changed`);
|
||||
};
|
||||
|
||||
@@ -39,17 +39,18 @@ export class SocketIoAdapter extends IoAdapter {
|
||||
}
|
||||
|
||||
const pubClient = this.app.get(SocketIoRedis);
|
||||
|
||||
pubClient.on('error', err => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
const subClient = pubClient.duplicate();
|
||||
subClient.on('error', err => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
server.adapter(createAdapter(pubClient, subClient));
|
||||
const close = server.close;
|
||||
|
||||
server.close = async fn => {
|
||||
await close.call(server, fn);
|
||||
// NOTE(@forehalo):
|
||||
// the lifecycle of duplicated redis client will not be controlled by nestjs lifecycle
|
||||
// we've got to manually disconnect it
|
||||
subClient.disconnect();
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
import { Cache, type EventPayload, OnEvent } from '../../base';
|
||||
import { Cache, OnEvent } from '../../base';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../doc';
|
||||
import {
|
||||
type PageDocContent,
|
||||
@@ -78,15 +78,15 @@ export class DocContentService {
|
||||
return content;
|
||||
}
|
||||
|
||||
@OnEvent('snapshot.updated')
|
||||
@OnEvent('doc.snapshot.updated')
|
||||
async markDocContentCacheStale({
|
||||
workspaceId,
|
||||
id,
|
||||
}: EventPayload<'snapshot.updated'>) {
|
||||
docId,
|
||||
}: Events['doc.snapshot.updated']) {
|
||||
const key =
|
||||
workspaceId === id
|
||||
workspaceId === docId
|
||||
? `workspace:${workspaceId}:content`
|
||||
: `workspace:${workspaceId}:doc:${id}:content`;
|
||||
: `workspace:${workspaceId}:doc:${docId}:content`;
|
||||
await this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Cache,
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
EventEmitter,
|
||||
EventBus,
|
||||
FailedToSaveUpdates,
|
||||
FailedToUpsertSnapshot,
|
||||
metrics,
|
||||
@@ -22,7 +22,18 @@ import {
|
||||
} from '../storage';
|
||||
|
||||
const UPDATES_QUEUE_CACHE_KEY = 'doc:manager:updates';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'doc.snapshot.deleted': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
'doc.snapshot.updated': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
@Injectable()
|
||||
export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
private readonly logger = new Logger(PgWorkspaceDocStorageAdapter.name);
|
||||
@@ -31,7 +42,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
private readonly db: PrismaClient,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly cache: Cache,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly event: EventBus,
|
||||
protected override readonly options: DocStorageOptions
|
||||
) {
|
||||
super(options);
|
||||
@@ -470,9 +481,9 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
const updatedSnapshot = result.at(0);
|
||||
|
||||
if (updatedSnapshot) {
|
||||
this.event.emit('snapshot.updated', {
|
||||
this.event.emit('doc.snapshot.updated', {
|
||||
workspaceId: snapshot.spaceId,
|
||||
id: snapshot.docId,
|
||||
docId: snapshot.docId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@ import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
CallMetric,
|
||||
Config,
|
||||
type EventPayload,
|
||||
metrics,
|
||||
OnEvent,
|
||||
} from '../../base';
|
||||
import { CallMetric, Config, metrics, OnEvent } from '../../base';
|
||||
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
|
||||
|
||||
@Injectable()
|
||||
@@ -81,7 +75,7 @@ export class DocStorageCronJob implements OnModuleInit {
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async clearUserWorkspaces(payload: EventPayload<'user.deleted'>) {
|
||||
async clearUserWorkspaces(payload: Events['user.deleted']) {
|
||||
for (const workspace of payload.ownedWorkspaces) {
|
||||
await this.workspace.deleteSpace(workspace);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type EventPayload, OnEvent, Runtime } from '../../base';
|
||||
import { Runtime } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
@@ -167,9 +167,4 @@ export class FeatureManagementService {
|
||||
async listFeatureWorkspaces(feature: FeatureType) {
|
||||
return this.feature.listWorkspacesByFeature(feature);
|
||||
}
|
||||
|
||||
@OnEvent('user.admin.created')
|
||||
async onAdminUserCreated({ id }: EventPayload<'user.admin.created'>) {
|
||||
await this.addAdmin(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { groupBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
DocAccessDenied,
|
||||
EventEmitter,
|
||||
EventBus,
|
||||
SpaceAccessDenied,
|
||||
SpaceOwnerNotFound,
|
||||
} from '../../base';
|
||||
@@ -15,7 +15,7 @@ import { Permission, PublicPageMode } from './types';
|
||||
export class PermissionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly event: EventEmitter
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
private get acceptedCondition() {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
EventEmitter,
|
||||
InternalServerError,
|
||||
Mutex,
|
||||
PasswordRequired,
|
||||
@@ -24,7 +23,6 @@ export class CustomSetupController {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly auth: AuthService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly server: ServerService,
|
||||
private readonly runtime: Runtime
|
||||
@@ -62,7 +60,6 @@ export class CustomSetupController {
|
||||
if (!lock) {
|
||||
throw new InternalServerError();
|
||||
}
|
||||
|
||||
const user = await this.models.user.create({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
@@ -70,7 +67,12 @@ export class CustomSetupController {
|
||||
});
|
||||
|
||||
try {
|
||||
await this.event.emitAsync('user.admin.created', user);
|
||||
await this.models.userFeature.add(
|
||||
user.id,
|
||||
'administrator',
|
||||
'selfhost setup'
|
||||
);
|
||||
|
||||
await this.auth.setCookies(req, res, user.id);
|
||||
res.send({ id: user.id, email: user.email, name: user.name });
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
BlobInputType,
|
||||
EventPayload,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from '../../../base';
|
||||
@@ -47,7 +46,7 @@ export class AvatarStorage {
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: EventPayload<'user.deleted'>) {
|
||||
async onUserDeleted(user: Events['user.deleted']) {
|
||||
if (user.avatarUrl) {
|
||||
await this.delete(user.avatarUrl);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
autoMetadata,
|
||||
Config,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
EventBus,
|
||||
type GetObjectMetadata,
|
||||
ListObjectsMetadata,
|
||||
OnEvent,
|
||||
@@ -21,7 +20,7 @@ export class WorkspaceBlobStorage {
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly event: EventBus,
|
||||
private readonly storageFactory: StorageProviderFactory,
|
||||
private readonly db: PrismaClient
|
||||
) {
|
||||
@@ -105,7 +104,7 @@ export class WorkspaceBlobStorage {
|
||||
});
|
||||
|
||||
deletedBlobs.forEach(blob => {
|
||||
this.event.emit('workspace.blob.deleted', {
|
||||
this.event.emit('workspace.blob.delete', {
|
||||
workspaceId: workspaceId,
|
||||
key: blob.key,
|
||||
});
|
||||
@@ -152,10 +151,7 @@ export class WorkspaceBlobStorage {
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blob.sync')
|
||||
async syncBlobMeta({
|
||||
workspaceId,
|
||||
key,
|
||||
}: EventPayload<'workspace.blob.sync'>) {
|
||||
async syncBlobMeta({ workspaceId, key }: Events['workspace.blob.sync']) {
|
||||
try {
|
||||
const meta = await this.provider.head(`${workspaceId}/${key}`);
|
||||
|
||||
@@ -176,23 +172,23 @@ export class WorkspaceBlobStorage {
|
||||
}
|
||||
|
||||
@OnEvent('workspace.deleted')
|
||||
async onWorkspaceDeleted(workspaceId: EventPayload<'workspace.deleted'>) {
|
||||
const blobs = await this.list(workspaceId);
|
||||
async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
|
||||
const blobs = await this.list(id);
|
||||
|
||||
// to reduce cpu time holding
|
||||
blobs.forEach(blob => {
|
||||
this.event.emit('workspace.blob.deleted', {
|
||||
workspaceId: workspaceId,
|
||||
this.event.emit('workspace.blob.delete', {
|
||||
workspaceId: id,
|
||||
key: blob.key,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blob.deleted')
|
||||
@OnEvent('workspace.blob.delete')
|
||||
async onDeleteWorkspaceBlob({
|
||||
workspaceId,
|
||||
key,
|
||||
}: EventPayload<'workspace.blob.deleted'>) {
|
||||
}: Events['workspace.blob.delete']) {
|
||||
await this.delete(workspaceId, key, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, type EventPayload, OnEvent } from '../../base';
|
||||
import { Config, OnEvent } from '../../base';
|
||||
|
||||
@Injectable()
|
||||
export class UserEventsListener {
|
||||
@@ -9,7 +9,7 @@ export class UserEventsListener {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@OnEvent('user.updated')
|
||||
async onUserUpdated(user: EventPayload<'user.updated'>) {
|
||||
async onUserUpdated(user: Events['user.updated']) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
const payload = {
|
||||
@@ -33,7 +33,7 @@ export class UserEventsListener {
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: EventPayload<'user.deleted'>) {
|
||||
async onUserDeleted(user: Events['user.deleted']) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import type { Payload } from '../../base/event/def';
|
||||
import { type CurrentUser } from '../auth/session';
|
||||
|
||||
@ObjectType()
|
||||
@@ -91,11 +90,3 @@ export class ManageUserInput {
|
||||
@Field({ description: 'User name', nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
|
||||
declare module '../../base/event/def' {
|
||||
interface UserEvents {
|
||||
admin: {
|
||||
created: Payload<{ id: string }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
type EventPayload,
|
||||
MailService,
|
||||
OnEvent,
|
||||
URLHelper,
|
||||
@@ -256,7 +255,7 @@ export class WorkspaceService {
|
||||
async onMemberLeave({
|
||||
user,
|
||||
workspaceId,
|
||||
}: EventPayload<'workspace.members.leave'>) {
|
||||
}: Events['workspace.members.leave']) {
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
||||
await this.mailer.sendMemberLeaveEmail(owner.email, {
|
||||
@@ -269,7 +268,7 @@ export class WorkspaceService {
|
||||
async onMemberRemoved({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: EventPayload<'workspace.members.requestDeclined'>) {
|
||||
}: Events['workspace.members.requestDeclined']) {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user) return;
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
EventBus,
|
||||
MemberNotFoundInSpace,
|
||||
OnEvent,
|
||||
RequestMutex,
|
||||
@@ -43,7 +42,7 @@ export class TeamWorkspaceResolver {
|
||||
|
||||
constructor(
|
||||
private readonly cache: Cache,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
@@ -344,7 +343,7 @@ export class TeamWorkspaceResolver {
|
||||
@OnEvent('workspace.members.reviewRequested')
|
||||
async onReviewRequested({
|
||||
inviteId,
|
||||
}: EventPayload<'workspace.members.reviewRequested'>) {
|
||||
}: Events['workspace.members.reviewRequested']) {
|
||||
// send review request mail to owner and admin
|
||||
await this.workspaceService.sendReviewRequestedEmail(inviteId);
|
||||
}
|
||||
@@ -352,7 +351,7 @@ export class TeamWorkspaceResolver {
|
||||
@OnEvent('workspace.members.requestApproved')
|
||||
async onApproveRequest({
|
||||
inviteId,
|
||||
}: EventPayload<'workspace.members.requestApproved'>) {
|
||||
}: Events['workspace.members.requestApproved']) {
|
||||
// send approve mail
|
||||
await this.workspaceService.sendReviewApproveEmail(inviteId);
|
||||
}
|
||||
@@ -361,7 +360,7 @@ export class TeamWorkspaceResolver {
|
||||
async onDeclineRequest({
|
||||
userId,
|
||||
workspaceId,
|
||||
}: EventPayload<'workspace.members.requestDeclined'>) {
|
||||
}: Events['workspace.members.requestDeclined']) {
|
||||
const user = await this.models.user.getPublicUser(userId);
|
||||
// send decline mail
|
||||
await this.workspaceService.sendReviewDeclinedEmail(
|
||||
@@ -375,7 +374,7 @@ export class TeamWorkspaceResolver {
|
||||
userId,
|
||||
workspaceId,
|
||||
permission,
|
||||
}: EventPayload<'workspace.members.roleChanged'>) {
|
||||
}: Events['workspace.members.roleChanged']) {
|
||||
// send role changed mail
|
||||
await this.workspaceService.sendRoleChangedEmail(userId, {
|
||||
id: workspaceId,
|
||||
@@ -388,7 +387,7 @@ export class TeamWorkspaceResolver {
|
||||
workspaceId,
|
||||
from,
|
||||
to,
|
||||
}: EventPayload<'workspace.members.ownershipTransferred'>) {
|
||||
}: Events['workspace.members.ownershipTransferred']) {
|
||||
// send ownership transferred mail
|
||||
const fromUser = await this.models.user.getPublicUser(from);
|
||||
const toUser = await this.models.user.getPublicUser(to);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
AlreadyInSpace,
|
||||
Cache,
|
||||
DocNotFound,
|
||||
EventEmitter,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
MemberQuotaExceeded,
|
||||
QueryTooLong,
|
||||
@@ -83,7 +83,7 @@ export class WorkspaceResolver {
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly models: Models,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly event: EventBus,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter
|
||||
@@ -389,7 +389,7 @@ export class WorkspaceResolver {
|
||||
});
|
||||
await this.workspaceStorage.deleteSpace(id);
|
||||
|
||||
this.event.emit('workspace.deleted', id);
|
||||
this.event.emit('workspace.deleted', { id });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import { pick } from 'lodash-es';
|
||||
import {
|
||||
CryptoHelper,
|
||||
EmailAlreadyUsed,
|
||||
EventEmitter,
|
||||
EventBus,
|
||||
WrongSignInCredentials,
|
||||
WrongSignInMethod,
|
||||
} from '../base';
|
||||
import type { Payload } from '../base/event/def';
|
||||
import { Quota_FreePlanV1_1 } from '../core/quota/schema';
|
||||
import { BaseModel } from './base';
|
||||
import type { Workspace } from './workspace';
|
||||
@@ -40,21 +39,15 @@ const defaultUserCreatingData = {
|
||||
},
|
||||
};
|
||||
|
||||
declare module '../base/event/def' {
|
||||
interface UserEvents {
|
||||
created: Payload<User>;
|
||||
updated: Payload<User>;
|
||||
deleted: Payload<
|
||||
User & {
|
||||
// TODO(@forehalo): unlink foreign key constraint on [WorkspaceUserPermission] to delegate
|
||||
// dealing of owned workspaces of deleted users to workspace model
|
||||
ownedWorkspaces: Workspace['id'][];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface EventDefinitions {
|
||||
user: UserEvents;
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.created': User;
|
||||
'user.updated': User;
|
||||
'user.deleted': User & {
|
||||
// TODO(@forehalo): unlink foreign key constraint on [WorkspaceUserPermission] to delegate
|
||||
// dealing of owned workspaces of deleted users to workspace model
|
||||
ownedWorkspaces: Workspace['id'][];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +58,7 @@ export type { User };
|
||||
export class UserModel extends BaseModel {
|
||||
constructor(
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly event: EventEmitter
|
||||
private readonly event: EventBus
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,57 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { EventEmitter } from '../base';
|
||||
import { EventBus } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import { Permission } from './common';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'workspace.members.reviewRequested': { inviteId: string };
|
||||
'workspace.members.requestDeclined': {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
'workspace.members.requestApproved': { inviteId: string };
|
||||
'workspace.members.roleChanged': {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
permission: number;
|
||||
};
|
||||
'workspace.members.ownershipTransferred': {
|
||||
from: string;
|
||||
to: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
'workspace.members.updated': {
|
||||
workspaceId: string;
|
||||
count: number;
|
||||
};
|
||||
'workspace.members.leave': {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
workspaceId: string;
|
||||
};
|
||||
'workspace.members.removed': {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
};
|
||||
'workspace.deleted': {
|
||||
id: string;
|
||||
};
|
||||
'workspace.blob.delete': {
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
};
|
||||
'workspace.blob.sync': {
|
||||
workspaceId: string;
|
||||
key: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { WorkspaceMemberStatus };
|
||||
export type { Workspace };
|
||||
export type UpdateWorkspaceInput = Pick<
|
||||
@@ -28,7 +75,7 @@ export interface FindWorkspaceMembersOptions {
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceModel extends BaseModel {
|
||||
constructor(private readonly event: EventEmitter) {
|
||||
constructor(private readonly event: EventBus) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InstalledLicense, PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
Config,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
LicenseNotFound,
|
||||
OnEvent,
|
||||
@@ -27,9 +27,10 @@ export class LicenseService {
|
||||
private readonly logger = new Logger(LicenseService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly event: EventBus,
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
@@ -151,7 +152,11 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated')
|
||||
async updateTeamSeats(payload: EventPayload<'workspace.members.updated'>) {
|
||||
async updateTeamSeats(payload: Events['workspace.members.updated']) {
|
||||
if (!this.config.isSelfhosted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { workspaceId, count } = payload;
|
||||
|
||||
const license = await this.db.installedLicense.findUnique({
|
||||
@@ -308,7 +313,7 @@ export class LicenseService {
|
||||
plan,
|
||||
recurring,
|
||||
quantity,
|
||||
}: EventPayload<'workspace.subscription.activated'>) {
|
||||
}: Events['workspace.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.SelfHostedTeam:
|
||||
await this.quota.addTeamWorkspace(
|
||||
@@ -331,7 +336,7 @@ export class LicenseService {
|
||||
async onWorkspaceSubscriptionCanceled({
|
||||
workspaceId,
|
||||
plan,
|
||||
}: EventPayload<'workspace.subscription.canceled'>) {
|
||||
}: Events['workspace.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.SelfHostedTeam:
|
||||
await this.quota.removeTeamWorkspace(workspaceId);
|
||||
|
||||
@@ -2,11 +2,10 @@ import assert from 'node:assert';
|
||||
|
||||
import type { RawBodyRequest } from '@nestjs/common';
|
||||
import { Controller, Logger, Post, Req } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { Request } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config, InternalServerError } from '../../base';
|
||||
import { Config, EventBus, InternalServerError } from '../../base';
|
||||
import { Public } from '../../core/auth';
|
||||
|
||||
@Controller('/api/stripe')
|
||||
@@ -17,7 +16,7 @@ export class StripeWebhookController {
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly event: EventEmitter2
|
||||
private readonly event: EventBus
|
||||
) {
|
||||
assert(config.plugins.payment.stripe);
|
||||
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
|
||||
@@ -41,7 +40,7 @@ export class StripeWebhookController {
|
||||
|
||||
// Stripe requires responseing webhook immediately and handle event asynchronously.
|
||||
setImmediate(() => {
|
||||
this.event.emitAsync(`stripe:${event.type}`, event).catch(e => {
|
||||
this.event.emitAsync(`stripe.${event.type}` as any, event).catch(e => {
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { EventEmitter, type EventPayload } from '../../base';
|
||||
import { EventBus } from '../../base';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
export class SubscriptionCronJobs {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly event: EventEmitter
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
private getDateRange(after: number, base: number | Date = Date.now()) {
|
||||
@@ -77,14 +77,14 @@ export class SubscriptionCronJobs {
|
||||
// should not reach here
|
||||
continue;
|
||||
}
|
||||
this.event.emit('workspace.subscription.notify', {
|
||||
workspaceId: subscription.targetId,
|
||||
expirationDate: end,
|
||||
deletionDate:
|
||||
subscription.status === 'canceled'
|
||||
? this.getDateRange(180, end).end
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!subscription.nextBillAt) {
|
||||
this.event.emit('workspace.subscription.notify', {
|
||||
workspaceId: subscription.targetId,
|
||||
expirationDate: end,
|
||||
deletionDate: this.getDateRange(180, end).end,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export class SubscriptionCronJobs {
|
||||
async handleUserSubscriptionCanceled({
|
||||
userId,
|
||||
plan,
|
||||
}: EventPayload<'user.subscription.canceled'>) {
|
||||
}: Events['user.subscription.canceled']) {
|
||||
await this.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
EventEmitter,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
InvalidCheckoutParameters,
|
||||
Runtime,
|
||||
@@ -58,7 +58,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
db: PrismaClient,
|
||||
private readonly runtime: Runtime,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper
|
||||
) {
|
||||
super(stripe, db);
|
||||
|
||||
@@ -5,8 +5,7 @@ import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
EventBus,
|
||||
OnEvent,
|
||||
SubscriptionAlreadyExists,
|
||||
SubscriptionPlanNotFound,
|
||||
@@ -49,7 +48,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
stripe: Stripe,
|
||||
db: PrismaClient,
|
||||
private readonly url: URLHelper,
|
||||
private readonly event: EventEmitter
|
||||
private readonly event: EventBus
|
||||
) {
|
||||
super(stripe, db);
|
||||
}
|
||||
@@ -269,7 +268,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
async onMembersUpdated({
|
||||
workspaceId,
|
||||
count,
|
||||
}: EventPayload<'workspace.members.updated'>) {
|
||||
}: Events['workspace.members.updated']) {
|
||||
const subscription = await this.getSubscription({
|
||||
plan: SubscriptionPlan.Team,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
|
||||
import type { EventPayload } from '../../base';
|
||||
import { FeatureManagementService } from '../../core/features';
|
||||
import { PermissionService } from '../../core/permission';
|
||||
import {
|
||||
@@ -29,7 +28,7 @@ export class QuotaOverride {
|
||||
plan,
|
||||
recurring,
|
||||
quantity,
|
||||
}: EventPayload<'workspace.subscription.activated'>) {
|
||||
}: Events['workspace.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case 'team': {
|
||||
const hasTeamWorkspace = await this.quota.hasWorkspaceQuota(
|
||||
@@ -62,7 +61,7 @@ export class QuotaOverride {
|
||||
async onWorkspaceSubscriptionCanceled({
|
||||
workspaceId,
|
||||
plan,
|
||||
}: EventPayload<'workspace.subscription.canceled'>) {
|
||||
}: Events['workspace.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.Team:
|
||||
await this.manager.removeTeamWorkspace(workspaceId);
|
||||
@@ -77,7 +76,7 @@ export class QuotaOverride {
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: EventPayload<'user.subscription.activated'>) {
|
||||
}: Events['user.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.feature.addCopilot(userId, 'subscription activated');
|
||||
@@ -100,7 +99,7 @@ export class QuotaOverride {
|
||||
async onUserSubscriptionCanceled({
|
||||
userId,
|
||||
plan,
|
||||
}: EventPayload<'user.subscription.canceled'>) {
|
||||
}: Events['user.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.feature.removeCopilot(userId);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { User, Workspace } from '@prisma/client';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import type { Payload } from '../../base/event/def';
|
||||
|
||||
export enum SubscriptionRecurring {
|
||||
Monthly = 'monthly',
|
||||
Yearly = 'yearly',
|
||||
@@ -50,41 +48,44 @@ export enum CouponType {
|
||||
ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free',
|
||||
}
|
||||
|
||||
declare module '../../base/event/def' {
|
||||
interface UserEvents {
|
||||
subscription: {
|
||||
activated: Payload<{
|
||||
userId: User['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
}>;
|
||||
canceled: Payload<{
|
||||
userId: User['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
}>;
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.subscription.activated': {
|
||||
userId: User['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
};
|
||||
'user.subscription.canceled': {
|
||||
userId: User['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceEvents {
|
||||
subscription: {
|
||||
activated: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
}>;
|
||||
canceled: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
}>;
|
||||
notify: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
expirationDate: Date;
|
||||
deletionDate: Date | undefined;
|
||||
}>;
|
||||
'workspace.subscription.activated': {
|
||||
workspaceId: Workspace['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
};
|
||||
'workspace.subscription.canceled': {
|
||||
workspaceId: Workspace['id'];
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
};
|
||||
'workspace.subscription.notify': {
|
||||
workspaceId: Workspace['id'];
|
||||
expirationDate: Date;
|
||||
deletionDate: Date;
|
||||
};
|
||||
|
||||
'stripe.invoice.created': Stripe.InvoiceCreatedEvent;
|
||||
'stripe.invoice.updated': Stripe.InvoiceUpdatedEvent;
|
||||
'stripe.invoice.finalization_failed': Stripe.InvoiceFinalizationFailedEvent;
|
||||
'stripe.invoice.payment_failed': Stripe.InvoicePaymentFailedEvent;
|
||||
'stripe.invoice.paid': Stripe.InvoicePaidEvent;
|
||||
'stripe.customer.subscription.created': Stripe.CustomerSubscriptionCreatedEvent;
|
||||
'stripe.customer.subscription.updated': Stripe.CustomerSubscriptionUpdatedEvent;
|
||||
'stripe.customer.subscription.deleted': Stripe.CustomerSubscriptionDeletedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { OnEvent } from '../../base';
|
||||
import { SubscriptionService } from './service';
|
||||
|
||||
const OnStripeEvent = (
|
||||
event: Stripe.Event.Type,
|
||||
opts?: Parameters<typeof OnEvent>[1]
|
||||
) => OnEvent(`stripe:${event}`, opts);
|
||||
|
||||
/**
|
||||
* Stripe webhook events sent in random order, and may be even sent more than once.
|
||||
*
|
||||
@@ -22,11 +17,11 @@ export class StripeWebhook {
|
||||
private readonly stripe: Stripe
|
||||
) {}
|
||||
|
||||
@OnStripeEvent('invoice.created')
|
||||
@OnStripeEvent('invoice.updated')
|
||||
@OnStripeEvent('invoice.finalization_failed')
|
||||
@OnStripeEvent('invoice.payment_failed')
|
||||
@OnStripeEvent('invoice.paid')
|
||||
@OnEvent('stripe.invoice.created')
|
||||
@OnEvent('stripe.invoice.updated')
|
||||
@OnEvent('stripe.invoice.finalization_failed')
|
||||
@OnEvent('stripe.invoice.payment_failed')
|
||||
@OnEvent('stripe.invoice.paid')
|
||||
async onInvoiceUpdated(
|
||||
event:
|
||||
| Stripe.InvoiceCreatedEvent
|
||||
@@ -39,8 +34,8 @@ export class StripeWebhook {
|
||||
await this.service.saveStripeInvoice(invoice);
|
||||
}
|
||||
|
||||
@OnStripeEvent('customer.subscription.created')
|
||||
@OnStripeEvent('customer.subscription.updated')
|
||||
@OnEvent('stripe.customer.subscription.created')
|
||||
@OnEvent('stripe.customer.subscription.updated')
|
||||
async onSubscriptionChanges(
|
||||
event:
|
||||
| Stripe.CustomerSubscriptionUpdatedEvent
|
||||
@@ -56,7 +51,7 @@ export class StripeWebhook {
|
||||
await this.service.saveStripeSubscription(subscription);
|
||||
}
|
||||
|
||||
@OnStripeEvent('customer.subscription.deleted')
|
||||
@OnEvent('stripe.customer.subscription.deleted')
|
||||
async onSubscriptionDeleted(event: Stripe.CustomerSubscriptionDeletedEvent) {
|
||||
await this.service.deleteStripeSubscription(event.data.object);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user