Skip to content

Commit 2bf8027

Browse files
authored
feat: billing (#672)
* feat: space supports displaying the plan level * chore: update icons and table component * feat: add the PAYMENT_REQUIRED http code * feat: admin user & setting config * feat: usage limit * feat: add paste checker for usage * chore: db migration * feat: user limit for license * feat: admin settings * refactor: use generics as the type for the custom ssrApi * fix: type error * fix: setting for disallow signup * refactor: obtain the settings from the database instead of from cls
1 parent 13b4463 commit 2bf8027

File tree

86 files changed

+1429
-118
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+1429
-118
lines changed

apps/nestjs-backend/src/app.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { NextModule } from './features/next/next.module';
1616
import { NotificationModule } from './features/notification/notification.module';
1717
import { PinModule } from './features/pin/pin.module';
1818
import { SelectionModule } from './features/selection/selection.module';
19+
import { SettingModule } from './features/setting/setting.module';
1920
import { ShareModule } from './features/share/share.module';
2021
import { SpaceModule } from './features/space/space.module';
2122
import { UserModule } from './features/user/user.module';
@@ -47,6 +48,7 @@ export const appModules = {
4748
ImportOpenApiModule,
4849
ExportOpenApiModule,
4950
PinModule,
51+
SettingModule,
5052
],
5153
providers: [InitBootstrapProvider],
5254
};

apps/nestjs-backend/src/configs/base.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ConfigType } from '@nestjs/config';
44
import { registerAs } from '@nestjs/config';
55

66
export const baseConfig = registerAs('base', () => ({
7+
isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD',
78
brandName: process.env.BRAND_NAME!,
89
publicOrigin: process.env.PUBLIC_ORIGIN!,
910
storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN!,

apps/nestjs-backend/src/custom.exception.ts

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const getDefaultCodeByStatus = (status: HttpStatus) => {
1616
return HttpErrorCode.VALIDATION_ERROR;
1717
case HttpStatus.UNAUTHORIZED:
1818
return HttpErrorCode.UNAUTHORIZED;
19+
case HttpStatus.PAYMENT_REQUIRED:
20+
return HttpErrorCode.PAYMENT_REQUIRED;
1921
case HttpStatus.FORBIDDEN:
2022
return HttpErrorCode.RESTRICTED_RESOURCE;
2123
case HttpStatus.NOT_FOUND:

apps/nestjs-backend/src/event-emitter/events/event.enum.ts

+3
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ export enum Events {
4040
// USER_PASSWORD_RESET = 'user.password.reset',
4141
USER_PASSWORD_CHANGE = 'user.password.change',
4242
// USER_PASSWORD_FORGOT = 'user.password.forgot'
43+
44+
COLLABORATOR_CREATE = 'collaborator.create',
45+
COLLABORATOR_DELETE = 'collaborator.delete',
4346
}

apps/nestjs-backend/src/event-emitter/events/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from './core-event';
33
export * from './op-event';
44
export * from './base/base.event';
55
export * from './space/space.event';
6+
export * from './space/collaborator.event';
67
export * from './table';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Events } from '../event.enum';
2+
3+
export class CollaboratorCreateEvent {
4+
public readonly name = Events.COLLABORATOR_CREATE;
5+
6+
constructor(public readonly spaceId: string) {}
7+
}
8+
9+
export class CollaboratorDeleteEvent {
10+
public readonly name = Events.COLLABORATOR_DELETE;
11+
12+
constructor(public readonly spaceId: string) {}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Events } from '../event.enum';
2+
3+
export class UserSignUpEvent {
4+
public readonly name = Events.USER_SIGNUP;
5+
6+
constructor(public readonly userId: string) {}
7+
}

apps/nestjs-backend/src/features/auth/strategies/access-token.strategy.ts

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStr
3232
if (!user) {
3333
throw new UnauthorizedException();
3434
}
35+
if (user.deactivatedTime) {
36+
throw new UnauthorizedException('Your account has been deactivated by the administrator');
37+
}
3538

3639
this.cls.set('user.id', user.id);
3740
this.cls.set('user.name', user.name);

apps/nestjs-backend/src/features/auth/strategies/github.strategy.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, UnauthorizedException } from '@nestjs/common';
1+
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
22
import { ConfigType } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
44
import type { Profile } from 'passport-github2';
@@ -42,6 +42,9 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
4242
if (!user) {
4343
throw new UnauthorizedException('Failed to create user from GitHub profile');
4444
}
45+
if (user.deactivatedTime) {
46+
throw new BadRequestException('Your account has been deactivated by the administrator');
47+
}
4548
await this.userService.refreshLastSignTime(user.id);
4649
return pickUserMe(user);
4750
}

apps/nestjs-backend/src/features/auth/strategies/google.strategy.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, UnauthorizedException } from '@nestjs/common';
1+
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
22
import { ConfigType } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
44
import type { Profile } from 'passport-google-oauth20';
@@ -44,6 +44,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
4444
if (!user) {
4545
throw new UnauthorizedException('Failed to create user from Google profile');
4646
}
47+
if (user.deactivatedTime) {
48+
throw new BadRequestException('Your account has been deactivated by the administrator');
49+
}
4750
await this.userService.refreshLastSignTime(user.id);
4851
return pickUserMe(user);
4952
}

apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
2222
if (!user) {
2323
throw new BadRequestException('Incorrect password.');
2424
}
25+
if (user.deactivatedTime) {
26+
throw new BadRequestException('Your account has been deactivated by the administrator');
27+
}
2528
await this.userService.refreshLastSignTime(user.id);
2629
return pickUserMe(user);
2730
}

apps/nestjs-backend/src/features/auth/strategies/session.strategy.ts

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ export class SessionStrategy extends PassportStrategy(PassportSessionStrategy) {
2525
if (!user) {
2626
throw new UnauthorizedException();
2727
}
28+
if (user.deactivatedTime) {
29+
throw new UnauthorizedException('Your account has been deactivated by the administrator');
30+
}
2831
this.cls.set('user.id', user.id);
2932
this.cls.set('user.name', user.name);
3033
this.cls.set('user.email', user.email);

apps/nestjs-backend/src/features/auth/utils.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { getFullStorageUrl } from '../../utils/full-storage-url';
66
export const pickUserMe = (
77
user: Pick<
88
Prisma.UserGetPayload<null>,
9-
'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta'
9+
'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta' | 'isAdmin'
1010
>
1111
): IUserMeVo => {
1212
return {
13-
...pick(user, 'id', 'name', 'phone', 'email'),
13+
...pick(user, 'id', 'name', 'phone', 'email', 'isAdmin'),
1414
notifyMeta: typeof user.notifyMeta === 'object' ? user.notifyMeta : JSON.parse(user.notifyMeta),
1515
avatar:
1616
user.avatar && !user.avatar?.startsWith('http')

apps/nestjs-backend/src/features/collaborator/collaborator.service.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { Knex } from 'knex';
1010
import { isDate } from 'lodash';
1111
import { InjectModel } from 'nest-knexjs';
1212
import { ClsService } from 'nestjs-cls';
13+
import { EventEmitterService } from '../../event-emitter/event-emitter.service';
14+
import {
15+
CollaboratorCreateEvent,
16+
CollaboratorDeleteEvent,
17+
Events,
18+
} from '../../event-emitter/events';
1319
import type { IClsStore } from '../../types/cls';
1420
import { getFullStorageUrl } from '../../utils/full-storage-url';
1521

@@ -18,6 +24,7 @@ export class CollaboratorService {
1824
constructor(
1925
private readonly prismaService: PrismaService,
2026
private readonly cls: ClsService<IClsStore>,
27+
private readonly eventEmitterService: EventEmitterService,
2128
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
2229
) {}
2330

@@ -29,14 +36,19 @@ export class CollaboratorService {
2936
if (exist) {
3037
throw new BadRequestException('has already existed in space');
3138
}
32-
return await this.prismaService.txClient().collaborator.create({
39+
const collaborator = await this.prismaService.txClient().collaborator.create({
3340
data: {
3441
spaceId,
3542
roleName: role,
3643
userId,
3744
createdBy: currentUserId,
3845
},
3946
});
47+
this.eventEmitterService.emitAsync(
48+
Events.COLLABORATOR_CREATE,
49+
new CollaboratorCreateEvent(spaceId)
50+
);
51+
return collaborator;
4052
}
4153

4254
async deleteBySpaceId(spaceId: string) {
@@ -121,7 +133,7 @@ export class CollaboratorService {
121133
}
122134

123135
async deleteCollaborator(spaceId: string, userId: string) {
124-
return await this.prismaService.txClient().collaborator.updateMany({
136+
const result = await this.prismaService.txClient().collaborator.updateMany({
125137
where: {
126138
spaceId,
127139
userId,
@@ -130,6 +142,11 @@ export class CollaboratorService {
130142
deletedTime: new Date().toISOString(),
131143
},
132144
});
145+
this.eventEmitterService.emitAsync(
146+
Events.COLLABORATOR_DELETE,
147+
new CollaboratorDeleteEvent(spaceId)
148+
);
149+
return result;
133150
}
134151

135152
async updateCollaborator(spaceId: string, updateCollaborator: UpdateSpaceCollaborateRo) {

apps/nestjs-backend/src/features/next/next.controller.ts

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class NextController {
2626
'invite/?*',
2727
'share/?*',
2828
'setting/?*',
29+
'admin/?*',
2930
])
3031
public async home(@Req() req: express.Request, @Res() res: express.Response) {
3132
await this.nextService.server.getRequestHandler()(req, res);

apps/nestjs-backend/src/features/selection/selection.service.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,11 @@ export class SelectionService {
649649
return [range[0], range[1]];
650650
}
651651

652-
async paste(tableId: string, pasteRo: IPasteRo) {
652+
async paste(
653+
tableId: string,
654+
pasteRo: IPasteRo,
655+
expansionChecker?: (col: number, row: number) => Promise<void>
656+
) {
653657
const { content, header = [], ...rangesRo } = pasteRo;
654658
const { ranges, type, ...queryRo } = rangesRo;
655659
const { viewId } = queryRo;
@@ -702,6 +706,7 @@ export class SelectionService {
702706
tableColCount,
703707
tableRowCount,
704708
]);
709+
await expansionChecker?.(numColsToExpand, numRowsToExpand);
705710

706711
const updateRange: IPasteVo['ranges'] = [cell, cell];
707712

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { CanActivate } from '@nestjs/common';
2+
import { ForbiddenException, Injectable } from '@nestjs/common';
3+
import { PrismaService } from '@teable/db-main-prisma';
4+
import { ClsService } from 'nestjs-cls';
5+
import type { IClsStore } from '../../types/cls';
6+
7+
@Injectable()
8+
export class AdminGuard implements CanActivate {
9+
constructor(
10+
private readonly cls: ClsService<IClsStore>,
11+
private readonly prismaService: PrismaService
12+
) {}
13+
14+
async canActivate() {
15+
const userId = this.cls.get('user.id');
16+
17+
const user = await this.prismaService.user.findUnique({
18+
where: { id: userId, deletedTime: null, deactivatedTime: null },
19+
});
20+
21+
if (!user || !user.isAdmin) {
22+
throw new ForbiddenException('User is not an admin');
23+
}
24+
25+
return true;
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
2+
import { IUpdateSettingRo, updateSettingRoSchema } from '@teable/openapi';
3+
import type { ISettingVo } from '@teable/openapi';
4+
import { ZodValidationPipe } from '../../zod.validation.pipe';
5+
import { Public } from '../auth/decorators/public.decorator';
6+
import { AdminGuard } from './admin.guard';
7+
import { SettingService } from './setting.service';
8+
9+
@Controller('api/admin/setting')
10+
export class SettingController {
11+
constructor(private readonly settingService: SettingService) {}
12+
13+
@Public()
14+
@Get()
15+
async getSetting(): Promise<ISettingVo> {
16+
return await this.settingService.getSetting();
17+
}
18+
19+
@UseGuards(AdminGuard)
20+
@Patch()
21+
async updateSetting(
22+
@Body(new ZodValidationPipe(updateSettingRoSchema))
23+
updateSettingRo: IUpdateSettingRo
24+
): Promise<ISettingVo> {
25+
return await this.settingService.updateSetting(updateSettingRo);
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { AdminGuard } from './admin.guard';
3+
import { SettingController } from './setting.controller';
4+
import { SettingService } from './setting.service';
5+
6+
@Module({
7+
controllers: [SettingController],
8+
exports: [SettingService],
9+
providers: [SettingService, AdminGuard],
10+
})
11+
export class SettingModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Injectable, NotFoundException } from '@nestjs/common';
2+
import { PrismaService } from '@teable/db-main-prisma';
3+
import type { ISettingVo, IUpdateSettingRo } from '@teable/openapi';
4+
5+
@Injectable()
6+
export class SettingService {
7+
constructor(private readonly prismaService: PrismaService) {}
8+
9+
async getSetting(): Promise<ISettingVo> {
10+
return await this.prismaService.setting
11+
.findFirstOrThrow({
12+
select: {
13+
instanceId: true,
14+
disallowSignUp: true,
15+
disallowSpaceCreation: true,
16+
},
17+
})
18+
.catch(() => {
19+
throw new NotFoundException('Setting not found');
20+
});
21+
}
22+
23+
async updateSetting(updateSettingRo: IUpdateSettingRo) {
24+
const setting = await this.getSetting();
25+
return await this.prismaService.setting.update({
26+
where: { instanceId: setting.instanceId },
27+
data: updateSettingRo,
28+
});
29+
}
30+
}

apps/nestjs-backend/src/features/space/space.service.ts

+12
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ export class SpaceService {
9898

9999
async createSpace(createSpaceRo: ICreateSpaceRo) {
100100
const userId = this.cls.get('user.id');
101+
const setting = await this.prismaService.setting.findFirst({
102+
select: {
103+
disallowSignUp: true,
104+
disallowSpaceCreation: true,
105+
},
106+
});
107+
108+
if (setting?.disallowSpaceCreation) {
109+
throw new ForbiddenException(
110+
'The current instance disallow space creation by the administrator'
111+
);
112+
}
101113

102114
const spaceList = await this.prismaService.space.findMany({
103115
where: { deletedTime: null, createdBy: userId },

0 commit comments

Comments
 (0)