Skip to content

Commit

Permalink
feat: billing (#672)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Sky-FE authored Jun 28, 2024
1 parent 13b4463 commit 2bf8027
Show file tree
Hide file tree
Showing 86 changed files with 1,429 additions and 118 deletions.
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NextModule } from './features/next/next.module';
import { NotificationModule } from './features/notification/notification.module';
import { PinModule } from './features/pin/pin.module';
import { SelectionModule } from './features/selection/selection.module';
import { SettingModule } from './features/setting/setting.module';
import { ShareModule } from './features/share/share.module';
import { SpaceModule } from './features/space/space.module';
import { UserModule } from './features/user/user.module';
Expand Down Expand Up @@ -47,6 +48,7 @@ export const appModules = {
ImportOpenApiModule,
ExportOpenApiModule,
PinModule,
SettingModule,
],
providers: [InitBootstrapProvider],
};
Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/configs/base.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';

export const baseConfig = registerAs('base', () => ({
isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD',
brandName: process.env.BRAND_NAME!,
publicOrigin: process.env.PUBLIC_ORIGIN!,
storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN!,
Expand Down
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/custom.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const getDefaultCodeByStatus = (status: HttpStatus) => {
return HttpErrorCode.VALIDATION_ERROR;
case HttpStatus.UNAUTHORIZED:
return HttpErrorCode.UNAUTHORIZED;
case HttpStatus.PAYMENT_REQUIRED:
return HttpErrorCode.PAYMENT_REQUIRED;
case HttpStatus.FORBIDDEN:
return HttpErrorCode.RESTRICTED_RESOURCE;
case HttpStatus.NOT_FOUND:
Expand Down
3 changes: 3 additions & 0 deletions apps/nestjs-backend/src/event-emitter/events/event.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ export enum Events {
// USER_PASSWORD_RESET = 'user.password.reset',
USER_PASSWORD_CHANGE = 'user.password.change',
// USER_PASSWORD_FORGOT = 'user.password.forgot'

COLLABORATOR_CREATE = 'collaborator.create',
COLLABORATOR_DELETE = 'collaborator.delete',
}
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/event-emitter/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './core-event';
export * from './op-event';
export * from './base/base.event';
export * from './space/space.event';
export * from './space/collaborator.event';
export * from './table';
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Events } from '../event.enum';

export class CollaboratorCreateEvent {
public readonly name = Events.COLLABORATOR_CREATE;

constructor(public readonly spaceId: string) {}
}

export class CollaboratorDeleteEvent {
public readonly name = Events.COLLABORATOR_DELETE;

constructor(public readonly spaceId: string) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Events } from '../event.enum';

export class UserSignUpEvent {
public readonly name = Events.USER_SIGNUP;

constructor(public readonly userId: string) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export class AccessTokenStrategy extends PassportStrategy(PassportAccessTokenStr
if (!user) {
throw new UnauthorizedException();
}
if (user.deactivatedTime) {
throw new UnauthorizedException('Your account has been deactivated by the administrator');
}

this.cls.set('user.id', user.id);
this.cls.set('user.name', user.name);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { Profile } from 'passport-github2';
Expand Down Expand Up @@ -42,6 +42,9 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
if (!user) {
throw new UnauthorizedException('Failed to create user from GitHub profile');
}
if (user.deactivatedTime) {
throw new BadRequestException('Your account has been deactivated by the administrator');
}
await this.userService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { Profile } from 'passport-google-oauth20';
Expand Down Expand Up @@ -44,6 +44,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
if (!user) {
throw new UnauthorizedException('Failed to create user from Google profile');
}
if (user.deactivatedTime) {
throw new BadRequestException('Your account has been deactivated by the administrator');
}
await this.userService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
if (!user) {
throw new BadRequestException('Incorrect password.');
}
if (user.deactivatedTime) {
throw new BadRequestException('Your account has been deactivated by the administrator');
}
await this.userService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export class SessionStrategy extends PassportStrategy(PassportSessionStrategy) {
if (!user) {
throw new UnauthorizedException();
}
if (user.deactivatedTime) {
throw new UnauthorizedException('Your account has been deactivated by the administrator');
}
this.cls.set('user.id', user.id);
this.cls.set('user.name', user.name);
this.cls.set('user.email', user.email);
Expand Down
4 changes: 2 additions & 2 deletions apps/nestjs-backend/src/features/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { getFullStorageUrl } from '../../utils/full-storage-url';
export const pickUserMe = (
user: Pick<
Prisma.UserGetPayload<null>,
'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta'
'id' | 'name' | 'avatar' | 'phone' | 'email' | 'password' | 'notifyMeta' | 'isAdmin'
>
): IUserMeVo => {
return {
...pick(user, 'id', 'name', 'phone', 'email'),
...pick(user, 'id', 'name', 'phone', 'email', 'isAdmin'),
notifyMeta: typeof user.notifyMeta === 'object' ? user.notifyMeta : JSON.parse(user.notifyMeta),
avatar:
user.avatar && !user.avatar?.startsWith('http')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { Knex } from 'knex';
import { isDate } from 'lodash';
import { InjectModel } from 'nest-knexjs';
import { ClsService } from 'nestjs-cls';
import { EventEmitterService } from '../../event-emitter/event-emitter.service';
import {
CollaboratorCreateEvent,
CollaboratorDeleteEvent,
Events,
} from '../../event-emitter/events';
import type { IClsStore } from '../../types/cls';
import { getFullStorageUrl } from '../../utils/full-storage-url';

Expand All @@ -18,6 +24,7 @@ export class CollaboratorService {
constructor(
private readonly prismaService: PrismaService,
private readonly cls: ClsService<IClsStore>,
private readonly eventEmitterService: EventEmitterService,
@InjectModel('CUSTOM_KNEX') private readonly knex: Knex
) {}

Expand All @@ -29,14 +36,19 @@ export class CollaboratorService {
if (exist) {
throw new BadRequestException('has already existed in space');
}
return await this.prismaService.txClient().collaborator.create({
const collaborator = await this.prismaService.txClient().collaborator.create({
data: {
spaceId,
roleName: role,
userId,
createdBy: currentUserId,
},
});
this.eventEmitterService.emitAsync(
Events.COLLABORATOR_CREATE,
new CollaboratorCreateEvent(spaceId)
);
return collaborator;
}

async deleteBySpaceId(spaceId: string) {
Expand Down Expand Up @@ -121,7 +133,7 @@ export class CollaboratorService {
}

async deleteCollaborator(spaceId: string, userId: string) {
return await this.prismaService.txClient().collaborator.updateMany({
const result = await this.prismaService.txClient().collaborator.updateMany({
where: {
spaceId,
userId,
Expand All @@ -130,6 +142,11 @@ export class CollaboratorService {
deletedTime: new Date().toISOString(),
},
});
this.eventEmitterService.emitAsync(
Events.COLLABORATOR_DELETE,
new CollaboratorDeleteEvent(spaceId)
);
return result;
}

async updateCollaborator(spaceId: string, updateCollaborator: UpdateSpaceCollaborateRo) {
Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/features/next/next.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class NextController {
'invite/?*',
'share/?*',
'setting/?*',
'admin/?*',
])
public async home(@Req() req: express.Request, @Res() res: express.Response) {
await this.nextService.server.getRequestHandler()(req, res);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,11 @@ export class SelectionService {
return [range[0], range[1]];
}

async paste(tableId: string, pasteRo: IPasteRo) {
async paste(
tableId: string,
pasteRo: IPasteRo,
expansionChecker?: (col: number, row: number) => Promise<void>
) {
const { content, header = [], ...rangesRo } = pasteRo;
const { ranges, type, ...queryRo } = rangesRo;
const { viewId } = queryRo;
Expand Down Expand Up @@ -702,6 +706,7 @@ export class SelectionService {
tableColCount,
tableRowCount,
]);
await expansionChecker?.(numColsToExpand, numRowsToExpand);

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

Expand Down
27 changes: 27 additions & 0 deletions apps/nestjs-backend/src/features/setting/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { CanActivate } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '@teable/db-main-prisma';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';

@Injectable()
export class AdminGuard implements CanActivate {
constructor(
private readonly cls: ClsService<IClsStore>,
private readonly prismaService: PrismaService
) {}

async canActivate() {
const userId = this.cls.get('user.id');

const user = await this.prismaService.user.findUnique({
where: { id: userId, deletedTime: null, deactivatedTime: null },
});

if (!user || !user.isAdmin) {
throw new ForbiddenException('User is not an admin');
}

return true;
}
}
27 changes: 27 additions & 0 deletions apps/nestjs-backend/src/features/setting/setting.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { IUpdateSettingRo, updateSettingRoSchema } from '@teable/openapi';
import type { ISettingVo } from '@teable/openapi';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Public } from '../auth/decorators/public.decorator';
import { AdminGuard } from './admin.guard';
import { SettingService } from './setting.service';

@Controller('api/admin/setting')
export class SettingController {
constructor(private readonly settingService: SettingService) {}

@Public()
@Get()
async getSetting(): Promise<ISettingVo> {
return await this.settingService.getSetting();
}

@UseGuards(AdminGuard)
@Patch()
async updateSetting(
@Body(new ZodValidationPipe(updateSettingRoSchema))
updateSettingRo: IUpdateSettingRo
): Promise<ISettingVo> {
return await this.settingService.updateSetting(updateSettingRo);
}
}
11 changes: 11 additions & 0 deletions apps/nestjs-backend/src/features/setting/setting.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminGuard } from './admin.guard';
import { SettingController } from './setting.controller';
import { SettingService } from './setting.service';

@Module({
controllers: [SettingController],
exports: [SettingService],
providers: [SettingService, AdminGuard],
})
export class SettingModule {}
30 changes: 30 additions & 0 deletions apps/nestjs-backend/src/features/setting/setting.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '@teable/db-main-prisma';
import type { ISettingVo, IUpdateSettingRo } from '@teable/openapi';

@Injectable()
export class SettingService {
constructor(private readonly prismaService: PrismaService) {}

async getSetting(): Promise<ISettingVo> {
return await this.prismaService.setting
.findFirstOrThrow({
select: {
instanceId: true,
disallowSignUp: true,
disallowSpaceCreation: true,
},
})
.catch(() => {
throw new NotFoundException('Setting not found');
});
}

async updateSetting(updateSettingRo: IUpdateSettingRo) {
const setting = await this.getSetting();
return await this.prismaService.setting.update({
where: { instanceId: setting.instanceId },
data: updateSettingRo,
});
}
}
12 changes: 12 additions & 0 deletions apps/nestjs-backend/src/features/space/space.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ export class SpaceService {

async createSpace(createSpaceRo: ICreateSpaceRo) {
const userId = this.cls.get('user.id');
const setting = await this.prismaService.setting.findFirst({
select: {
disallowSignUp: true,
disallowSpaceCreation: true,
},
});

if (setting?.disallowSpaceCreation) {
throw new ForbiddenException(
'The current instance disallow space creation by the administrator'
);
}

const spaceList = await this.prismaService.space.findMany({
where: { deletedTime: null, createdBy: userId },
Expand Down
Loading

0 comments on commit 2bf8027

Please sign in to comment.