feat(server): team quota (#8955)
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkspaceMemberStatus" AS ENUM ('Pending', 'NeedMoreSeat', 'NeedMoreSeatAndReview', 'UnderReview', 'Accepted');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workspace_features" ADD COLUMN "configs" JSON NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workspace_user_permissions" ADD COLUMN "status" "WorkspaceMemberStatus" NOT NULL DEFAULT 'Pending',
|
||||||
|
ADD COLUMN "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "workspace_features_workspace_id_idx" ON "workspace_features"("workspace_id");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "workspaces" ADD COLUMN "enable_ai" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -97,8 +97,10 @@ model VerificationToken {
|
|||||||
model Workspace {
|
model Workspace {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
public Boolean
|
public Boolean
|
||||||
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
// workspace level feature flags
|
||||||
|
enableAi Boolean @default(true) @map("enable_ai")
|
||||||
|
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
|
||||||
|
|
||||||
pages WorkspacePage[]
|
pages WorkspacePage[]
|
||||||
permissions WorkspaceUserPermission[]
|
permissions WorkspaceUserPermission[]
|
||||||
@@ -126,21 +128,33 @@ model WorkspacePage {
|
|||||||
@@map("workspace_pages")
|
@@map("workspace_pages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WorkspaceMemberStatus {
|
||||||
|
Pending // 1. old state accepted = false
|
||||||
|
NeedMoreSeat // 2.1 for team: workspace owner need to buy more seat
|
||||||
|
NeedMoreSeatAndReview // 2.2 for team: workspace owner need to buy more seat and member need review
|
||||||
|
UnderReview // 3. for team: member is under review
|
||||||
|
Accepted // 4. old state accepted = true
|
||||||
|
}
|
||||||
|
|
||||||
model WorkspaceUserPermission {
|
model WorkspaceUserPermission {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
userId String @map("user_id") @db.VarChar
|
userId String @map("user_id") @db.VarChar
|
||||||
// Read/Write
|
// Read/Write
|
||||||
type Int @db.SmallInt
|
type Int @db.SmallInt
|
||||||
/// Whether the permission invitation is accepted by the user
|
/// @deprecated Whether the permission invitation is accepted by the user
|
||||||
accepted Boolean @default(false)
|
accepted Boolean @default(false)
|
||||||
|
/// Whether the invite status of the workspace member
|
||||||
|
status WorkspaceMemberStatus @default(Pending)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
/// When the invite status changed
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([workspaceId, userId])
|
@@unique([workspaceId, userId])
|
||||||
// optimize for quering user's workspace permissions
|
// optimize for querying user's workspace permissions
|
||||||
@@index(userId)
|
@@index(userId)
|
||||||
@@map("workspace_user_permissions")
|
@@map("workspace_user_permissions")
|
||||||
}
|
}
|
||||||
@@ -200,6 +214,8 @@ model WorkspaceFeature {
|
|||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
featureId Int @map("feature_id") @db.Integer
|
featureId Int @map("feature_id") @db.Integer
|
||||||
|
|
||||||
|
// override quota's configs
|
||||||
|
configs Json @default("{}") @db.Json
|
||||||
// we will record the reason why the feature is enabled/disabled
|
// we will record the reason why the feature is enabled/disabled
|
||||||
// for example:
|
// for example:
|
||||||
// - copilet_v1: "owner buy the copilet feature package"
|
// - copilet_v1: "owner buy the copilet feature package"
|
||||||
@@ -216,6 +232,7 @@ model WorkspaceFeature {
|
|||||||
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([workspaceId])
|
||||||
@@map("workspace_features")
|
@@map("workspace_features")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +242,7 @@ model Feature {
|
|||||||
version Int @default(0) @db.Integer
|
version Int @default(0) @db.Integer
|
||||||
// 0: feature, 1: quota
|
// 0: feature, 1: quota
|
||||||
type Int @db.Integer
|
type Int @db.Integer
|
||||||
// configs, define by feature conntroller
|
// configs, define by feature controller
|
||||||
configs Json @db.Json
|
configs Json @db.Json
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import { FeatureService } from './service';
|
|||||||
})
|
})
|
||||||
export class FeatureModule {}
|
export class FeatureModule {}
|
||||||
|
|
||||||
|
export type { FeatureConfigType } from './feature';
|
||||||
export {
|
export {
|
||||||
type CommonFeature,
|
type CommonFeature,
|
||||||
commonFeatureSchema,
|
commonFeatureSchema,
|
||||||
|
type FeatureConfig,
|
||||||
FeatureKind,
|
FeatureKind,
|
||||||
Features,
|
Features,
|
||||||
FeatureType,
|
FeatureType,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class FeatureManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listEarlyAccess(type: EarlyAccessType = EarlyAccessType.App) {
|
async listEarlyAccess(type: EarlyAccessType = EarlyAccessType.App) {
|
||||||
return this.feature.listFeatureUsers(
|
return this.feature.listUsersByFeature(
|
||||||
type === EarlyAccessType.App
|
type === EarlyAccessType.App
|
||||||
? FeatureType.EarlyAccess
|
? FeatureType.EarlyAccess
|
||||||
: FeatureType.AIEarlyAccess
|
: FeatureType.AIEarlyAccess
|
||||||
@@ -132,7 +132,7 @@ export class FeatureManagementService {
|
|||||||
|
|
||||||
// ======== User Feature ========
|
// ======== User Feature ========
|
||||||
async getActivatedUserFeatures(userId: string): Promise<FeatureType[]> {
|
async getActivatedUserFeatures(userId: string): Promise<FeatureType[]> {
|
||||||
const features = await this.feature.getActivatedUserFeatures(userId);
|
const features = await this.feature.getUserActivatedFeatures(userId);
|
||||||
return features.map(f => f.feature.name);
|
return features.map(f => f.feature.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ export class FeatureManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listFeatureWorkspaces(feature: FeatureType) {
|
async listFeatureWorkspaces(feature: FeatureType) {
|
||||||
return this.feature.listFeatureWorkspaces(feature);
|
return this.feature.listWorkspacesByFeature(feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('user.admin.created')
|
@OnEvent('user.admin.created')
|
||||||
|
|||||||
@@ -12,14 +12,9 @@ export class FeatureService {
|
|||||||
|
|
||||||
async getFeature<F extends FeatureType>(feature: F) {
|
async getFeature<F extends FeatureType>(feature: F) {
|
||||||
const data = await this.prisma.feature.findFirst({
|
const data = await this.prisma.feature.findFirst({
|
||||||
where: {
|
where: { feature, type: FeatureKind.Feature },
|
||||||
feature,
|
|
||||||
type: FeatureKind.Feature,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
orderBy: {
|
orderBy: { version: 'desc' },
|
||||||
version: 'desc',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -146,7 +141,7 @@ export class FeatureService {
|
|||||||
return configs.filter(feature => !!feature.feature);
|
return configs.filter(feature => !!feature.feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivatedUserFeatures(userId: string) {
|
async getUserActivatedFeatures(userId: string) {
|
||||||
const features = await this.prisma.userFeature.findMany({
|
const features = await this.prisma.userFeature.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
@@ -173,7 +168,7 @@ export class FeatureService {
|
|||||||
return configs.filter(feature => !!feature.feature);
|
return configs.filter(feature => !!feature.feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatureUsers(feature: FeatureType) {
|
async listUsersByFeature(feature: FeatureType) {
|
||||||
return this.prisma.userFeature
|
return this.prisma.userFeature
|
||||||
.findMany({
|
.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -318,7 +313,9 @@ export class FeatureService {
|
|||||||
return configs.filter(feature => !!feature.feature);
|
return configs.filter(feature => !!feature.feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
async listWorkspacesByFeature(
|
||||||
|
feature: FeatureType
|
||||||
|
): Promise<WorkspaceType[]> {
|
||||||
return this.prisma.workspaceFeature
|
return this.prisma.workspaceFeature
|
||||||
.findMany({
|
.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -76,20 +76,24 @@ export const Features: Feature[] = [
|
|||||||
|
|
||||||
/// ======== schema infer ========
|
/// ======== schema infer ========
|
||||||
|
|
||||||
export const FeatureSchema = commonFeatureSchema
|
export const FeatureConfigSchema = z.discriminatedUnion('feature', [
|
||||||
.extend({
|
|
||||||
type: z.literal(FeatureKind.Feature),
|
|
||||||
})
|
|
||||||
.and(
|
|
||||||
z.discriminatedUnion('feature', [
|
|
||||||
featureCopilot,
|
featureCopilot,
|
||||||
featureEarlyAccess,
|
featureEarlyAccess,
|
||||||
featureAIEarlyAccess,
|
featureAIEarlyAccess,
|
||||||
featureUnlimitedWorkspace,
|
featureUnlimitedWorkspace,
|
||||||
featureUnlimitedCopilot,
|
featureUnlimitedCopilot,
|
||||||
featureAdministrator,
|
featureAdministrator,
|
||||||
])
|
]);
|
||||||
);
|
|
||||||
|
export const FeatureSchema = commonFeatureSchema
|
||||||
|
.extend({
|
||||||
|
type: z.literal(FeatureKind.Feature),
|
||||||
|
})
|
||||||
|
.and(FeatureConfigSchema);
|
||||||
|
|
||||||
|
export type FeatureConfig<F extends FeatureType> = (z.infer<
|
||||||
|
typeof FeatureConfigSchema
|
||||||
|
> & { feature: F })['configs'];
|
||||||
|
|
||||||
export type Feature = z.infer<typeof FeatureSchema>;
|
export type Feature = z.infer<typeof FeatureSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DocAccessDenied,
|
DocAccessDenied,
|
||||||
|
EventEmitter,
|
||||||
|
PrismaTransaction,
|
||||||
SpaceAccessDenied,
|
SpaceAccessDenied,
|
||||||
SpaceOwnerNotFound,
|
SpaceOwnerNotFound,
|
||||||
} from '../../fundamentals';
|
} from '../../fundamentals';
|
||||||
|
import { FeatureKind } from '../features/types';
|
||||||
|
import { QuotaType } from '../quota/types';
|
||||||
import { Permission, PublicPageMode } from './types';
|
import { Permission, PublicPageMode } from './types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PermissionService {
|
export class PermissionService {
|
||||||
constructor(private readonly prisma: PrismaClient) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly event: EventEmitter
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get acceptedCondition() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Start regin: workspace permission
|
/// Start regin: workspace permission
|
||||||
async get(ws: string, user: string) {
|
async get(ws: string, user: string) {
|
||||||
@@ -19,7 +38,7 @@ export class PermissionService {
|
|||||||
where: {
|
where: {
|
||||||
workspaceId: ws,
|
workspaceId: ws,
|
||||||
userId: user,
|
userId: user,
|
||||||
accepted: true,
|
OR: this.acceptedCondition,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +55,7 @@ export class PermissionService {
|
|||||||
.count({
|
.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
accepted: true,
|
OR: this.acceptedCondition,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(count => count > 0);
|
.then(count => count > 0);
|
||||||
@@ -47,8 +66,8 @@ export class PermissionService {
|
|||||||
.findMany({
|
.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
accepted: true,
|
|
||||||
type: Permission.Owner,
|
type: Permission.Owner,
|
||||||
|
OR: this.acceptedCondition,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
workspaceId: true,
|
workspaceId: true,
|
||||||
@@ -120,19 +139,31 @@ export class PermissionService {
|
|||||||
return this.tryCheckPage(ws, id, user);
|
return this.tryCheckPage(ws, id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWorkspaceMemberStatus(ws: string, user: string) {
|
||||||
|
return this.prisma.workspaceUserPermission
|
||||||
|
.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId: ws,
|
||||||
|
userId: user,
|
||||||
|
},
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
.then(r => r?.status);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
||||||
*/
|
*/
|
||||||
async isWorkspaceMember(
|
async isWorkspaceMember(
|
||||||
ws: string,
|
ws: string,
|
||||||
user: string,
|
user: string,
|
||||||
permission: Permission
|
permission: Permission = Permission.Read
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const count = await this.prisma.workspaceUserPermission.count({
|
const count = await this.prisma.workspaceUserPermission.count({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: ws,
|
workspaceId: ws,
|
||||||
userId: user,
|
userId: user,
|
||||||
accepted: true,
|
OR: this.acceptedCondition,
|
||||||
type: {
|
type: {
|
||||||
gte: permission,
|
gte: permission,
|
||||||
},
|
},
|
||||||
@@ -193,7 +224,7 @@ export class PermissionService {
|
|||||||
where: {
|
where: {
|
||||||
workspaceId: ws,
|
workspaceId: ws,
|
||||||
userId: user,
|
userId: user,
|
||||||
accepted: true,
|
OR: this.acceptedCondition,
|
||||||
type: {
|
type: {
|
||||||
gte: permission,
|
gte: permission,
|
||||||
},
|
},
|
||||||
@@ -208,6 +239,33 @@ export class PermissionService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkWorkspaceIs(
|
||||||
|
ws: string,
|
||||||
|
user: string,
|
||||||
|
permission: Permission = Permission.Read
|
||||||
|
) {
|
||||||
|
if (!(await this.tryCheckWorkspaceIs(ws, user, permission))) {
|
||||||
|
throw new SpaceAccessDenied({ spaceId: ws });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async tryCheckWorkspaceIs(
|
||||||
|
ws: string,
|
||||||
|
user: string,
|
||||||
|
permission: Permission = Permission.Read
|
||||||
|
) {
|
||||||
|
const count = await this.prisma.workspaceUserPermission.count({
|
||||||
|
where: {
|
||||||
|
workspaceId: ws,
|
||||||
|
userId: user,
|
||||||
|
OR: this.acceptedCondition,
|
||||||
|
type: permission,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
async allowUrlPreview(ws: string) {
|
async allowUrlPreview(ws: string) {
|
||||||
const count = await this.prisma.workspace.count({
|
const count = await this.prisma.workspace.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -222,13 +280,14 @@ export class PermissionService {
|
|||||||
async grant(
|
async grant(
|
||||||
ws: string,
|
ws: string,
|
||||||
user: string,
|
user: string,
|
||||||
permission: Permission = Permission.Read
|
permission: Permission = Permission.Read,
|
||||||
|
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const data = await this.prisma.workspaceUserPermission.findFirst({
|
const data = await this.prisma.workspaceUserPermission.findFirst({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: ws,
|
workspaceId: ws,
|
||||||
userId: user,
|
userId: user,
|
||||||
accepted: true,
|
OR: this.acceptedCondition,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,6 +333,7 @@ export class PermissionService {
|
|||||||
workspaceId: ws,
|
workspaceId: ws,
|
||||||
userId: user,
|
userId: user,
|
||||||
type: permission,
|
type: permission,
|
||||||
|
status,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(p => p.id);
|
.then(p => p.id);
|
||||||
@@ -291,33 +351,124 @@ export class PermissionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptWorkspaceInvitation(invitationId: string, workspaceId: string) {
|
private async isTeamWorkspace(tx: PrismaTransaction, workspaceId: string) {
|
||||||
|
return await tx.workspaceFeature
|
||||||
|
.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
activated: true,
|
||||||
|
feature: {
|
||||||
|
feature: QuotaType.TeamPlanV1,
|
||||||
|
type: FeatureKind.Feature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(count => count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptWorkspaceInvitation(
|
||||||
|
invitationId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted
|
||||||
|
) {
|
||||||
const result = await this.prisma.workspaceUserPermission.updateMany({
|
const result = await this.prisma.workspaceUserPermission.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: invitationId,
|
id: invitationId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
|
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
status: status,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.count > 0;
|
return result.count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeWorkspace(ws: string, user: string) {
|
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
|
||||||
const result = await this.prisma.workspaceUserPermission.deleteMany({
|
return this.prisma.$transaction(async tx => {
|
||||||
|
const members = await tx.workspaceUserPermission.findMany({
|
||||||
where: {
|
where: {
|
||||||
workspaceId: ws,
|
workspaceId,
|
||||||
userId: user,
|
|
||||||
type: {
|
|
||||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
|
||||||
not: Permission.Owner,
|
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
status: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const memberCount = members.filter(
|
||||||
|
m => m.status === WorkspaceMemberStatus.Accepted
|
||||||
|
).length;
|
||||||
|
const NeedUpdateStatus = new Set<WorkspaceMemberStatus>([
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeat,
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
|
]);
|
||||||
|
const needChange = members
|
||||||
|
.filter(m => NeedUpdateStatus.has(m.status))
|
||||||
|
.toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt))
|
||||||
|
.slice(0, memberLimit - memberCount);
|
||||||
|
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
|
||||||
|
needChange,
|
||||||
|
m => m.status
|
||||||
|
);
|
||||||
|
const approvedCount = await tx.workspaceUserPermission
|
||||||
|
.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: {
|
||||||
|
in: NeedMoreSeat?.map(m => m.userId) ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(r => r.count);
|
||||||
|
const needReviewCount = await tx.workspaceUserPermission
|
||||||
|
.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: {
|
||||||
|
in: NeedMoreSeatAndReview?.map(m => m.userId) ?? [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: WorkspaceMemberStatus.UnderReview,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(r => r.count);
|
||||||
|
return approvedCount + needReviewCount === needChange.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeWorkspace(workspaceId: string, user: string) {
|
||||||
|
return await this.prisma.$transaction(async tx => {
|
||||||
|
const result = await tx.workspaceUserPermission.deleteMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId: user,
|
||||||
|
// We shouldn't revoke owner permission
|
||||||
|
// should auto deleted by workspace/user delete cascading
|
||||||
|
type: { not: Permission.Owner },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.count > 0;
|
const success = result.count > 0;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const isTeam = await this.isTeamWorkspace(tx, workspaceId);
|
||||||
|
if (isTeam) {
|
||||||
|
const count = await tx.workspaceUserPermission.count({
|
||||||
|
where: { workspaceId },
|
||||||
|
});
|
||||||
|
this.event.emit('workspace.members.updated', {
|
||||||
|
workspaceId,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
/// End regin: workspace permission
|
/// End regin: workspace permission
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,10 @@ export class QuotaModule {}
|
|||||||
|
|
||||||
export { QuotaManagementService, QuotaService };
|
export { QuotaManagementService, QuotaService };
|
||||||
export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema';
|
export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema';
|
||||||
export { QuotaQueryType, QuotaType } from './types';
|
export {
|
||||||
|
formatDate,
|
||||||
|
formatSize,
|
||||||
|
type QuotaBusinessType,
|
||||||
|
QuotaQueryType,
|
||||||
|
QuotaType,
|
||||||
|
} from './types';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const QuotaCache = new Map<number, QuotaConfig>();
|
|||||||
|
|
||||||
export class QuotaConfig {
|
export class QuotaConfig {
|
||||||
readonly config: Quota;
|
readonly config: Quota;
|
||||||
|
readonly override?: Quota['configs'];
|
||||||
|
|
||||||
static async get(tx: PrismaTransaction, featureId: number) {
|
static async get(tx: PrismaTransaction, featureId: number) {
|
||||||
const cachedQuota = QuotaCache.get(featureId);
|
const cachedQuota = QuotaCache.get(featureId);
|
||||||
@@ -31,7 +32,7 @@ export class QuotaConfig {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(data: any) {
|
private constructor(data: any, override?: any) {
|
||||||
const config = QuotaSchema.safeParse(data);
|
const config = QuotaSchema.safeParse(data);
|
||||||
if (config.success) {
|
if (config.success) {
|
||||||
this.config = config.data;
|
this.config = config.data;
|
||||||
@@ -42,6 +43,38 @@ export class QuotaConfig {
|
|||||||
)})}`
|
)})}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (override) {
|
||||||
|
const overrideConfig = QuotaSchema.safeParse({
|
||||||
|
...config.data,
|
||||||
|
configs: Object.assign({}, config.data.configs, override),
|
||||||
|
});
|
||||||
|
if (overrideConfig.success) {
|
||||||
|
this.override = overrideConfig.data.configs;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid quota override config: ${override.error.message}, ${JSON.stringify(
|
||||||
|
data
|
||||||
|
)})}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withOverride(override: any) {
|
||||||
|
if (override) {
|
||||||
|
return new QuotaConfig(
|
||||||
|
this.config,
|
||||||
|
Object.assign({}, this.override, override)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkOverride(override: any) {
|
||||||
|
return QuotaSchema.safeParse({
|
||||||
|
...this.config,
|
||||||
|
configs: Object.assign({}, this.config.configs, override),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get version() {
|
get version() {
|
||||||
@@ -54,30 +87,44 @@ export class QuotaConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get blobLimit() {
|
get blobLimit() {
|
||||||
return this.config.configs.blobLimit;
|
return this.override?.blobLimit || this.config.configs.blobLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
get businessBlobLimit() {
|
get businessBlobLimit() {
|
||||||
return (
|
return (
|
||||||
this.config.configs.businessBlobLimit || this.config.configs.blobLimit
|
this.override?.businessBlobLimit ||
|
||||||
|
this.config.configs.businessBlobLimit ||
|
||||||
|
this.override?.blobLimit ||
|
||||||
|
this.config.configs.blobLimit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get additionalQuota() {
|
||||||
|
const seatQuota =
|
||||||
|
this.override?.seatQuota || this.config.configs.seatQuota || 0;
|
||||||
|
return this.memberLimit * seatQuota;
|
||||||
|
}
|
||||||
|
|
||||||
get storageQuota() {
|
get storageQuota() {
|
||||||
return this.config.configs.storageQuota;
|
const baseQuota =
|
||||||
|
this.override?.storageQuota || this.config.configs.storageQuota;
|
||||||
|
return baseQuota + this.additionalQuota;
|
||||||
}
|
}
|
||||||
|
|
||||||
get historyPeriod() {
|
get historyPeriod() {
|
||||||
return this.config.configs.historyPeriod;
|
return this.override?.historyPeriod || this.config.configs.historyPeriod;
|
||||||
}
|
}
|
||||||
|
|
||||||
get memberLimit() {
|
get memberLimit() {
|
||||||
return this.config.configs.memberLimit;
|
return this.override?.memberLimit || this.config.configs.memberLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
get copilotActionLimit() {
|
get copilotActionLimit() {
|
||||||
|
if ('copilotActionLimit' in this.config.configs) {
|
||||||
return this.config.configs.copilotActionLimit || undefined;
|
return this.config.configs.copilotActionLimit || undefined;
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
get humanReadable() {
|
get humanReadable() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ export const Quotas: Quota[] = [
|
|||||||
configs: {
|
configs: {
|
||||||
// quota name
|
// quota name
|
||||||
name: 'Restricted',
|
name: 'Restricted',
|
||||||
// single blob limit 10MB
|
// single blob limit 1MB
|
||||||
blobLimit: OneMB,
|
blobLimit: OneMB,
|
||||||
// total blob limit 1GB
|
// total blob limit 10MB
|
||||||
storageQuota: 10 * OneMB,
|
storageQuota: 10 * OneMB,
|
||||||
// history period of validity 30 days
|
// history period of validity 30 days
|
||||||
historyPeriod: 30 * OneDay,
|
historyPeriod: 30 * OneDay,
|
||||||
@@ -174,12 +174,31 @@ export const Quotas: Quota[] = [
|
|||||||
copilotActionLimit: 10,
|
copilotActionLimit: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
feature: QuotaType.TeamPlanV1,
|
||||||
|
type: FeatureKind.Quota,
|
||||||
|
version: 1,
|
||||||
|
configs: {
|
||||||
|
// quota name
|
||||||
|
name: 'Team Workspace',
|
||||||
|
// single blob limit 100MB
|
||||||
|
blobLimit: 500 * OneMB,
|
||||||
|
// total blob limit 100GB
|
||||||
|
storageQuota: 100 * OneGB,
|
||||||
|
// seat quota 20GB per seat
|
||||||
|
seatQuota: 20 * OneGB,
|
||||||
|
// history period of validity 30 days
|
||||||
|
historyPeriod: 30 * OneDay,
|
||||||
|
// member limit 1, override by workspace config
|
||||||
|
memberLimit: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getLatestQuota(type: QuotaType) {
|
export function getLatestQuota<Q extends QuotaType>(type: Q): Quota<Q> {
|
||||||
const quota = Quotas.filter(f => f.feature === type);
|
const quota = Quotas.filter(f => f.feature === type);
|
||||||
quota.sort((a, b) => b.version - a.version);
|
quota.sort((a, b) => b.version - a.version);
|
||||||
return quota[0];
|
return quota[0] as Quota<Q>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
||||||
|
|||||||
@@ -15,14 +15,32 @@ export class QuotaService {
|
|||||||
private readonly feature: FeatureManagementService
|
private readonly feature: FeatureManagementService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getQuota<Q extends QuotaType>(
|
||||||
|
quota: Q,
|
||||||
|
tx?: PrismaTransaction
|
||||||
|
): Promise<QuotaConfig | undefined> {
|
||||||
|
const executor = tx ?? this.prisma;
|
||||||
|
|
||||||
|
const data = await executor.feature.findFirst({
|
||||||
|
where: { feature: quota, type: FeatureKind.Quota },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { version: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return QuotaConfig.get(this.prisma, data.id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== User Quota ========
|
||||||
|
|
||||||
// get activated user quota
|
// get activated user quota
|
||||||
async getUserQuota(userId: string) {
|
async getUserQuota(userId: string) {
|
||||||
const quota = await this.prisma.userFeature.findFirst({
|
const quota = await this.prisma.userFeature.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
feature: {
|
feature: { type: FeatureKind.Quota },
|
||||||
type: FeatureKind.Quota,
|
|
||||||
},
|
|
||||||
activated: true,
|
activated: true,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -47,9 +65,7 @@ export class QuotaService {
|
|||||||
const quotas = await this.prisma.userFeature.findMany({
|
const quotas = await this.prisma.userFeature.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
feature: {
|
feature: { type: FeatureKind.Quota },
|
||||||
type: FeatureKind.Quota,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
activated: true,
|
activated: true,
|
||||||
@@ -58,9 +74,7 @@ export class QuotaService {
|
|||||||
expiredAt: true,
|
expiredAt: true,
|
||||||
featureId: true,
|
featureId: true,
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: { id: 'asc' },
|
||||||
id: 'asc',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const configs = await Promise.all(
|
const configs = await Promise.all(
|
||||||
quotas.map(async quota => {
|
quotas.map(async quota => {
|
||||||
@@ -88,12 +102,8 @@ export class QuotaService {
|
|||||||
expiredAt?: Date
|
expiredAt?: Date
|
||||||
) {
|
) {
|
||||||
await this.prisma.$transaction(async tx => {
|
await this.prisma.$transaction(async tx => {
|
||||||
const hasSameActivatedQuota = await this.hasQuota(userId, quota, tx);
|
const hasSameActivatedQuota = await this.hasUserQuota(userId, quota, tx);
|
||||||
|
if (hasSameActivatedQuota) return; // don't need to switch
|
||||||
if (hasSameActivatedQuota) {
|
|
||||||
// don't need to switch
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const featureId = await tx.feature
|
const featureId = await tx.feature
|
||||||
.findFirst({
|
.findFirst({
|
||||||
@@ -133,7 +143,7 @@ export class QuotaService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
async hasUserQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
||||||
const executor = tx ?? this.prisma;
|
const executor = tx ?? this.prisma;
|
||||||
|
|
||||||
return executor.userFeature
|
return executor.userFeature
|
||||||
@@ -150,6 +160,161 @@ export class QuotaService {
|
|||||||
.then(count => count > 0);
|
.then(count => count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== Workspace Quota ========
|
||||||
|
|
||||||
|
// get activated workspace quota
|
||||||
|
async getWorkspaceQuota(workspaceId: string) {
|
||||||
|
const quota = await this.prisma.workspaceFeature.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
feature: { type: FeatureKind.Quota },
|
||||||
|
activated: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
configs: true,
|
||||||
|
reason: true,
|
||||||
|
createdAt: true,
|
||||||
|
expiredAt: true,
|
||||||
|
featureId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (quota) {
|
||||||
|
const feature = await QuotaConfig.get(this.prisma, quota.featureId);
|
||||||
|
const { configs, ...rest } = quota;
|
||||||
|
return { ...rest, feature: feature.withOverride(configs) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// switch user to a new quota
|
||||||
|
// currently each user can only have one quota
|
||||||
|
async switchWorkspaceQuota(
|
||||||
|
workspaceId: string,
|
||||||
|
quota: QuotaType,
|
||||||
|
reason?: string,
|
||||||
|
expiredAt?: Date
|
||||||
|
) {
|
||||||
|
await this.prisma.$transaction(async tx => {
|
||||||
|
const hasSameActivatedQuota = await this.hasWorkspaceQuota(
|
||||||
|
workspaceId,
|
||||||
|
quota,
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
if (hasSameActivatedQuota) return; // don't need to switch
|
||||||
|
|
||||||
|
const featureId = await tx.feature
|
||||||
|
.findFirst({
|
||||||
|
where: { feature: quota, type: FeatureKind.Quota },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { version: 'desc' },
|
||||||
|
})
|
||||||
|
.then(f => f?.id);
|
||||||
|
|
||||||
|
if (!featureId) {
|
||||||
|
throw new Error(`Quota ${quota} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we will deactivate all exists quota for this workspace
|
||||||
|
await this.deactivateWorkspaceQuota(workspaceId, undefined, tx);
|
||||||
|
|
||||||
|
await tx.workspaceFeature.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
featureId,
|
||||||
|
reason: reason ?? 'switch quota',
|
||||||
|
activated: true,
|
||||||
|
expiredAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateWorkspaceQuota(
|
||||||
|
workspaceId: string,
|
||||||
|
quota?: QuotaType,
|
||||||
|
tx?: PrismaTransaction
|
||||||
|
) {
|
||||||
|
const executor = tx ?? this.prisma;
|
||||||
|
|
||||||
|
await executor.workspaceFeature.updateMany({
|
||||||
|
where: {
|
||||||
|
id: undefined,
|
||||||
|
workspaceId,
|
||||||
|
feature: quota
|
||||||
|
? { feature: quota, type: FeatureKind.Quota }
|
||||||
|
: { type: FeatureKind.Quota },
|
||||||
|
},
|
||||||
|
data: { activated: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasWorkspaceQuota(
|
||||||
|
workspaceId: string,
|
||||||
|
quota: QuotaType,
|
||||||
|
tx?: PrismaTransaction
|
||||||
|
) {
|
||||||
|
const executor = tx ?? this.prisma;
|
||||||
|
|
||||||
|
return executor.workspaceFeature
|
||||||
|
.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
feature: {
|
||||||
|
feature: quota,
|
||||||
|
type: FeatureKind.Quota,
|
||||||
|
},
|
||||||
|
activated: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(count => count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkspaceConfig<Q extends QuotaType>(
|
||||||
|
workspaceId: string,
|
||||||
|
type: Q
|
||||||
|
): Promise<QuotaConfig | undefined> {
|
||||||
|
const quota = await this.getQuota(type);
|
||||||
|
if (quota) {
|
||||||
|
const configs = await this.prisma.workspaceFeature
|
||||||
|
.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
feature: { feature: type, type: FeatureKind.Feature },
|
||||||
|
activated: true,
|
||||||
|
},
|
||||||
|
select: { configs: true },
|
||||||
|
})
|
||||||
|
.then(q => q?.configs);
|
||||||
|
return quota.withOverride(configs);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWorkspaceConfig(
|
||||||
|
workspaceId: string,
|
||||||
|
quota: QuotaType,
|
||||||
|
configs: any
|
||||||
|
) {
|
||||||
|
const current = await this.getWorkspaceConfig(workspaceId, quota);
|
||||||
|
|
||||||
|
const ret = current?.checkOverride(configs);
|
||||||
|
if (!ret || !ret.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid quota config: ${ret?.error.message || 'quota not defined'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const r = await this.prisma.workspaceFeature.updateMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
feature: { feature: quota, type: FeatureKind.Quota },
|
||||||
|
activated: true,
|
||||||
|
},
|
||||||
|
data: { configs },
|
||||||
|
});
|
||||||
|
return r.count;
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent('user.subscription.activated')
|
@OnEvent('user.subscription.activated')
|
||||||
async onSubscriptionUpdated({
|
async onSubscriptionUpdated({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MemberQuotaExceeded } from '../../fundamentals';
|
||||||
import { FeatureService, FeatureType } from '../features';
|
import { FeatureService, FeatureType } from '../features';
|
||||||
import { PermissionService } from '../permission';
|
import { PermissionService } from '../permission';
|
||||||
import { WorkspaceBlobStorage } from '../storage';
|
import { WorkspaceBlobStorage } from '../storage';
|
||||||
import { OneGB } from './constant';
|
import { OneGB } from './constant';
|
||||||
|
import { QuotaConfig } from './quota';
|
||||||
import { QuotaService } from './service';
|
import { QuotaService } from './service';
|
||||||
import { formatSize, QuotaQueryType } from './types';
|
import { formatSize, Quota, type QuotaBusinessType, QuotaType } from './types';
|
||||||
|
|
||||||
type QuotaBusinessType = QuotaQueryType & {
|
|
||||||
businessBlobLimit: number;
|
|
||||||
unlimited: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QuotaManagementService {
|
export class QuotaManagementService {
|
||||||
@@ -40,6 +37,46 @@ export class QuotaManagementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWorkspaceConfig<Q extends QuotaType>(
|
||||||
|
workspaceId: string,
|
||||||
|
quota: Q
|
||||||
|
): Promise<QuotaConfig | undefined> {
|
||||||
|
return this.quota.getWorkspaceConfig(workspaceId, quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWorkspaceConfig<Q extends QuotaType>(
|
||||||
|
workspaceId: string,
|
||||||
|
quota: Q,
|
||||||
|
configs: Partial<Quota<Q>['configs']>
|
||||||
|
) {
|
||||||
|
const orig = await this.getWorkspaceConfig(workspaceId, quota);
|
||||||
|
return await this.quota.updateWorkspaceConfig(
|
||||||
|
workspaceId,
|
||||||
|
quota,
|
||||||
|
Object.assign({}, orig?.override, configs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== Team Workspace ========
|
||||||
|
async addTeamWorkspace(workspaceId: string, reason: string) {
|
||||||
|
return this.quota.switchWorkspaceQuota(
|
||||||
|
workspaceId,
|
||||||
|
QuotaType.TeamPlanV1,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTeamWorkspace(workspaceId: string) {
|
||||||
|
return this.quota.deactivateWorkspaceQuota(
|
||||||
|
workspaceId,
|
||||||
|
QuotaType.TeamPlanV1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isTeamWorkspace(workspaceId: string) {
|
||||||
|
return this.quota.hasWorkspaceQuota(workspaceId, QuotaType.TeamPlanV1);
|
||||||
|
}
|
||||||
|
|
||||||
async getUserUsage(userId: string) {
|
async getUserUsage(userId: string) {
|
||||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
||||||
|
|
||||||
@@ -109,6 +146,26 @@ export class QuotaManagementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getWorkspaceQuota(userId: string, workspaceId: string) {
|
||||||
|
const { feature: workspaceQuota } =
|
||||||
|
(await this.quota.getWorkspaceQuota(workspaceId)) || {};
|
||||||
|
const { feature: userQuota } = await this.quota.getUserQuota(userId);
|
||||||
|
if (workspaceQuota) {
|
||||||
|
return workspaceQuota.withOverride({
|
||||||
|
// override user quota with workspace quota
|
||||||
|
copilotActionLimit: userQuota.copilotActionLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userQuota;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkWorkspaceSeat(workspaceId: string, excludeSelf = false) {
|
||||||
|
const quota = await this.getWorkspaceUsage(workspaceId);
|
||||||
|
if (quota.memberCount - (excludeSelf ? 1 : 0) >= quota.memberLimit) {
|
||||||
|
throw new MemberQuotaExceeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// get workspace's owner quota and total size of used
|
// get workspace's owner quota and total size of used
|
||||||
// quota was apply to owner's account
|
// quota was apply to owner's account
|
||||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
||||||
@@ -116,7 +173,6 @@ export class QuotaManagementService {
|
|||||||
const memberCount =
|
const memberCount =
|
||||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
||||||
const {
|
const {
|
||||||
feature: {
|
|
||||||
name,
|
name,
|
||||||
blobLimit,
|
blobLimit,
|
||||||
businessBlobLimit,
|
businessBlobLimit,
|
||||||
@@ -125,8 +181,7 @@ export class QuotaManagementService {
|
|||||||
storageQuota,
|
storageQuota,
|
||||||
copilotActionLimit,
|
copilotActionLimit,
|
||||||
humanReadable,
|
humanReadable,
|
||||||
},
|
} = await this.getWorkspaceQuota(owner.id, workspaceId);
|
||||||
} = await this.quota.getUserQuota(owner.id);
|
|
||||||
// get all workspaces size of owner used
|
// get all workspaces size of owner used
|
||||||
const usedSize = await this.getUserUsage(owner.id);
|
const usedSize = await this.getUserUsage(owner.id);
|
||||||
// relax restrictions if workspace has unlimited feature
|
// relax restrictions if workspace has unlimited feature
|
||||||
@@ -157,7 +212,7 @@ export class QuotaManagementService {
|
|||||||
return quota;
|
return quota;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeUnlimitedQuota(orig: QuotaBusinessType) {
|
private mergeUnlimitedQuota(orig: QuotaBusinessType): QuotaBusinessType {
|
||||||
return {
|
return {
|
||||||
...orig,
|
...orig,
|
||||||
storageQuota: 1000 * OneGB,
|
storageQuota: 1000 * OneGB,
|
||||||
|
|||||||
@@ -17,27 +17,39 @@ import { ByteUnit, OneDay, OneKB } from './constant';
|
|||||||
export enum QuotaType {
|
export enum QuotaType {
|
||||||
FreePlanV1 = 'free_plan_v1',
|
FreePlanV1 = 'free_plan_v1',
|
||||||
ProPlanV1 = 'pro_plan_v1',
|
ProPlanV1 = 'pro_plan_v1',
|
||||||
|
TeamPlanV1 = 'team_plan_v1',
|
||||||
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
|
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
|
||||||
// only for test, smaller quota
|
// only for test, smaller quota
|
||||||
RestrictedPlanV1 = 'restricted_plan_v1',
|
RestrictedPlanV1 = 'restricted_plan_v1',
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotaPlan = z.object({
|
const basicQuota = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
blobLimit: z.number().positive().int(),
|
||||||
|
storageQuota: z.number().positive().int(),
|
||||||
|
seatQuota: z.number().positive().int().nullish(),
|
||||||
|
historyPeriod: z.number().positive().int(),
|
||||||
|
memberLimit: z.number().positive().int(),
|
||||||
|
businessBlobLimit: z.number().positive().int().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userQuota = basicQuota.extend({
|
||||||
|
copilotActionLimit: z.number().positive().int().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userQuotaPlan = z.object({
|
||||||
feature: z.enum([
|
feature: z.enum([
|
||||||
QuotaType.FreePlanV1,
|
QuotaType.FreePlanV1,
|
||||||
QuotaType.ProPlanV1,
|
QuotaType.ProPlanV1,
|
||||||
QuotaType.LifetimeProPlanV1,
|
QuotaType.LifetimeProPlanV1,
|
||||||
QuotaType.RestrictedPlanV1,
|
QuotaType.RestrictedPlanV1,
|
||||||
]),
|
]),
|
||||||
configs: z.object({
|
configs: userQuota,
|
||||||
name: z.string(),
|
});
|
||||||
blobLimit: z.number().positive().int(),
|
|
||||||
storageQuota: z.number().positive().int(),
|
const workspaceQuotaPlan = z.object({
|
||||||
historyPeriod: z.number().positive().int(),
|
feature: z.enum([QuotaType.TeamPlanV1]),
|
||||||
memberLimit: z.number().positive().int(),
|
configs: basicQuota,
|
||||||
businessBlobLimit: z.number().positive().int().nullish(),
|
|
||||||
copilotActionLimit: z.number().positive().int().nullish(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ======== schema infer ========
|
/// ======== schema infer ========
|
||||||
@@ -46,9 +58,12 @@ export const QuotaSchema = commonFeatureSchema
|
|||||||
.extend({
|
.extend({
|
||||||
type: z.literal(FeatureKind.Quota),
|
type: z.literal(FeatureKind.Quota),
|
||||||
})
|
})
|
||||||
.and(z.discriminatedUnion('feature', [quotaPlan]));
|
.and(z.discriminatedUnion('feature', [userQuotaPlan, workspaceQuotaPlan]));
|
||||||
|
|
||||||
export type Quota = z.infer<typeof QuotaSchema>;
|
export type Quota<Q extends QuotaType = QuotaType> = z.infer<
|
||||||
|
typeof QuotaSchema
|
||||||
|
> & { feature: Q };
|
||||||
|
export type QuotaConfigType = Quota['configs'];
|
||||||
|
|
||||||
/// ======== query types ========
|
/// ======== query types ========
|
||||||
|
|
||||||
@@ -120,3 +135,8 @@ export function formatSize(bytes: number, decimals: number = 2): string {
|
|||||||
export function formatDate(ms: number): string {
|
export function formatDate(ms: number): string {
|
||||||
return `${(ms / OneDay).toFixed(0)} days`;
|
return `${(ms / OneDay).toFixed(0)} days`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QuotaBusinessType = QuotaQueryType & {
|
||||||
|
businessBlobLimit: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { WorkspaceManagementResolver } from './management';
|
|||||||
import {
|
import {
|
||||||
DocHistoryResolver,
|
DocHistoryResolver,
|
||||||
PagePermissionResolver,
|
PagePermissionResolver,
|
||||||
|
TeamWorkspaceResolver,
|
||||||
WorkspaceBlobResolver,
|
WorkspaceBlobResolver,
|
||||||
WorkspaceResolver,
|
WorkspaceResolver,
|
||||||
} from './resolvers';
|
} from './resolvers';
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
controllers: [WorkspacesController],
|
controllers: [WorkspacesController],
|
||||||
providers: [
|
providers: [
|
||||||
WorkspaceResolver,
|
WorkspaceResolver,
|
||||||
|
TeamWorkspaceResolver,
|
||||||
WorkspaceManagementResolver,
|
WorkspaceManagementResolver,
|
||||||
PagePermissionResolver,
|
PagePermissionResolver,
|
||||||
DocHistoryResolver,
|
DocHistoryResolver,
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ export class WorkspaceBlobResolver {
|
|||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('hash') name: string
|
@Args('hash') name: string
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
await this.permissions.checkWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Write
|
||||||
|
);
|
||||||
|
|
||||||
await this.storage.delete(workspaceId, name);
|
await this.storage.delete(workspaceId, name);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './blob';
|
export * from './blob';
|
||||||
export * from './history';
|
export * from './history';
|
||||||
export * from './page';
|
export * from './page';
|
||||||
|
export * from './team';
|
||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class PagePermissionResolver {
|
|||||||
await this.permission.checkWorkspace(
|
await this.permission.checkWorkspace(
|
||||||
docId.workspace,
|
docId.workspace,
|
||||||
user.id,
|
user.id,
|
||||||
Permission.Read
|
Permission.Write
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.permission.publishPage(docId.workspace, docId.guid, mode);
|
return this.permission.publishPage(docId.workspace, docId.guid, mode);
|
||||||
@@ -177,7 +177,7 @@ export class PagePermissionResolver {
|
|||||||
await this.permission.checkWorkspace(
|
await this.permission.checkWorkspace(
|
||||||
docId.workspace,
|
docId.workspace,
|
||||||
user.id,
|
user.id,
|
||||||
Permission.Read
|
Permission.Write
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPublic = await this.permission.isPublicPage(
|
const isPublic = await this.permission.isPublicPage(
|
||||||
|
|||||||
284
packages/backend/server/src/core/workspaces/resolvers/team.ts
Normal file
284
packages/backend/server/src/core/workspaces/resolvers/team.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Args,
|
||||||
|
Mutation,
|
||||||
|
Parent,
|
||||||
|
ResolveField,
|
||||||
|
Resolver,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Cache,
|
||||||
|
EventEmitter,
|
||||||
|
MailService,
|
||||||
|
NotInSpace,
|
||||||
|
RequestMutex,
|
||||||
|
TooManyRequest,
|
||||||
|
} from '../../../fundamentals';
|
||||||
|
import { CurrentUser } from '../../auth';
|
||||||
|
import { Permission, PermissionService } from '../../permission';
|
||||||
|
import { QuotaManagementService } from '../../quota';
|
||||||
|
import { UserService } from '../../user';
|
||||||
|
import {
|
||||||
|
InviteResult,
|
||||||
|
WorkspaceInviteLinkExpireTime,
|
||||||
|
WorkspaceType,
|
||||||
|
} from '../types';
|
||||||
|
import { WorkspaceResolver } from './workspace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace team resolver
|
||||||
|
* Public apis rate limit: 10 req/m
|
||||||
|
* Other rate limit: 120 req/m
|
||||||
|
*/
|
||||||
|
@Resolver(() => WorkspaceType)
|
||||||
|
export class TeamWorkspaceResolver {
|
||||||
|
private readonly logger = new Logger(TeamWorkspaceResolver.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cache: Cache,
|
||||||
|
private readonly event: EventEmitter,
|
||||||
|
private readonly mailer: MailService,
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly permissions: PermissionService,
|
||||||
|
private readonly users: UserService,
|
||||||
|
private readonly quota: QuotaManagementService,
|
||||||
|
private readonly mutex: RequestMutex,
|
||||||
|
private readonly workspace: WorkspaceResolver
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@ResolveField(() => Boolean, {
|
||||||
|
name: 'team',
|
||||||
|
description: 'if workspace is team workspace',
|
||||||
|
complexity: 2,
|
||||||
|
})
|
||||||
|
team(@Parent() workspace: WorkspaceType) {
|
||||||
|
return this.quota.isTeamWorkspace(workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => [InviteResult])
|
||||||
|
async inviteBatch(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('workspaceId') workspaceId: string,
|
||||||
|
@Args({ name: 'emails', type: () => [String] }) emails: string[],
|
||||||
|
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
||||||
|
) {
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Admin
|
||||||
|
);
|
||||||
|
|
||||||
|
// lock to prevent concurrent invite
|
||||||
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
|
await using lock = await this.mutex.lock(lockFlag);
|
||||||
|
if (!lock) {
|
||||||
|
return new TooManyRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const quota = await this.quota.getWorkspaceUsage(workspaceId);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [idx, email] of emails.entries()) {
|
||||||
|
const ret: InviteResult = { email, sentSuccess: false, inviteId: null };
|
||||||
|
try {
|
||||||
|
let target = await this.users.findUserByEmail(email);
|
||||||
|
if (target) {
|
||||||
|
const originRecord =
|
||||||
|
await this.prisma.workspaceUserPermission.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId: target.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// only invite if the user is not already in the workspace
|
||||||
|
if (originRecord) continue;
|
||||||
|
} else {
|
||||||
|
target = await this.users.createUser({
|
||||||
|
email,
|
||||||
|
registered: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
|
||||||
|
|
||||||
|
ret.inviteId = await this.permissions.grant(
|
||||||
|
workspaceId,
|
||||||
|
target.id,
|
||||||
|
Permission.Write,
|
||||||
|
needMoreSeat
|
||||||
|
? WorkspaceMemberStatus.NeedMoreSeat
|
||||||
|
: WorkspaceMemberStatus.Pending
|
||||||
|
);
|
||||||
|
if (!needMoreSeat && sendInviteMail) {
|
||||||
|
const inviteInfo = await this.workspace.getInviteInfo(ret.inviteId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailer.sendInviteEmail(email, ret.inviteId, {
|
||||||
|
workspace: {
|
||||||
|
id: inviteInfo.workspace.id,
|
||||||
|
name: inviteInfo.workspace.name,
|
||||||
|
avatar: inviteInfo.workspace.avatar,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
avatar: inviteInfo.user?.avatarUrl || '',
|
||||||
|
name: inviteInfo.user?.name || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ret.sentSuccess = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`failed to send ${workspaceId} invite email to ${email}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('failed to invite user', e);
|
||||||
|
}
|
||||||
|
results.push(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = quota.memberCount + results.length;
|
||||||
|
if (memberCount > quota.memberLimit) {
|
||||||
|
this.event.emit('workspace.members.updated', {
|
||||||
|
workspaceId,
|
||||||
|
count: memberCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
async inviteLink(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('workspaceId') workspaceId: string,
|
||||||
|
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
|
||||||
|
expireTime: WorkspaceInviteLinkExpireTime
|
||||||
|
) {
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Admin
|
||||||
|
);
|
||||||
|
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
|
||||||
|
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
||||||
|
if (typeof invite?.inviteId === 'string') {
|
||||||
|
return invite.inviteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteId = nanoid();
|
||||||
|
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
|
||||||
|
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
|
||||||
|
await this.cache.set(cacheInviteId, { workspaceId }, { ttl: expireTime });
|
||||||
|
return inviteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean)
|
||||||
|
async revokeInviteLink(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('workspaceId') workspaceId: string
|
||||||
|
) {
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Admin
|
||||||
|
);
|
||||||
|
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||||
|
return await this.cache.delete(cacheId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
async approveMember(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('workspaceId') workspaceId: string,
|
||||||
|
@Args('userId') userId: string
|
||||||
|
) {
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Admin
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// lock to prevent concurrent invite and grant
|
||||||
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
|
await using lock = await this.mutex.lock(lockFlag);
|
||||||
|
if (!lock) {
|
||||||
|
return new TooManyRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnderReview =
|
||||||
|
(await this.permissions.getWorkspaceMemberStatus(
|
||||||
|
workspaceId,
|
||||||
|
userId
|
||||||
|
)) === WorkspaceMemberStatus.UnderReview;
|
||||||
|
if (isUnderReview) {
|
||||||
|
const result = await this.permissions.grant(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
Permission.Write,
|
||||||
|
WorkspaceMemberStatus.Accepted
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// TODO(@darkskygit): send team approve mail
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return new NotInSpace({ spaceId: workspaceId });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('failed to invite user', e);
|
||||||
|
return new TooManyRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
async grantMember(
|
||||||
|
@CurrentUser() user: CurrentUser,
|
||||||
|
@Args('workspaceId') workspaceId: string,
|
||||||
|
@Args('userId') userId: string,
|
||||||
|
@Args('permission', { type: () => Permission }) permission: Permission
|
||||||
|
) {
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Owner
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// lock to prevent concurrent invite and grant
|
||||||
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
|
await using lock = await this.mutex.lock(lockFlag);
|
||||||
|
if (!lock) {
|
||||||
|
return new TooManyRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMember = await this.permissions.isWorkspaceMember(
|
||||||
|
workspaceId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
if (isMember) {
|
||||||
|
const result = await this.permissions.grant(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
permission
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// TODO(@darkskygit): send team role changed mail
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return new NotInSpace({ spaceId: workspaceId });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('failed to invite user', e);
|
||||||
|
return new TooManyRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,23 +10,24 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
import { getStreamAsBuffer } from 'get-stream';
|
||||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
|
|
||||||
import type { FileUpload } from '../../../fundamentals';
|
import type { FileUpload } from '../../../fundamentals';
|
||||||
import {
|
import {
|
||||||
|
Cache,
|
||||||
CantChangeSpaceOwner,
|
CantChangeSpaceOwner,
|
||||||
DocNotFound,
|
DocNotFound,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
InternalServerError,
|
InternalServerError,
|
||||||
MailService,
|
MailService,
|
||||||
MemberQuotaExceeded,
|
|
||||||
RequestMutex,
|
RequestMutex,
|
||||||
SpaceAccessDenied,
|
SpaceAccessDenied,
|
||||||
SpaceNotFound,
|
SpaceNotFound,
|
||||||
Throttle,
|
Throttle,
|
||||||
TooManyRequest,
|
TooManyRequest,
|
||||||
|
UserFriendlyError,
|
||||||
UserNotFound,
|
UserNotFound,
|
||||||
} from '../../../fundamentals';
|
} from '../../../fundamentals';
|
||||||
import { CurrentUser, Public } from '../../auth';
|
import { CurrentUser, Public } from '../../auth';
|
||||||
@@ -78,6 +79,7 @@ export class WorkspaceResolver {
|
|||||||
private readonly logger = new Logger(WorkspaceResolver.name);
|
private readonly logger = new Logger(WorkspaceResolver.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly cache: Cache,
|
||||||
private readonly mailer: MailService,
|
private readonly mailer: MailService,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
@@ -153,31 +155,21 @@ export class WorkspaceResolver {
|
|||||||
@Args('take', { type: () => Int, nullable: true }) take?: number
|
@Args('take', { type: () => Int, nullable: true }) take?: number
|
||||||
) {
|
) {
|
||||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||||
where: {
|
where: { workspaceId: workspace.id },
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
skip,
|
skip,
|
||||||
take: take || 8,
|
take: take || 8,
|
||||||
orderBy: [
|
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
|
||||||
{
|
include: { user: true },
|
||||||
createdAt: 'asc',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'desc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data
|
return data
|
||||||
.filter(({ user }) => !!user)
|
.filter(({ user }) => !!user)
|
||||||
.map(({ id, accepted, type, user }) => ({
|
.map(({ id, accepted, status, type, user }) => ({
|
||||||
...user,
|
...user,
|
||||||
permission: type,
|
permission: type,
|
||||||
inviteId: id,
|
inviteId: id,
|
||||||
accepted,
|
accepted,
|
||||||
|
status,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +232,15 @@ export class WorkspaceResolver {
|
|||||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
accepted: true,
|
accepted: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
workspace: true,
|
workspace: true,
|
||||||
},
|
},
|
||||||
@@ -287,6 +286,7 @@ export class WorkspaceResolver {
|
|||||||
type: Permission.Owner,
|
type: Permission.Owner,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -331,7 +331,12 @@ export class WorkspaceResolver {
|
|||||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||||
{ id, ...updates }: UpdateWorkspaceInput
|
{ id, ...updates }: UpdateWorkspaceInput
|
||||||
) {
|
) {
|
||||||
await this.permissions.checkWorkspace(id, user.id, Permission.Admin);
|
const isTeam = await this.quota.isTeamWorkspace(id);
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
id,
|
||||||
|
user.id,
|
||||||
|
isTeam ? Permission.Owner : Permission.Admin
|
||||||
|
);
|
||||||
|
|
||||||
return this.prisma.workspace.update({
|
return this.prisma.workspace.update({
|
||||||
where: {
|
where: {
|
||||||
@@ -379,7 +384,7 @@ export class WorkspaceResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// lock to prevent concurrent invite
|
// lock to prevent concurrent invite and grant
|
||||||
const lockFlag = `invite:${workspaceId}`;
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
await using lock = await this.mutex.lock(lockFlag);
|
await using lock = await this.mutex.lock(lockFlag);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
@@ -387,10 +392,7 @@ export class WorkspaceResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// member limit check
|
// member limit check
|
||||||
const quota = await this.quota.getWorkspaceUsage(workspaceId);
|
await this.quota.checkWorkspaceSeat(workspaceId);
|
||||||
if (quota.memberCount >= quota.memberLimit) {
|
|
||||||
return new MemberQuotaExceeded();
|
|
||||||
}
|
|
||||||
|
|
||||||
let target = await this.users.findUserByEmail(email);
|
let target = await this.users.findUserByEmail(email);
|
||||||
if (target) {
|
if (target) {
|
||||||
@@ -452,6 +454,10 @@ export class WorkspaceResolver {
|
|||||||
}
|
}
|
||||||
return inviteId;
|
return inviteId;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// pass through user friendly error
|
||||||
|
if (e instanceof UserFriendlyError) {
|
||||||
|
return e;
|
||||||
|
}
|
||||||
this.logger.error('failed to invite user', e);
|
this.logger.error('failed to invite user', e);
|
||||||
return new TooManyRequest();
|
return new TooManyRequest();
|
||||||
}
|
}
|
||||||
@@ -463,7 +469,16 @@ export class WorkspaceResolver {
|
|||||||
description: 'send workspace invitation',
|
description: 'send workspace invitation',
|
||||||
})
|
})
|
||||||
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
||||||
const workspaceId = await this.prisma.workspaceUserPermission
|
let workspaceId = null;
|
||||||
|
// invite link
|
||||||
|
const invite = await this.cache.get<{ workspaceId: string }>(
|
||||||
|
`workspace:inviteLinkId:${inviteId}`
|
||||||
|
);
|
||||||
|
if (typeof invite?.workspaceId === 'string') {
|
||||||
|
workspaceId = invite.workspaceId;
|
||||||
|
}
|
||||||
|
if (!workspaceId) {
|
||||||
|
workspaceId = await this.prisma.workspaceUserPermission
|
||||||
.findUniqueOrThrow({
|
.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: inviteId,
|
id: inviteId,
|
||||||
@@ -473,6 +488,7 @@ export class WorkspaceResolver {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(({ workspaceId }) => workspaceId);
|
.then(({ workspaceId }) => workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||||
|
|
||||||
@@ -511,22 +527,81 @@ export class WorkspaceResolver {
|
|||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('userId') userId: string
|
@Args('userId') userId: string
|
||||||
) {
|
) {
|
||||||
|
const isTeam = await this.quota.isTeamWorkspace(workspaceId);
|
||||||
|
const isAdmin = await this.permissions.tryCheckWorkspaceIs(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
Permission.Admin
|
||||||
|
);
|
||||||
|
if (isTeam && isAdmin) {
|
||||||
|
// only owner can revoke team workspace admin
|
||||||
|
await this.permissions.checkWorkspaceIs(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Owner
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await this.permissions.checkWorkspace(
|
await this.permissions.checkWorkspace(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
Permission.Admin
|
Permission.Admin
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.permissions.revokeWorkspace(workspaceId, userId);
|
const result = await this.permissions.revokeWorkspace(workspaceId, userId);
|
||||||
|
|
||||||
|
if (result && isTeam) {
|
||||||
|
// TODO(@darkskygit): send team revoke mail
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@Public()
|
@Public()
|
||||||
async acceptInviteById(
|
async acceptInviteById(
|
||||||
|
@CurrentUser() user: CurrentUser | undefined,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('inviteId') inviteId: string,
|
@Args('inviteId') inviteId: string,
|
||||||
@Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean
|
@Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean
|
||||||
) {
|
) {
|
||||||
|
if (user) {
|
||||||
|
// invite link
|
||||||
|
const invite = await this.cache.get<{ inviteId: string }>(
|
||||||
|
`workspace:inviteLink:${workspaceId}`
|
||||||
|
);
|
||||||
|
if (invite?.inviteId === inviteId) {
|
||||||
|
const lockFlag = `invite:${workspaceId}`;
|
||||||
|
await using lock = await this.mutex.lock(lockFlag);
|
||||||
|
if (!lock) {
|
||||||
|
return new TooManyRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
const quota = await this.quota.getWorkspaceUsage(workspaceId);
|
||||||
|
if (quota.memberCount >= quota.memberLimit) {
|
||||||
|
await this.permissions.grant(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
Permission.Write,
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeatAndReview
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const inviteId = await this.permissions.grant(workspaceId, user.id);
|
||||||
|
// invite by link need admin to approve
|
||||||
|
return this.permissions.acceptWorkspaceInvitation(
|
||||||
|
inviteId,
|
||||||
|
workspaceId,
|
||||||
|
WorkspaceMemberStatus.UnderReview
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we added seats when sending invitation emails, but the deduction may fail
|
||||||
|
// so we need to check seat again here
|
||||||
|
await this.quota.checkWorkspaceSeat(workspaceId, true);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
invitee,
|
invitee,
|
||||||
user: inviter,
|
user: inviter,
|
||||||
@@ -538,6 +613,7 @@ export class WorkspaceResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sendAcceptMail) {
|
if (sendAcceptMail) {
|
||||||
|
// TODO(@darkskygit): team accept mail
|
||||||
await this.mailer.sendAcceptedEmail(inviter.email, {
|
await this.mailer.sendAcceptedEmail(inviter.email, {
|
||||||
inviteeName: invitee.name,
|
inviteeName: invitee.name,
|
||||||
workspaceName: workspace.name,
|
workspaceName: workspace.name,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
PickType,
|
PickType,
|
||||||
registerEnumType,
|
registerEnumType,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import type { Workspace } from '@prisma/client';
|
import { Workspace, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import { SafeIntResolver } from 'graphql-scalars';
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
import { Permission } from '../permission';
|
import { Permission } from '../permission';
|
||||||
@@ -19,6 +19,11 @@ registerEnumType(Permission, {
|
|||||||
description: 'User permission in workspace',
|
description: 'User permission in workspace',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerEnumType(WorkspaceMemberStatus, {
|
||||||
|
name: 'WorkspaceMemberStatus',
|
||||||
|
description: 'Member invite status in workspace',
|
||||||
|
});
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class InviteUserType extends OmitType(
|
export class InviteUserType extends OmitType(
|
||||||
PartialType(UserType),
|
PartialType(UserType),
|
||||||
@@ -34,8 +39,16 @@ export class InviteUserType extends OmitType(
|
|||||||
@Field({ description: 'Invite id' })
|
@Field({ description: 'Invite id' })
|
||||||
inviteId!: string;
|
inviteId!: string;
|
||||||
|
|
||||||
@Field({ description: 'User accepted' })
|
@Field({
|
||||||
|
description: 'User accepted',
|
||||||
|
deprecationReason: 'Use `status` instead',
|
||||||
|
})
|
||||||
accepted!: boolean;
|
accepted!: boolean;
|
||||||
|
|
||||||
|
@Field(() => WorkspaceMemberStatus, {
|
||||||
|
description: 'Member invite status in workspace',
|
||||||
|
})
|
||||||
|
status!: WorkspaceMemberStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
@@ -46,6 +59,9 @@ export class WorkspaceType implements Partial<Workspace> {
|
|||||||
@Field({ description: 'is Public workspace' })
|
@Field({ description: 'is Public workspace' })
|
||||||
public!: boolean;
|
public!: boolean;
|
||||||
|
|
||||||
|
@Field({ description: 'Enable AI' })
|
||||||
|
enableAi!: boolean;
|
||||||
|
|
||||||
@Field({ description: 'Enable url previous when sharing' })
|
@Field({ description: 'Enable url previous when sharing' })
|
||||||
enableUrlPreview!: boolean;
|
enableUrlPreview!: boolean;
|
||||||
|
|
||||||
@@ -92,9 +108,38 @@ export class InvitationType {
|
|||||||
@InputType()
|
@InputType()
|
||||||
export class UpdateWorkspaceInput extends PickType(
|
export class UpdateWorkspaceInput extends PickType(
|
||||||
PartialType(WorkspaceType),
|
PartialType(WorkspaceType),
|
||||||
['public', 'enableUrlPreview'],
|
['public', 'enableAi', 'enableUrlPreview'],
|
||||||
InputType
|
InputType
|
||||||
) {
|
) {
|
||||||
@Field(() => ID)
|
@Field(() => ID)
|
||||||
id!: string;
|
id!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class InviteResult {
|
||||||
|
@Field(() => String)
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Field(() => String, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'Invite id, null if invite record create failed',
|
||||||
|
})
|
||||||
|
inviteId!: string | null;
|
||||||
|
|
||||||
|
@Field(() => Boolean, { description: 'Invite email sent success' })
|
||||||
|
sentSuccess!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Day = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export enum WorkspaceInviteLinkExpireTime {
|
||||||
|
OneDay = Day,
|
||||||
|
ThreeDays = 3 * Day,
|
||||||
|
OneWeek = 7 * Day,
|
||||||
|
OneMonth = 30 * Day,
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(WorkspaceInviteLinkExpireTime, {
|
||||||
|
name: 'WorkspaceInviteLinkExpireTime',
|
||||||
|
description: 'Workspace invite link expire time',
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class MigrateInviteStatus1732861452428 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaClient) {
|
||||||
|
await db.workspaceUserPermission.updateMany({
|
||||||
|
where: {
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down(_db: PrismaClient) {}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import './config';
|
|||||||
import { ServerFeature } from '../../core/config';
|
import { ServerFeature } from '../../core/config';
|
||||||
import { FeatureModule } from '../../core/features';
|
import { FeatureModule } from '../../core/features';
|
||||||
import { PermissionModule } from '../../core/permission';
|
import { PermissionModule } from '../../core/permission';
|
||||||
|
import { QuotaModule } from '../../core/quota';
|
||||||
import { UserModule } from '../../core/user';
|
import { UserModule } from '../../core/user';
|
||||||
import { Plugin } from '../registry';
|
import { Plugin } from '../registry';
|
||||||
import { StripeWebhookController } from './controller';
|
import { StripeWebhookController } from './controller';
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
UserSubscriptionManager,
|
UserSubscriptionManager,
|
||||||
WorkspaceSubscriptionManager,
|
WorkspaceSubscriptionManager,
|
||||||
} from './manager';
|
} from './manager';
|
||||||
|
import { TeamQuotaOverride } from './quota';
|
||||||
import {
|
import {
|
||||||
SubscriptionResolver,
|
SubscriptionResolver,
|
||||||
UserSubscriptionResolver,
|
UserSubscriptionResolver,
|
||||||
@@ -22,7 +24,7 @@ import { StripeWebhook } from './webhook';
|
|||||||
|
|
||||||
@Plugin({
|
@Plugin({
|
||||||
name: 'payment',
|
name: 'payment',
|
||||||
imports: [FeatureModule, UserModule, PermissionModule],
|
imports: [FeatureModule, QuotaModule, UserModule, PermissionModule],
|
||||||
providers: [
|
providers: [
|
||||||
StripeProvider,
|
StripeProvider,
|
||||||
SubscriptionService,
|
SubscriptionService,
|
||||||
@@ -33,6 +35,7 @@ import { StripeWebhook } from './webhook';
|
|||||||
WorkspaceSubscriptionManager,
|
WorkspaceSubscriptionManager,
|
||||||
SubscriptionCronJobs,
|
SubscriptionCronJobs,
|
||||||
WorkspaceSubscriptionResolver,
|
WorkspaceSubscriptionResolver,
|
||||||
|
TeamQuotaOverride,
|
||||||
],
|
],
|
||||||
controllers: [StripeWebhookController],
|
controllers: [StripeWebhookController],
|
||||||
requires: [
|
requires: [
|
||||||
|
|||||||
53
packages/backend/server/src/plugins/payment/quota.ts
Normal file
53
packages/backend/server/src/plugins/payment/quota.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
|
import { PermissionService } from '../../core/permission';
|
||||||
|
import { QuotaManagementService, QuotaType } from '../../core/quota';
|
||||||
|
import type { EventPayload } from '../../fundamentals';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamQuotaOverride {
|
||||||
|
constructor(
|
||||||
|
private readonly manager: QuotaManagementService,
|
||||||
|
private readonly permission: PermissionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent('workspace.subscription.activated')
|
||||||
|
async onSubscriptionUpdated({
|
||||||
|
workspaceId,
|
||||||
|
plan,
|
||||||
|
recurring,
|
||||||
|
quantity,
|
||||||
|
}: EventPayload<'workspace.subscription.activated'>) {
|
||||||
|
switch (plan) {
|
||||||
|
case 'team':
|
||||||
|
await this.manager.addTeamWorkspace(
|
||||||
|
workspaceId,
|
||||||
|
`${recurring} team subscription activated`
|
||||||
|
);
|
||||||
|
await this.manager.updateWorkspaceConfig(
|
||||||
|
workspaceId,
|
||||||
|
QuotaType.TeamPlanV1,
|
||||||
|
{ memberLimit: quantity }
|
||||||
|
);
|
||||||
|
await this.permission.refreshSeatStatus(workspaceId, quantity);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('workspace.subscription.canceled')
|
||||||
|
async onSubscriptionCanceled({
|
||||||
|
workspaceId,
|
||||||
|
plan,
|
||||||
|
}: EventPayload<'workspace.subscription.canceled'>) {
|
||||||
|
switch (plan) {
|
||||||
|
case 'team':
|
||||||
|
await this.manager.removeTeamWorkspace(workspaceId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,9 +361,19 @@ type InvitationWorkspaceType {
|
|||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InviteResult {
|
||||||
|
email: String!
|
||||||
|
|
||||||
|
"""Invite id, null if invite record create failed"""
|
||||||
|
inviteId: String
|
||||||
|
|
||||||
|
"""Invite email sent success"""
|
||||||
|
sentSuccess: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
type InviteUserType {
|
type InviteUserType {
|
||||||
"""User accepted"""
|
"""User accepted"""
|
||||||
accepted: Boolean!
|
accepted: Boolean! @deprecated(reason: "Use `status` instead")
|
||||||
|
|
||||||
"""User avatar url"""
|
"""User avatar url"""
|
||||||
avatarUrl: String
|
avatarUrl: String
|
||||||
@@ -389,6 +399,9 @@ type InviteUserType {
|
|||||||
|
|
||||||
"""User permission in workspace"""
|
"""User permission in workspace"""
|
||||||
permission: Permission!
|
permission: Permission!
|
||||||
|
|
||||||
|
"""Member invite status in workspace"""
|
||||||
|
status: WorkspaceMemberStatus!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum InvoiceStatus {
|
enum InvoiceStatus {
|
||||||
@@ -451,6 +464,7 @@ type MissingOauthQueryParameterDataType {
|
|||||||
type Mutation {
|
type Mutation {
|
||||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
||||||
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||||
|
approveMember(userId: String!, workspaceId: String!): String!
|
||||||
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||||
changeEmail(email: String!, token: String!): UserType!
|
changeEmail(email: String!, token: String!): UserType!
|
||||||
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
|
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
|
||||||
@@ -490,7 +504,10 @@ type Mutation {
|
|||||||
|
|
||||||
"""Create a chat session"""
|
"""Create a chat session"""
|
||||||
forkCopilotSession(options: ForkChatSessionInput!): String!
|
forkCopilotSession(options: ForkChatSessionInput!): String!
|
||||||
|
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
|
||||||
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
|
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
|
||||||
|
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
||||||
|
inviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!
|
||||||
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean!
|
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean!
|
||||||
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
|
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
|
||||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||||
@@ -500,6 +517,7 @@ type Mutation {
|
|||||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||||
revoke(userId: String!, workspaceId: String!): Boolean!
|
revoke(userId: String!, workspaceId: String!): Boolean!
|
||||||
|
revokeInviteLink(workspaceId: String!): Boolean!
|
||||||
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
||||||
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
|
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
|
||||||
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
||||||
@@ -831,6 +849,9 @@ input UpdateUserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input UpdateWorkspaceInput {
|
input UpdateWorkspaceInput {
|
||||||
|
"""Enable AI"""
|
||||||
|
enableAi: Boolean
|
||||||
|
|
||||||
"""Enable url previous when sharing"""
|
"""Enable url previous when sharing"""
|
||||||
enableUrlPreview: Boolean
|
enableUrlPreview: Boolean
|
||||||
id: ID!
|
id: ID!
|
||||||
@@ -902,6 +923,22 @@ type WorkspaceBlobSizes {
|
|||||||
size: SafeInt!
|
size: SafeInt!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""Workspace invite link expire time"""
|
||||||
|
enum WorkspaceInviteLinkExpireTime {
|
||||||
|
OneDay
|
||||||
|
OneMonth
|
||||||
|
OneWeek
|
||||||
|
ThreeDays
|
||||||
|
}
|
||||||
|
|
||||||
|
"""Member invite status in workspace"""
|
||||||
|
enum WorkspaceMemberStatus {
|
||||||
|
Accepted
|
||||||
|
NeedMoreSeat
|
||||||
|
Pending
|
||||||
|
UnderReview
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspacePage {
|
type WorkspacePage {
|
||||||
id: String!
|
id: String!
|
||||||
mode: PublicPageMode!
|
mode: PublicPageMode!
|
||||||
@@ -929,6 +966,9 @@ type WorkspaceType {
|
|||||||
"""Workspace created date"""
|
"""Workspace created date"""
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
|
|
||||||
|
"""Enable AI"""
|
||||||
|
enableAi: Boolean!
|
||||||
|
|
||||||
"""Enable url previous when sharing"""
|
"""Enable url previous when sharing"""
|
||||||
enableUrlPreview: Boolean!
|
enableUrlPreview: Boolean!
|
||||||
|
|
||||||
@@ -976,6 +1016,9 @@ type WorkspaceType {
|
|||||||
|
|
||||||
"""The team subscription of the workspace, if exists."""
|
"""The team subscription of the workspace, if exists."""
|
||||||
subscription: SubscriptionType
|
subscription: SubscriptionType
|
||||||
|
|
||||||
|
"""if workspace is team workspace"""
|
||||||
|
team: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenType {
|
type tokenType {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/// <reference types="../src/global.d.ts" />
|
/// <reference types="../src/global.d.ts" />
|
||||||
|
|
||||||
import { INestApplication, Injectable } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava from 'ava';
|
import ava from 'ava';
|
||||||
|
|
||||||
@@ -12,32 +11,10 @@ import {
|
|||||||
FeatureService,
|
FeatureService,
|
||||||
FeatureType,
|
FeatureType,
|
||||||
} from '../src/core/features';
|
} from '../src/core/features';
|
||||||
import { Permission } from '../src/core/permission';
|
|
||||||
import { UserType } from '../src/core/user/types';
|
|
||||||
import { WorkspaceResolver } from '../src/core/workspaces/resolvers';
|
import { WorkspaceResolver } from '../src/core/workspaces/resolvers';
|
||||||
import { Config, ConfigModule } from '../src/fundamentals/config';
|
import { Config, ConfigModule } from '../src/fundamentals/config';
|
||||||
import { createTestingApp } from './utils';
|
import { createTestingApp } from './utils';
|
||||||
|
import { WorkspaceResolverMock } from './utils/feature';
|
||||||
@Injectable()
|
|
||||||
class WorkspaceResolverMock {
|
|
||||||
constructor(private readonly prisma: PrismaClient) {}
|
|
||||||
|
|
||||||
async createWorkspace(user: UserType, _init: null) {
|
|
||||||
const workspace = await this.prisma.workspace.create({
|
|
||||||
data: {
|
|
||||||
public: false,
|
|
||||||
permissions: {
|
|
||||||
create: {
|
|
||||||
type: Permission.Owner,
|
|
||||||
userId: user.id,
|
|
||||||
accepted: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return workspace;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
@@ -105,7 +82,7 @@ test('should be able to check early access', async t => {
|
|||||||
const f2 = await management.canEarlyAccess(u1.email);
|
const f2 = await management.canEarlyAccess(u1.email);
|
||||||
t.true(f2, 'should have early access');
|
t.true(f2, 'should have early access');
|
||||||
|
|
||||||
const f3 = await feature.listFeatureUsers(FeatureType.EarlyAccess);
|
const f3 = await feature.listUsersByFeature(FeatureType.EarlyAccess);
|
||||||
t.is(f3.length, 1, 'should have 1 user');
|
t.is(f3.length, 1, 'should have 1 user');
|
||||||
t.is(f3[0].id, u1.id, 'should be the same user');
|
t.is(f3[0].id, u1.id, 'should be the same user');
|
||||||
});
|
});
|
||||||
@@ -179,7 +156,7 @@ test('should be able to check workspace feature', async t => {
|
|||||||
const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||||
t.true(f2, 'should have copilot');
|
t.true(f2, 'should have copilot');
|
||||||
|
|
||||||
const f3 = await feature.listFeatureWorkspaces(FeatureType.Copilot);
|
const f3 = await feature.listWorkspacesByFeature(FeatureType.Copilot);
|
||||||
t.is(f3.length, 1, 'should have 1 workspace');
|
t.is(f3.length, 1, 'should have 1 workspace');
|
||||||
t.is(f3[0].id, w1.id, 'should be the same workspace');
|
t.is(f3[0].id, w1.id, 'should be the same workspace');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,29 +11,41 @@ import {
|
|||||||
QuotaService,
|
QuotaService,
|
||||||
QuotaType,
|
QuotaType,
|
||||||
} from '../src/core/quota';
|
} from '../src/core/quota';
|
||||||
|
import { OneGB, OneMB } from '../src/core/quota/constant';
|
||||||
import { FreePlan, ProPlan } from '../src/core/quota/schema';
|
import { FreePlan, ProPlan } from '../src/core/quota/schema';
|
||||||
import { StorageModule } from '../src/core/storage';
|
import { StorageModule } from '../src/core/storage';
|
||||||
|
import { WorkspaceResolver } from '../src/core/workspaces/resolvers';
|
||||||
import { createTestingModule } from './utils';
|
import { createTestingModule } from './utils';
|
||||||
|
import { WorkspaceResolverMock } from './utils/feature';
|
||||||
|
|
||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
quota: QuotaService;
|
quota: QuotaService;
|
||||||
quotaManager: QuotaManagementService;
|
quotaManager: QuotaManagementService;
|
||||||
|
workspace: WorkspaceResolver;
|
||||||
module: TestingModule;
|
module: TestingModule;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.beforeEach(async t => {
|
test.beforeEach(async t => {
|
||||||
const module = await createTestingModule({
|
const module = await createTestingModule({
|
||||||
imports: [StorageModule, QuotaModule],
|
imports: [StorageModule, QuotaModule],
|
||||||
|
providers: [WorkspaceResolver],
|
||||||
|
tapModule: module => {
|
||||||
|
module
|
||||||
|
.overrideProvider(WorkspaceResolver)
|
||||||
|
.useClass(WorkspaceResolverMock);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const quota = module.get(QuotaService);
|
const quota = module.get(QuotaService);
|
||||||
const quotaManager = module.get(QuotaManagementService);
|
const quotaManager = module.get(QuotaManagementService);
|
||||||
|
const workspace = module.get(WorkspaceResolver);
|
||||||
const auth = module.get(AuthService);
|
const auth = module.get(AuthService);
|
||||||
|
|
||||||
t.context.module = module;
|
t.context.module = module;
|
||||||
t.context.quota = quota;
|
t.context.quota = quota;
|
||||||
t.context.quotaManager = quotaManager;
|
t.context.quotaManager = quotaManager;
|
||||||
|
t.context.workspace = workspace;
|
||||||
t.context.auth = auth;
|
t.context.auth = auth;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,3 +140,28 @@ test('should be able to check quota', async t => {
|
|||||||
'should be free plan'
|
'should be free plan'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should be able to override quota', async t => {
|
||||||
|
const { auth, quotaManager, workspace } = t.context;
|
||||||
|
|
||||||
|
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||||
|
const w1 = await workspace.createWorkspace(u1, null);
|
||||||
|
|
||||||
|
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||||
|
t.is(wq1.blobLimit, 10 * OneMB, 'should be 10MB');
|
||||||
|
t.is(wq1.businessBlobLimit, 100 * OneMB, 'should be 100MB');
|
||||||
|
t.is(wq1.memberLimit, 3, 'should be 3');
|
||||||
|
|
||||||
|
await quotaManager.addTeamWorkspace(w1.id, 'test');
|
||||||
|
const wq2 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||||
|
t.is(wq2.storageQuota, 120 * OneGB, 'should be override to 100GB');
|
||||||
|
t.is(wq2.businessBlobLimit, 500 * OneMB, 'should be override to 500MB');
|
||||||
|
t.is(wq2.memberLimit, 1, 'should be override to 1');
|
||||||
|
|
||||||
|
await quotaManager.updateWorkspaceConfig(w1.id, QuotaType.TeamPlanV1, {
|
||||||
|
memberLimit: 2,
|
||||||
|
});
|
||||||
|
const wq3 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||||
|
t.is(wq3.storageQuota, 140 * OneGB, 'should be override to 120GB');
|
||||||
|
t.is(wq3.memberLimit, 2, 'should be override to 1');
|
||||||
|
});
|
||||||
|
|||||||
287
packages/backend/server/tests/team.e2e.ts
Normal file
287
packages/backend/server/tests/team.e2e.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/// <reference types="../src/global.d.ts" />
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||||
|
import type { TestFn } from 'ava';
|
||||||
|
import ava from 'ava';
|
||||||
|
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { AuthService } from '../src/core/auth';
|
||||||
|
import { Permission, PermissionService } from '../src/core/permission';
|
||||||
|
import {
|
||||||
|
QuotaManagementService,
|
||||||
|
QuotaService,
|
||||||
|
QuotaType,
|
||||||
|
} from '../src/core/quota';
|
||||||
|
import {
|
||||||
|
acceptInviteById,
|
||||||
|
createTestingApp,
|
||||||
|
createWorkspace,
|
||||||
|
grantMember,
|
||||||
|
inviteLink,
|
||||||
|
inviteUser,
|
||||||
|
inviteUsers,
|
||||||
|
leaveWorkspace,
|
||||||
|
PermissionEnum,
|
||||||
|
signUp,
|
||||||
|
sleep,
|
||||||
|
UserAuthedType,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
const test = ava as TestFn<{
|
||||||
|
app: INestApplication;
|
||||||
|
auth: AuthService;
|
||||||
|
quota: QuotaService;
|
||||||
|
quotaManager: QuotaManagementService;
|
||||||
|
permissions: PermissionService;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
test.beforeEach(async t => {
|
||||||
|
const { app } = await createTestingApp({
|
||||||
|
imports: [AppModule],
|
||||||
|
});
|
||||||
|
|
||||||
|
const quota = app.get(QuotaService);
|
||||||
|
const quotaManager = app.get(QuotaManagementService);
|
||||||
|
const permissions = app.get(PermissionService);
|
||||||
|
const auth = app.get(AuthService);
|
||||||
|
|
||||||
|
t.context.app = app;
|
||||||
|
t.context.quota = quota;
|
||||||
|
t.context.quotaManager = quotaManager;
|
||||||
|
t.context.permissions = permissions;
|
||||||
|
t.context.auth = auth;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach.always(async t => {
|
||||||
|
await t.context.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const init = async (app: INestApplication, memberLimit = 10) => {
|
||||||
|
const owner = await signUp(app, 'test', 'test@affine.pro', '123456');
|
||||||
|
const ws = await createWorkspace(app, owner.token.token);
|
||||||
|
|
||||||
|
const quota = app.get(QuotaManagementService);
|
||||||
|
await quota.addTeamWorkspace(ws.id, 'test');
|
||||||
|
await quota.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, {
|
||||||
|
memberLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invite = async (
|
||||||
|
email: string,
|
||||||
|
permission: PermissionEnum = 'Write'
|
||||||
|
) => {
|
||||||
|
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||||
|
const inviteId = await inviteUser(
|
||||||
|
app,
|
||||||
|
owner.token.token,
|
||||||
|
ws.id,
|
||||||
|
member.email,
|
||||||
|
permission
|
||||||
|
);
|
||||||
|
await acceptInviteById(app, ws.id, inviteId);
|
||||||
|
return member;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inviteBatch = async (emails: string[]) => {
|
||||||
|
const members = [];
|
||||||
|
for (const email of emails) {
|
||||||
|
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||||
|
members.push(member);
|
||||||
|
}
|
||||||
|
const invites = await inviteUsers(app, owner.token.token, ws.id, emails);
|
||||||
|
return [members, invites] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInviteLink = async () => {
|
||||||
|
const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay');
|
||||||
|
return async (email: string): Promise<UserAuthedType> => {
|
||||||
|
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||||
|
await acceptInviteById(app, ws.id, inviteId, false, member.token.token);
|
||||||
|
return member;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const admin = await invite('admin@affine.pro', 'Admin');
|
||||||
|
const write = await invite('member1@affine.pro');
|
||||||
|
const read = await invite('member2@affine.pro', 'Read');
|
||||||
|
|
||||||
|
return {
|
||||||
|
invite,
|
||||||
|
inviteBatch,
|
||||||
|
createInviteLink,
|
||||||
|
owner,
|
||||||
|
ws,
|
||||||
|
admin,
|
||||||
|
write,
|
||||||
|
read,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
test('should be able to check seat limit', async t => {
|
||||||
|
const { app, permissions, quotaManager } = t.context;
|
||||||
|
const { invite, inviteBatch, ws } = await init(app, 4);
|
||||||
|
|
||||||
|
{
|
||||||
|
// invite
|
||||||
|
await t.throwsAsync(
|
||||||
|
invite('member3@affine.pro', 'Read'),
|
||||||
|
{ message: 'You have exceeded your workspace member quota.' },
|
||||||
|
'should throw error if exceed member limit'
|
||||||
|
);
|
||||||
|
await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, {
|
||||||
|
memberLimit: 5,
|
||||||
|
});
|
||||||
|
await t.notThrowsAsync(
|
||||||
|
invite('member4@affine.pro', 'Read'),
|
||||||
|
'should not throw error if not exceed member limit'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const members1 = inviteBatch(['member5@affine.pro']);
|
||||||
|
// invite batch
|
||||||
|
await t.notThrowsAsync(
|
||||||
|
members1,
|
||||||
|
'should not throw error in batch invite event reach limit'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(
|
||||||
|
ws.id,
|
||||||
|
(await members1)[0][0].id
|
||||||
|
),
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeat,
|
||||||
|
'should be able to check member status'
|
||||||
|
);
|
||||||
|
|
||||||
|
// refresh seat, fifo
|
||||||
|
sleep(1000);
|
||||||
|
const [[members2]] = await inviteBatch(['member6@affine.pro']);
|
||||||
|
await permissions.refreshSeatStatus(ws.id, 6);
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(
|
||||||
|
ws.id,
|
||||||
|
(await members1)[0][0].id
|
||||||
|
),
|
||||||
|
WorkspaceMemberStatus.Accepted,
|
||||||
|
'should become accepted after refresh'
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(ws.id, members2.id),
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeat,
|
||||||
|
'should not change status'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to grant team member permission', async t => {
|
||||||
|
const { app, permissions } = t.context;
|
||||||
|
const { owner, ws, admin, write, read } = await init(app);
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
grantMember(app, read.token.token, ws.id, write.id, 'Write'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not owner'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
grantMember(app, write.token.token, ws.id, read.id, 'Write'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not owner'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
grantMember(app, admin.token.token, ws.id, read.id, 'Write'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not owner'
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
// owner should be able to grant permission
|
||||||
|
t.true(
|
||||||
|
await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Read),
|
||||||
|
'should be able to check permission'
|
||||||
|
);
|
||||||
|
t.truthy(
|
||||||
|
await grantMember(app, owner.token.token, ws.id, read.id, 'Admin'),
|
||||||
|
'should be able to grant permission'
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Admin),
|
||||||
|
'should be able to check permission'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to leave workspace', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const { owner, ws, admin, write, read } = await init(app);
|
||||||
|
|
||||||
|
t.false(
|
||||||
|
await leaveWorkspace(app, owner.token.token, ws.id),
|
||||||
|
'owner should not be able to leave workspace'
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
await leaveWorkspace(app, admin.token.token, ws.id),
|
||||||
|
'admin should be able to leave workspace'
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
await leaveWorkspace(app, write.token.token, ws.id),
|
||||||
|
'write should be able to leave workspace'
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
await leaveWorkspace(app, read.token.token, ws.id),
|
||||||
|
'read should be able to leave workspace'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to invite by link', async t => {
|
||||||
|
const { app, permissions, quotaManager } = t.context;
|
||||||
|
const { createInviteLink, ws } = await init(app, 4);
|
||||||
|
const invite = await createInviteLink();
|
||||||
|
{
|
||||||
|
// invite link
|
||||||
|
const members: UserAuthedType[] = [];
|
||||||
|
await t.notThrowsAsync(async () => {
|
||||||
|
members.push(await invite('member3@affine.pro'));
|
||||||
|
members.push(await invite('member4@affine.pro'));
|
||||||
|
}, 'should not throw error even exceed member limit');
|
||||||
|
const [m3, m4] = members;
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(ws.id, m3.id),
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
|
'should not change status'
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(ws.id, m4.id),
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
|
'should not change status'
|
||||||
|
);
|
||||||
|
|
||||||
|
await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, {
|
||||||
|
memberLimit: 5,
|
||||||
|
});
|
||||||
|
await permissions.refreshSeatStatus(ws.id, 5);
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(ws.id, m3.id),
|
||||||
|
WorkspaceMemberStatus.UnderReview,
|
||||||
|
'should not change status'
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(ws.id, m4.id),
|
||||||
|
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||||
|
'should not change status'
|
||||||
|
);
|
||||||
|
|
||||||
|
await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, {
|
||||||
|
memberLimit: 6,
|
||||||
|
});
|
||||||
|
await permissions.refreshSeatStatus(ws.id, 6);
|
||||||
|
t.is(
|
||||||
|
await permissions.getWorkspaceMemberStatus(ws.id, m4.id),
|
||||||
|
WorkspaceMemberStatus.UnderReview,
|
||||||
|
'should not change status'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
27
packages/backend/server/tests/utils/feature.ts
Normal file
27
packages/backend/server/tests/utils/feature.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { Permission } from '../../src/core/permission';
|
||||||
|
import { UserType } from '../../src/core/user/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceResolverMock {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async createWorkspace(user: UserType, _init: null) {
|
||||||
|
const workspace = await this.prisma.workspace.create({
|
||||||
|
data: {
|
||||||
|
public: false,
|
||||||
|
permissions: {
|
||||||
|
create: {
|
||||||
|
type: Permission.Owner,
|
||||||
|
userId: user.id,
|
||||||
|
accepted: true,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ import request from 'supertest';
|
|||||||
|
|
||||||
import type { InvitationType } from '../../src/core/workspaces';
|
import type { InvitationType } from '../../src/core/workspaces';
|
||||||
import { gql } from './common';
|
import { gql } from './common';
|
||||||
|
import { PermissionEnum } from './utils';
|
||||||
|
|
||||||
export async function inviteUser(
|
export async function inviteUser(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
token: string,
|
token: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
email: string,
|
email: string,
|
||||||
permission: string,
|
permission: PermissionEnum,
|
||||||
sendInviteMail = false
|
sendInviteMail = false
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
@@ -24,18 +25,81 @@ export async function inviteUser(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
return res.body.data.invite;
|
return res.body.data.invite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function inviteUsers(
|
||||||
|
app: INestApplication,
|
||||||
|
token: string,
|
||||||
|
workspaceId: string,
|
||||||
|
emails: string[],
|
||||||
|
sendInviteMail = false
|
||||||
|
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
|
||||||
|
inviteBatch(
|
||||||
|
workspaceId: $workspaceId
|
||||||
|
emails: $emails
|
||||||
|
sendInviteMail: $sendInviteMail
|
||||||
|
) {
|
||||||
|
email
|
||||||
|
inviteId
|
||||||
|
sentSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { workspaceId, emails, sendInviteMail },
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
|
return res.body.data.inviteBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteLink(
|
||||||
|
app: INestApplication,
|
||||||
|
token: string,
|
||||||
|
workspaceId: string,
|
||||||
|
expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth'
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
inviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime})
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
|
return res.body.data.inviteLink;
|
||||||
|
}
|
||||||
|
|
||||||
export async function acceptInviteById(
|
export async function acceptInviteById(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
inviteId: string,
|
inviteId: string,
|
||||||
sendAcceptMail = false
|
sendAcceptMail = false,
|
||||||
|
token: string = ''
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(gql)
|
.post(gql)
|
||||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
.send({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
mutation {
|
mutation {
|
||||||
@@ -44,6 +108,9 @@ export async function acceptInviteById(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
return res.body.data.acceptInviteById;
|
return res.body.data.acceptInviteById;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +132,9 @@ export async function leaveWorkspace(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
return res.body.data.leaveWorkspace;
|
return res.body.data.leaveWorkspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { sessionUser } from '../../src/core/auth/service';
|
|||||||
import { UserService, type UserType } from '../../src/core/user';
|
import { UserService, type UserType } from '../../src/core/user';
|
||||||
import { gql } from './common';
|
import { gql } from './common';
|
||||||
|
|
||||||
|
export type UserAuthedType = UserType & { token: ClientTokenType };
|
||||||
|
|
||||||
export async function internalSignIn(app: INestApplication, userId: string) {
|
export async function internalSignIn(app: INestApplication, userId: string) {
|
||||||
const auth = app.get(AuthService);
|
const auth = app.get(AuthService);
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ export async function signUp(
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
autoVerifyEmail = true
|
autoVerifyEmail = true
|
||||||
): Promise<UserType & { token: ClientTokenType }> {
|
): Promise<UserAuthedType> {
|
||||||
const user = await app.get(UserService).createUser({
|
const user = await app.get(UserService).createUser({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
@@ -176,7 +178,7 @@ export async function changeEmail(
|
|||||||
userToken: string,
|
userToken: string,
|
||||||
token: string,
|
token: string,
|
||||||
email: string
|
email: string
|
||||||
): Promise<UserType & { token: ClientTokenType }> {
|
): Promise<UserAuthedType> {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(gql)
|
.post(gql)
|
||||||
.auth(userToken, { type: 'bearer' })
|
.auth(userToken, { type: 'bearer' })
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652
|
|||||||
import { Config, GlobalExceptionFilter } from '../../src/fundamentals';
|
import { Config, GlobalExceptionFilter } from '../../src/fundamentals';
|
||||||
import { GqlModule } from '../../src/fundamentals/graphql';
|
import { GqlModule } from '../../src/fundamentals/graphql';
|
||||||
|
|
||||||
|
export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read';
|
||||||
|
|
||||||
async function flushDB(client: PrismaClient) {
|
async function flushDB(client: PrismaClient) {
|
||||||
const result: { tablename: string }[] =
|
const result: { tablename: string }[] =
|
||||||
await client.$queryRaw`SELECT tablename
|
await client.$queryRaw`SELECT tablename
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import request from 'supertest';
|
|||||||
|
|
||||||
import type { WorkspaceType } from '../../src/core/workspaces';
|
import type { WorkspaceType } from '../../src/core/workspaces';
|
||||||
import { gql } from './common';
|
import { gql } from './common';
|
||||||
|
import { PermissionEnum } from './utils';
|
||||||
|
|
||||||
export async function createWorkspace(
|
export async function createWorkspace(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
@@ -150,3 +151,32 @@ export async function revokePublicPage(
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage;
|
return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function grantMember(
|
||||||
|
app: INestApplication,
|
||||||
|
token: string,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
permission: PermissionEnum
|
||||||
|
) {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
grantMember(
|
||||||
|
workspaceId: "${workspaceId}"
|
||||||
|
userId: "${userId}"
|
||||||
|
permission: ${permission}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
|
return res.body.data?.grantMember;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
import { HttpStatus, INestApplication } from '@nestjs/common';
|
import { HttpStatus, INestApplication } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import ava, { TestFn } from 'ava';
|
import ava, { TestFn } from 'ava';
|
||||||
import Sinon from 'sinon';
|
import Sinon from 'sinon';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
@@ -182,6 +182,7 @@ test('should be able to get permission granted workspace', async t => {
|
|||||||
userId: u1.id,
|
userId: u1.id,
|
||||||
type: 1,
|
type: 1,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
status: WorkspaceMemberStatus.Accepted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import type { GetEnableUrlPreviewQuery } from '@affine/graphql';
|
import type { GetWorkspaceConfigQuery } from '@affine/graphql';
|
||||||
import type { WorkspaceService } from '@toeverything/infra';
|
import type { WorkspaceService } from '@toeverything/infra';
|
||||||
import {
|
import {
|
||||||
backoffRetry,
|
backoffRetry,
|
||||||
@@ -8,21 +8,22 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
fromPromise,
|
fromPromise,
|
||||||
LiveData,
|
LiveData,
|
||||||
mapInto,
|
|
||||||
onComplete,
|
onComplete,
|
||||||
onStart,
|
onStart,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { exhaustMap } from 'rxjs';
|
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||||
|
|
||||||
import { isBackendError, isNetworkError } from '../../cloud';
|
import { isBackendError, isNetworkError } from '../../cloud';
|
||||||
import type { WorkspaceShareSettingStore } from '../stores/share-setting';
|
import type { WorkspaceShareSettingStore } from '../stores/share-setting';
|
||||||
|
|
||||||
|
type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi'];
|
||||||
type EnableUrlPreview =
|
type EnableUrlPreview =
|
||||||
GetEnableUrlPreviewQuery['workspace']['enableUrlPreview'];
|
GetWorkspaceConfigQuery['workspace']['enableUrlPreview'];
|
||||||
|
|
||||||
const logger = new DebugLogger('affine:workspace-permission');
|
const logger = new DebugLogger('affine:workspace-permission');
|
||||||
|
|
||||||
export class WorkspaceShareSetting extends Entity {
|
export class WorkspaceShareSetting extends Entity {
|
||||||
|
enableAi$ = new LiveData<EnableAi | null>(null);
|
||||||
enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null);
|
enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null);
|
||||||
isLoading$ = new LiveData(false);
|
isLoading$ = new LiveData(false);
|
||||||
error$ = new LiveData<any>(null);
|
error$ = new LiveData<any>(null);
|
||||||
@@ -38,7 +39,7 @@ export class WorkspaceShareSetting extends Entity {
|
|||||||
revalidate = effect(
|
revalidate = effect(
|
||||||
exhaustMap(() => {
|
exhaustMap(() => {
|
||||||
return fromPromise(signal =>
|
return fromPromise(signal =>
|
||||||
this.store.fetchWorkspaceEnableUrlPreview(
|
this.store.fetchWorkspaceConfig(
|
||||||
this.workspaceService.workspace.id,
|
this.workspaceService.workspace.id,
|
||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
@@ -51,7 +52,13 @@ export class WorkspaceShareSetting extends Entity {
|
|||||||
when: isBackendError,
|
when: isBackendError,
|
||||||
count: 3,
|
count: 3,
|
||||||
}),
|
}),
|
||||||
mapInto(this.enableUrlPreview$),
|
mergeMap(value => {
|
||||||
|
if (value) {
|
||||||
|
this.enableAi$.next(value.enableAi);
|
||||||
|
this.enableUrlPreview$.next(value.enableUrlPreview);
|
||||||
|
}
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
catchErrorInto(this.error$, error => {
|
catchErrorInto(this.error$, error => {
|
||||||
logger.error('Failed to fetch enableUrlPreview', error);
|
logger.error('Failed to fetch enableUrlPreview', error);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||||
import {
|
import {
|
||||||
getEnableUrlPreviewQuery,
|
getWorkspaceConfigQuery,
|
||||||
|
setEnableAiMutation,
|
||||||
setEnableUrlPreviewMutation,
|
setEnableUrlPreviewMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { Store } from '@toeverything/infra';
|
import { Store } from '@toeverything/infra';
|
||||||
@@ -10,15 +11,12 @@ export class WorkspaceShareSettingStore extends Store {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchWorkspaceEnableUrlPreview(
|
async fetchWorkspaceConfig(workspaceId: string, signal?: AbortSignal) {
|
||||||
workspaceId: string,
|
|
||||||
signal?: AbortSignal
|
|
||||||
) {
|
|
||||||
if (!this.workspaceServerService.server) {
|
if (!this.workspaceServerService.server) {
|
||||||
throw new Error('No Server');
|
throw new Error('No Server');
|
||||||
}
|
}
|
||||||
const data = await this.workspaceServerService.server.gql({
|
const data = await this.workspaceServerService.server.gql({
|
||||||
query: getEnableUrlPreviewQuery,
|
query: getWorkspaceConfigQuery,
|
||||||
variables: {
|
variables: {
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
},
|
},
|
||||||
@@ -26,7 +24,27 @@ export class WorkspaceShareSettingStore extends Store {
|
|||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return data.workspace.enableUrlPreview;
|
return data.workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWorkspaceEnableAi(
|
||||||
|
workspaceId: string,
|
||||||
|
enableAi: boolean,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
if (!this.workspaceServerService.server) {
|
||||||
|
throw new Error('No Server');
|
||||||
|
}
|
||||||
|
await this.workspaceServerService.server.gql({
|
||||||
|
query: setEnableAiMutation,
|
||||||
|
variables: {
|
||||||
|
id: workspaceId,
|
||||||
|
enableAi,
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateWorkspaceEnableUrlPreview(
|
async updateWorkspaceEnableUrlPreview(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
|
|||||||
inviteId
|
inviteId
|
||||||
accepted
|
accepted
|
||||||
emailVerified
|
emailVerified
|
||||||
|
status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ query getWorkspaces {
|
|||||||
workspaces {
|
workspaces {
|
||||||
id
|
id
|
||||||
initialized
|
initialized
|
||||||
|
team
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
|
|||||||
inviteId
|
inviteId
|
||||||
accepted
|
accepted
|
||||||
emailVerified
|
emailVerified
|
||||||
|
status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
@@ -702,6 +703,7 @@ query getWorkspaces {
|
|||||||
workspaces {
|
workspaces {
|
||||||
id
|
id
|
||||||
initialized
|
initialized
|
||||||
|
team
|
||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
@@ -1166,19 +1168,33 @@ mutation verifyEmail($token: String!) {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEnableUrlPreviewQuery = {
|
export const getWorkspaceConfigQuery = {
|
||||||
id: 'getEnableUrlPreviewQuery' as const,
|
id: 'getWorkspaceConfigQuery' as const,
|
||||||
operationName: 'getEnableUrlPreview',
|
operationName: 'getWorkspaceConfig',
|
||||||
definitionName: 'workspace',
|
definitionName: 'workspace',
|
||||||
containsFile: false,
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
query getEnableUrlPreview($id: String!) {
|
query getWorkspaceConfig($id: String!) {
|
||||||
workspace(id: $id) {
|
workspace(id: $id) {
|
||||||
|
enableAi
|
||||||
enableUrlPreview
|
enableUrlPreview
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setEnableAiMutation = {
|
||||||
|
id: 'setEnableAiMutation' as const,
|
||||||
|
operationName: 'setEnableAi',
|
||||||
|
definitionName: 'updateWorkspace',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation setEnableAi($id: ID!, $enableAi: Boolean!) {
|
||||||
|
updateWorkspace(input: {id: $id, enableAi: $enableAi}) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const setEnableUrlPreviewMutation = {
|
export const setEnableUrlPreviewMutation = {
|
||||||
id: 'setEnableUrlPreviewMutation' as const,
|
id: 'setEnableUrlPreviewMutation' as const,
|
||||||
operationName: 'setEnableUrlPreview',
|
operationName: 'setEnableUrlPreview',
|
||||||
@@ -1306,6 +1322,47 @@ mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!, $send
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const inviteBatchMutation = {
|
||||||
|
id: 'inviteBatchMutation' as const,
|
||||||
|
operationName: 'inviteBatch',
|
||||||
|
definitionName: 'inviteBatch',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
|
||||||
|
inviteBatch(
|
||||||
|
workspaceId: $workspaceId
|
||||||
|
emails: $emails
|
||||||
|
sendInviteMail: $sendInviteMail
|
||||||
|
) {
|
||||||
|
email
|
||||||
|
inviteId
|
||||||
|
sentSuccess
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inviteLinkMutation = {
|
||||||
|
id: 'inviteLinkMutation' as const,
|
||||||
|
operationName: 'inviteLink',
|
||||||
|
definitionName: 'inviteLink',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation inviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) {
|
||||||
|
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeInviteLinkMutation = {
|
||||||
|
id: 'revokeInviteLinkMutation' as const,
|
||||||
|
operationName: 'revokeInviteLink',
|
||||||
|
definitionName: 'revokeInviteLink',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation revokeInviteLink($workspaceId: String!) {
|
||||||
|
revokeInviteLink(workspaceId: $workspaceId)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const workspaceQuotaQuery = {
|
export const workspaceQuotaQuery = {
|
||||||
id: 'workspaceQuotaQuery' as const,
|
id: 'workspaceQuotaQuery' as const,
|
||||||
operationName: 'workspaceQuota',
|
operationName: 'workspaceQuota',
|
||||||
@@ -1333,3 +1390,25 @@ query workspaceQuota($id: String!) {
|
|||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const approveWorkspaceTeamMemberMutation = {
|
||||||
|
id: 'approveWorkspaceTeamMemberMutation' as const,
|
||||||
|
operationName: 'approveWorkspaceTeamMember',
|
||||||
|
definitionName: 'approveMember',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation approveWorkspaceTeamMember($workspaceId: String!, $userId: String!) {
|
||||||
|
approveMember(workspaceId: $workspaceId, userId: $userId)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const grantWorkspaceTeamMemberMutation = {
|
||||||
|
id: 'grantWorkspaceTeamMemberMutation' as const,
|
||||||
|
operationName: 'grantWorkspaceTeamMember',
|
||||||
|
definitionName: 'grantMember',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation grantWorkspaceTeamMember($workspaceId: String!, $userId: String!, $permission: Permission!) {
|
||||||
|
grantMember(workspaceId: $workspaceId, userId: $userId, permission: $permission)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
query getWorkspaceConfig($id: String!) {
|
||||||
|
workspace(id: $id) {
|
||||||
|
enableAi
|
||||||
|
enableUrlPreview
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mutation setEnableAi($id: ID!, $enableAi: Boolean!) {
|
||||||
|
updateWorkspace(input: { id: $id, enableAi: $enableAi }) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
query getEnableUrlPreview($id: String!) {
|
|
||||||
workspace(id: $id) {
|
|
||||||
enableUrlPreview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
mutation inviteBatch(
|
||||||
|
$workspaceId: String!
|
||||||
|
$emails: [String!]!
|
||||||
|
$sendInviteMail: Boolean
|
||||||
|
) {
|
||||||
|
inviteBatch(
|
||||||
|
workspaceId: $workspaceId
|
||||||
|
emails: $emails
|
||||||
|
sendInviteMail: $sendInviteMail
|
||||||
|
) {
|
||||||
|
email
|
||||||
|
inviteId
|
||||||
|
sentSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
mutation inviteLink(
|
||||||
|
$workspaceId: String!
|
||||||
|
$expireTime: WorkspaceInviteLinkExpireTime!
|
||||||
|
) {
|
||||||
|
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mutation revokeInviteLink($workspaceId: String!) {
|
||||||
|
revokeInviteLink(workspaceId: $workspaceId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mutation approveWorkspaceTeamMember($workspaceId: String!, $userId: String!) {
|
||||||
|
approveMember(workspaceId: $workspaceId, userId: $userId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
mutation grantWorkspaceTeamMember(
|
||||||
|
$workspaceId: String!
|
||||||
|
$userId: String!
|
||||||
|
$permission: Permission!
|
||||||
|
) {
|
||||||
|
grantMember(
|
||||||
|
workspaceId: $workspaceId
|
||||||
|
userId: $userId
|
||||||
|
permission: $permission
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -438,9 +438,21 @@ export interface InvitationWorkspaceType {
|
|||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteResult {
|
||||||
|
__typename?: 'InviteResult';
|
||||||
|
email: Scalars['String']['output'];
|
||||||
|
/** Invite id, null if invite record create failed */
|
||||||
|
inviteId: Maybe<Scalars['String']['output']>;
|
||||||
|
/** Invite email sent success */
|
||||||
|
sentSuccess: Scalars['Boolean']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface InviteUserType {
|
export interface InviteUserType {
|
||||||
__typename?: 'InviteUserType';
|
__typename?: 'InviteUserType';
|
||||||
/** User accepted */
|
/**
|
||||||
|
* User accepted
|
||||||
|
* @deprecated Use `status` instead
|
||||||
|
*/
|
||||||
accepted: Scalars['Boolean']['output'];
|
accepted: Scalars['Boolean']['output'];
|
||||||
/** User avatar url */
|
/** User avatar url */
|
||||||
avatarUrl: Maybe<Scalars['String']['output']>;
|
avatarUrl: Maybe<Scalars['String']['output']>;
|
||||||
@@ -462,6 +474,8 @@ export interface InviteUserType {
|
|||||||
name: Maybe<Scalars['String']['output']>;
|
name: Maybe<Scalars['String']['output']>;
|
||||||
/** User permission in workspace */
|
/** User permission in workspace */
|
||||||
permission: Permission;
|
permission: Permission;
|
||||||
|
/** Member invite status in workspace */
|
||||||
|
status: WorkspaceMemberStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InvoiceStatus {
|
export enum InvoiceStatus {
|
||||||
@@ -519,6 +533,7 @@ export interface Mutation {
|
|||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
acceptInviteById: Scalars['Boolean']['output'];
|
acceptInviteById: Scalars['Boolean']['output'];
|
||||||
addWorkspaceFeature: Scalars['Int']['output'];
|
addWorkspaceFeature: Scalars['Int']['output'];
|
||||||
|
approveMember: Scalars['String']['output'];
|
||||||
cancelSubscription: SubscriptionType;
|
cancelSubscription: SubscriptionType;
|
||||||
changeEmail: UserType;
|
changeEmail: UserType;
|
||||||
changePassword: Scalars['Boolean']['output'];
|
changePassword: Scalars['Boolean']['output'];
|
||||||
@@ -547,7 +562,10 @@ export interface Mutation {
|
|||||||
deleteWorkspace: Scalars['Boolean']['output'];
|
deleteWorkspace: Scalars['Boolean']['output'];
|
||||||
/** Create a chat session */
|
/** Create a chat session */
|
||||||
forkCopilotSession: Scalars['String']['output'];
|
forkCopilotSession: Scalars['String']['output'];
|
||||||
|
grantMember: Scalars['String']['output'];
|
||||||
invite: Scalars['String']['output'];
|
invite: Scalars['String']['output'];
|
||||||
|
inviteBatch: Array<InviteResult>;
|
||||||
|
inviteLink: Scalars['String']['output'];
|
||||||
leaveWorkspace: Scalars['Boolean']['output'];
|
leaveWorkspace: Scalars['Boolean']['output'];
|
||||||
publishPage: WorkspacePage;
|
publishPage: WorkspacePage;
|
||||||
recoverDoc: Scalars['DateTime']['output'];
|
recoverDoc: Scalars['DateTime']['output'];
|
||||||
@@ -556,6 +574,7 @@ export interface Mutation {
|
|||||||
removeWorkspaceFeature: Scalars['Int']['output'];
|
removeWorkspaceFeature: Scalars['Int']['output'];
|
||||||
resumeSubscription: SubscriptionType;
|
resumeSubscription: SubscriptionType;
|
||||||
revoke: Scalars['Boolean']['output'];
|
revoke: Scalars['Boolean']['output'];
|
||||||
|
revokeInviteLink: Scalars['Boolean']['output'];
|
||||||
/** @deprecated use revokePublicPage */
|
/** @deprecated use revokePublicPage */
|
||||||
revokePage: Scalars['Boolean']['output'];
|
revokePage: Scalars['Boolean']['output'];
|
||||||
revokePublicPage: WorkspacePage;
|
revokePublicPage: WorkspacePage;
|
||||||
@@ -598,6 +617,11 @@ export interface MutationAddWorkspaceFeatureArgs {
|
|||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationApproveMemberArgs {
|
||||||
|
userId: Scalars['String']['input'];
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationCancelSubscriptionArgs {
|
export interface MutationCancelSubscriptionArgs {
|
||||||
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
|
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
|
||||||
plan?: InputMaybe<SubscriptionPlan>;
|
plan?: InputMaybe<SubscriptionPlan>;
|
||||||
@@ -665,6 +689,12 @@ export interface MutationForkCopilotSessionArgs {
|
|||||||
options: ForkChatSessionInput;
|
options: ForkChatSessionInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationGrantMemberArgs {
|
||||||
|
permission: Permission;
|
||||||
|
userId: Scalars['String']['input'];
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationInviteArgs {
|
export interface MutationInviteArgs {
|
||||||
email: Scalars['String']['input'];
|
email: Scalars['String']['input'];
|
||||||
permission: Permission;
|
permission: Permission;
|
||||||
@@ -672,6 +702,17 @@ export interface MutationInviteArgs {
|
|||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationInviteBatchArgs {
|
||||||
|
emails: Array<Scalars['String']['input']>;
|
||||||
|
sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationInviteLinkArgs {
|
||||||
|
expireTime: WorkspaceInviteLinkExpireTime;
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationLeaveWorkspaceArgs {
|
export interface MutationLeaveWorkspaceArgs {
|
||||||
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
@@ -706,6 +747,10 @@ export interface MutationRevokeArgs {
|
|||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MutationRevokeInviteLinkArgs {
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationRevokePageArgs {
|
export interface MutationRevokePageArgs {
|
||||||
pageId: Scalars['String']['input'];
|
pageId: Scalars['String']['input'];
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
@@ -1144,6 +1189,8 @@ export interface UpdateUserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateWorkspaceInput {
|
export interface UpdateWorkspaceInput {
|
||||||
|
/** Enable AI */
|
||||||
|
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
/** Enable url previous when sharing */
|
/** Enable url previous when sharing */
|
||||||
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
|
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
id: Scalars['ID']['input'];
|
id: Scalars['ID']['input'];
|
||||||
@@ -1222,6 +1269,22 @@ export interface WorkspaceBlobSizes {
|
|||||||
size: Scalars['SafeInt']['output'];
|
size: Scalars['SafeInt']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Workspace invite link expire time */
|
||||||
|
export enum WorkspaceInviteLinkExpireTime {
|
||||||
|
OneDay = 'OneDay',
|
||||||
|
OneMonth = 'OneMonth',
|
||||||
|
OneWeek = 'OneWeek',
|
||||||
|
ThreeDays = 'ThreeDays',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Member invite status in workspace */
|
||||||
|
export enum WorkspaceMemberStatus {
|
||||||
|
Accepted = 'Accepted',
|
||||||
|
NeedMoreSeat = 'NeedMoreSeat',
|
||||||
|
Pending = 'Pending',
|
||||||
|
UnderReview = 'UnderReview',
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspacePage {
|
export interface WorkspacePage {
|
||||||
__typename?: 'WorkspacePage';
|
__typename?: 'WorkspacePage';
|
||||||
id: Scalars['String']['output'];
|
id: Scalars['String']['output'];
|
||||||
@@ -1248,6 +1311,8 @@ export interface WorkspaceType {
|
|||||||
blobsSize: Scalars['Int']['output'];
|
blobsSize: Scalars['Int']['output'];
|
||||||
/** Workspace created date */
|
/** Workspace created date */
|
||||||
createdAt: Scalars['DateTime']['output'];
|
createdAt: Scalars['DateTime']['output'];
|
||||||
|
/** Enable AI */
|
||||||
|
enableAi: Scalars['Boolean']['output'];
|
||||||
/** Enable url previous when sharing */
|
/** Enable url previous when sharing */
|
||||||
enableUrlPreview: Scalars['Boolean']['output'];
|
enableUrlPreview: Scalars['Boolean']['output'];
|
||||||
/** Enabled features of workspace */
|
/** Enabled features of workspace */
|
||||||
@@ -1284,6 +1349,8 @@ export interface WorkspaceType {
|
|||||||
sharedPages: Array<Scalars['String']['output']>;
|
sharedPages: Array<Scalars['String']['output']>;
|
||||||
/** The team subscription of the workspace, if exists. */
|
/** The team subscription of the workspace, if exists. */
|
||||||
subscription: Maybe<SubscriptionType>;
|
subscription: Maybe<SubscriptionType>;
|
||||||
|
/** if workspace is team workspace */
|
||||||
|
team: Scalars['Boolean']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceTypeHistoriesArgs {
|
export interface WorkspaceTypeHistoriesArgs {
|
||||||
@@ -1706,6 +1773,7 @@ export type GetMembersByWorkspaceIdQuery = {
|
|||||||
inviteId: string;
|
inviteId: string;
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
emailVerified: boolean | null;
|
emailVerified: boolean | null;
|
||||||
|
status: WorkspaceMemberStatus;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1939,6 +2007,7 @@ export type GetWorkspacesQuery = {
|
|||||||
__typename?: 'WorkspaceType';
|
__typename?: 'WorkspaceType';
|
||||||
id: string;
|
id: string;
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
|
team: boolean;
|
||||||
owner: { __typename?: 'UserType'; id: string };
|
owner: { __typename?: 'UserType'; id: string };
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -2361,13 +2430,27 @@ export type VerifyEmailMutation = {
|
|||||||
verifyEmail: boolean;
|
verifyEmail: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetEnableUrlPreviewQueryVariables = Exact<{
|
export type GetWorkspaceConfigQueryVariables = Exact<{
|
||||||
id: Scalars['String']['input'];
|
id: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type GetEnableUrlPreviewQuery = {
|
export type GetWorkspaceConfigQuery = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
workspace: { __typename?: 'WorkspaceType'; enableUrlPreview: boolean };
|
workspace: {
|
||||||
|
__typename?: 'WorkspaceType';
|
||||||
|
enableAi: boolean;
|
||||||
|
enableUrlPreview: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetEnableAiMutationVariables = Exact<{
|
||||||
|
id: Scalars['ID']['input'];
|
||||||
|
enableAi: Scalars['Boolean']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SetEnableAiMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
updateWorkspace: { __typename?: 'WorkspaceType'; id: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetEnableUrlPreviewMutationVariables = Exact<{
|
export type SetEnableUrlPreviewMutationVariables = Exact<{
|
||||||
@@ -2469,6 +2552,41 @@ export type AcceptInviteByInviteIdMutation = {
|
|||||||
acceptInviteById: boolean;
|
acceptInviteById: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InviteBatchMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
emails: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||||
|
sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type InviteBatchMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
inviteBatch: Array<{
|
||||||
|
__typename?: 'InviteResult';
|
||||||
|
email: string;
|
||||||
|
inviteId: string | null;
|
||||||
|
sentSuccess: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InviteLinkMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
expireTime: WorkspaceInviteLinkExpireTime;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type InviteLinkMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
inviteLink: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RevokeInviteLinkMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RevokeInviteLinkMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
revokeInviteLink: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceQuotaQueryVariables = Exact<{
|
export type WorkspaceQuotaQueryVariables = Exact<{
|
||||||
id: Scalars['String']['input'];
|
id: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
@@ -2498,6 +2616,27 @@ export type WorkspaceQuotaQuery = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApproveWorkspaceTeamMemberMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
userId: Scalars['String']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ApproveWorkspaceTeamMemberMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
approveMember: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GrantWorkspaceTeamMemberMutationVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
userId: Scalars['String']['input'];
|
||||||
|
permission: Permission;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GrantWorkspaceTeamMemberMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
grantMember: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Queries =
|
export type Queries =
|
||||||
| {
|
| {
|
||||||
name: 'adminServerConfigQuery';
|
name: 'adminServerConfigQuery';
|
||||||
@@ -2675,9 +2814,9 @@ export type Queries =
|
|||||||
response: SubscriptionQuery;
|
response: SubscriptionQuery;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: 'getEnableUrlPreviewQuery';
|
name: 'getWorkspaceConfigQuery';
|
||||||
variables: GetEnableUrlPreviewQueryVariables;
|
variables: GetWorkspaceConfigQueryVariables;
|
||||||
response: GetEnableUrlPreviewQuery;
|
response: GetWorkspaceConfigQuery;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: 'enabledFeaturesQuery';
|
name: 'enabledFeaturesQuery';
|
||||||
@@ -2891,6 +3030,11 @@ export type Mutations =
|
|||||||
variables: VerifyEmailMutationVariables;
|
variables: VerifyEmailMutationVariables;
|
||||||
response: VerifyEmailMutation;
|
response: VerifyEmailMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'setEnableAiMutation';
|
||||||
|
variables: SetEnableAiMutationVariables;
|
||||||
|
response: SetEnableAiMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'setEnableUrlPreviewMutation';
|
name: 'setEnableUrlPreviewMutation';
|
||||||
variables: SetEnableUrlPreviewMutationVariables;
|
variables: SetEnableUrlPreviewMutationVariables;
|
||||||
@@ -2920,4 +3064,29 @@ export type Mutations =
|
|||||||
name: 'acceptInviteByInviteIdMutation';
|
name: 'acceptInviteByInviteIdMutation';
|
||||||
variables: AcceptInviteByInviteIdMutationVariables;
|
variables: AcceptInviteByInviteIdMutationVariables;
|
||||||
response: AcceptInviteByInviteIdMutation;
|
response: AcceptInviteByInviteIdMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'inviteBatchMutation';
|
||||||
|
variables: InviteBatchMutationVariables;
|
||||||
|
response: InviteBatchMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'inviteLinkMutation';
|
||||||
|
variables: InviteLinkMutationVariables;
|
||||||
|
response: InviteLinkMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'revokeInviteLinkMutation';
|
||||||
|
variables: RevokeInviteLinkMutationVariables;
|
||||||
|
response: RevokeInviteLinkMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'approveWorkspaceTeamMemberMutation';
|
||||||
|
variables: ApproveWorkspaceTeamMemberMutationVariables;
|
||||||
|
response: ApproveWorkspaceTeamMemberMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'grantWorkspaceTeamMemberMutation';
|
||||||
|
variables: GrantWorkspaceTeamMemberMutationVariables;
|
||||||
|
response: GrantWorkspaceTeamMemberMutation;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export async function addUserToWorkspace(
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId,
|
userId,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
status: 'Accepted',
|
||||||
type: permission,
|
type: permission,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user