feat(server): cluster level event system (#9884)

This commit is contained in:
forehalo
2025-01-25 14:51:03 +00:00
parent 0d2c2ea21e
commit 6370f45928
43 changed files with 634 additions and 364 deletions

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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: {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);
}