mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-30 08:42:22 +03:00
feat(server): team quota (#8955)
This commit is contained in:
parent
8fe188e773
commit
9365958a02
@ -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 {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
|
||||
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[]
|
||||
permissions WorkspaceUserPermission[]
|
||||
@ -126,21 +128,33 @@ model WorkspacePage {
|
||||
@@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 {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
// Read/Write
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
type Int @db.SmallInt
|
||||
/// @deprecated Whether the permission invitation is accepted by the user
|
||||
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)
|
||||
/// 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)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId, userId])
|
||||
// optimize for quering user's workspace permissions
|
||||
// optimize for querying user's workspace permissions
|
||||
@@index(userId)
|
||||
@@map("workspace_user_permissions")
|
||||
}
|
||||
@ -200,6 +214,8 @@ model WorkspaceFeature {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
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
|
||||
// for example:
|
||||
// - copilet_v1: "owner buy the copilet feature package"
|
||||
@ -216,6 +232,7 @@ model WorkspaceFeature {
|
||||
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@map("workspace_features")
|
||||
}
|
||||
|
||||
@ -225,7 +242,7 @@ model Feature {
|
||||
version Int @default(0) @db.Integer
|
||||
// 0: feature, 1: quota
|
||||
type Int @db.Integer
|
||||
// configs, define by feature conntroller
|
||||
// configs, define by feature controller
|
||||
configs Json @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
|
@ -26,9 +26,11 @@ import { FeatureService } from './service';
|
||||
})
|
||||
export class FeatureModule {}
|
||||
|
||||
export type { FeatureConfigType } from './feature';
|
||||
export {
|
||||
type CommonFeature,
|
||||
commonFeatureSchema,
|
||||
type FeatureConfig,
|
||||
FeatureKind,
|
||||
Features,
|
||||
FeatureType,
|
||||
|
@ -69,7 +69,7 @@ export class FeatureManagementService {
|
||||
}
|
||||
|
||||
async listEarlyAccess(type: EarlyAccessType = EarlyAccessType.App) {
|
||||
return this.feature.listFeatureUsers(
|
||||
return this.feature.listUsersByFeature(
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess
|
||||
@ -132,7 +132,7 @@ export class FeatureManagementService {
|
||||
|
||||
// ======== User Feature ========
|
||||
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);
|
||||
}
|
||||
|
||||
@ -165,7 +165,7 @@ export class FeatureManagementService {
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType) {
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
return this.feature.listWorkspacesByFeature(feature);
|
||||
}
|
||||
|
||||
@OnEvent('user.admin.created')
|
||||
|
@ -12,14 +12,9 @@ export class FeatureService {
|
||||
|
||||
async getFeature<F extends FeatureType>(feature: F) {
|
||||
const data = await this.prisma.feature.findFirst({
|
||||
where: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
select: { id: true },
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
|
||||
if (data) {
|
||||
@ -146,7 +141,7 @@ export class FeatureService {
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async getActivatedUserFeatures(userId: string) {
|
||||
async getUserActivatedFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@ -173,7 +168,7 @@ export class FeatureService {
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType) {
|
||||
async listUsersByFeature(feature: FeatureType) {
|
||||
return this.prisma.userFeature
|
||||
.findMany({
|
||||
where: {
|
||||
@ -318,7 +313,9 @@ export class FeatureService {
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
||||
async listWorkspacesByFeature(
|
||||
feature: FeatureType
|
||||
): Promise<WorkspaceType[]> {
|
||||
return this.prisma.workspaceFeature
|
||||
.findMany({
|
||||
where: {
|
||||
|
@ -76,20 +76,24 @@ export const Features: Feature[] = [
|
||||
|
||||
/// ======== schema infer ========
|
||||
|
||||
export const FeatureConfigSchema = z.discriminatedUnion('feature', [
|
||||
featureCopilot,
|
||||
featureEarlyAccess,
|
||||
featureAIEarlyAccess,
|
||||
featureUnlimitedWorkspace,
|
||||
featureUnlimitedCopilot,
|
||||
featureAdministrator,
|
||||
]);
|
||||
|
||||
export const FeatureSchema = commonFeatureSchema
|
||||
.extend({
|
||||
type: z.literal(FeatureKind.Feature),
|
||||
})
|
||||
.and(
|
||||
z.discriminatedUnion('feature', [
|
||||
featureCopilot,
|
||||
featureEarlyAccess,
|
||||
featureAIEarlyAccess,
|
||||
featureUnlimitedWorkspace,
|
||||
featureUnlimitedCopilot,
|
||||
featureAdministrator,
|
||||
])
|
||||
);
|
||||
.and(FeatureConfigSchema);
|
||||
|
||||
export type FeatureConfig<F extends FeatureType> = (z.infer<
|
||||
typeof FeatureConfigSchema
|
||||
> & { feature: F })['configs'];
|
||||
|
||||
export type Feature = z.infer<typeof FeatureSchema>;
|
||||
|
||||
|
@ -1,17 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
DocAccessDenied,
|
||||
EventEmitter,
|
||||
PrismaTransaction,
|
||||
SpaceAccessDenied,
|
||||
SpaceOwnerNotFound,
|
||||
} from '../../fundamentals';
|
||||
import { FeatureKind } from '../features/types';
|
||||
import { QuotaType } from '../quota/types';
|
||||
import { Permission, PublicPageMode } from './types';
|
||||
|
||||
@Injectable()
|
||||
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
|
||||
async get(ws: string, user: string) {
|
||||
@ -19,7 +38,7 @@ export class PermissionService {
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
OR: this.acceptedCondition,
|
||||
},
|
||||
});
|
||||
|
||||
@ -36,7 +55,7 @@ export class PermissionService {
|
||||
.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
accepted: true,
|
||||
OR: this.acceptedCondition,
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
@ -47,8 +66,8 @@ export class PermissionService {
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
type: Permission.Owner,
|
||||
OR: this.acceptedCondition,
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
@ -120,19 +139,31 @@ export class PermissionService {
|
||||
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.
|
||||
*/
|
||||
async isWorkspaceMember(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission
|
||||
permission: Permission = Permission.Read
|
||||
): Promise<boolean> {
|
||||
const count = await this.prisma.workspaceUserPermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
OR: this.acceptedCondition,
|
||||
type: {
|
||||
gte: permission,
|
||||
},
|
||||
@ -193,7 +224,7 @@ export class PermissionService {
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
OR: this.acceptedCondition,
|
||||
type: {
|
||||
gte: permission,
|
||||
},
|
||||
@ -208,6 +239,33 @@ export class PermissionService {
|
||||
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) {
|
||||
const count = await this.prisma.workspace.count({
|
||||
where: {
|
||||
@ -222,13 +280,14 @@ export class PermissionService {
|
||||
async grant(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
permission: Permission = Permission.Read,
|
||||
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
|
||||
): Promise<string> {
|
||||
const data = await this.prisma.workspaceUserPermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
OR: this.acceptedCondition,
|
||||
},
|
||||
});
|
||||
|
||||
@ -274,6 +333,7 @@ export class PermissionService {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: permission,
|
||||
status,
|
||||
},
|
||||
})
|
||||
.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({
|
||||
where: {
|
||||
id: invitationId,
|
||||
workspaceId: workspaceId,
|
||||
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
status: status,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
async revokeWorkspace(ws: string, user: string) {
|
||||
const result = await this.prisma.workspaceUserPermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: {
|
||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
||||
not: Permission.Owner,
|
||||
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const members = await tx.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
return result.count > 0;
|
||||
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 },
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
|
@ -22,4 +22,10 @@ export class QuotaModule {}
|
||||
|
||||
export { QuotaManagementService, QuotaService };
|
||||
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 {
|
||||
readonly config: Quota;
|
||||
readonly override?: Quota['configs'];
|
||||
|
||||
static async get(tx: PrismaTransaction, featureId: number) {
|
||||
const cachedQuota = QuotaCache.get(featureId);
|
||||
@ -31,7 +32,7 @@ export class QuotaConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
private constructor(data: any) {
|
||||
private constructor(data: any, override?: any) {
|
||||
const config = QuotaSchema.safeParse(data);
|
||||
if (config.success) {
|
||||
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() {
|
||||
@ -54,29 +87,43 @@ export class QuotaConfig {
|
||||
}
|
||||
|
||||
get blobLimit() {
|
||||
return this.config.configs.blobLimit;
|
||||
return this.override?.blobLimit || this.config.configs.blobLimit;
|
||||
}
|
||||
|
||||
get businessBlobLimit() {
|
||||
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() {
|
||||
return this.config.configs.storageQuota;
|
||||
const baseQuota =
|
||||
this.override?.storageQuota || this.config.configs.storageQuota;
|
||||
return baseQuota + this.additionalQuota;
|
||||
}
|
||||
|
||||
get historyPeriod() {
|
||||
return this.config.configs.historyPeriod;
|
||||
return this.override?.historyPeriod || this.config.configs.historyPeriod;
|
||||
}
|
||||
|
||||
get memberLimit() {
|
||||
return this.config.configs.memberLimit;
|
||||
return this.override?.memberLimit || this.config.configs.memberLimit;
|
||||
}
|
||||
|
||||
get copilotActionLimit() {
|
||||
return this.config.configs.copilotActionLimit || undefined;
|
||||
if ('copilotActionLimit' in this.config.configs) {
|
||||
return this.config.configs.copilotActionLimit || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get humanReadable() {
|
||||
|
@ -143,9 +143,9 @@ export const Quotas: Quota[] = [
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Restricted',
|
||||
// single blob limit 10MB
|
||||
// single blob limit 1MB
|
||||
blobLimit: OneMB,
|
||||
// total blob limit 1GB
|
||||
// total blob limit 10MB
|
||||
storageQuota: 10 * OneMB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
@ -174,12 +174,31 @@ export const Quotas: Quota[] = [
|
||||
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);
|
||||
quota.sort((a, b) => b.version - a.version);
|
||||
return quota[0];
|
||||
return quota[0] as Quota<Q>;
|
||||
}
|
||||
|
||||
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
||||
|
@ -15,14 +15,32 @@ export class QuotaService {
|
||||
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
|
||||
async getUserQuota(userId: string) {
|
||||
const quota = await this.prisma.userFeature.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
},
|
||||
feature: { type: FeatureKind.Quota },
|
||||
activated: true,
|
||||
},
|
||||
select: {
|
||||
@ -47,9 +65,7 @@ export class QuotaService {
|
||||
const quotas = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
},
|
||||
feature: { type: FeatureKind.Quota },
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
@ -58,9 +74,7 @@ export class QuotaService {
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const configs = await Promise.all(
|
||||
quotas.map(async quota => {
|
||||
@ -88,12 +102,8 @@ export class QuotaService {
|
||||
expiredAt?: Date
|
||||
) {
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const hasSameActivatedQuota = await this.hasQuota(userId, quota, tx);
|
||||
|
||||
if (hasSameActivatedQuota) {
|
||||
// don't need to switch
|
||||
return;
|
||||
}
|
||||
const hasSameActivatedQuota = await this.hasUserQuota(userId, quota, tx);
|
||||
if (hasSameActivatedQuota) return; // don't need to switch
|
||||
|
||||
const featureId = await tx.feature
|
||||
.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;
|
||||
|
||||
return executor.userFeature
|
||||
@ -150,6 +160,161 @@ export class QuotaService {
|
||||
.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')
|
||||
async onSubscriptionUpdated({
|
||||
userId,
|
||||
|
@ -1,16 +1,13 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { MemberQuotaExceeded } from '../../fundamentals';
|
||||
import { FeatureService, FeatureType } from '../features';
|
||||
import { PermissionService } from '../permission';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { OneGB } from './constant';
|
||||
import { QuotaConfig } from './quota';
|
||||
import { QuotaService } from './service';
|
||||
import { formatSize, QuotaQueryType } from './types';
|
||||
|
||||
type QuotaBusinessType = QuotaQueryType & {
|
||||
businessBlobLimit: number;
|
||||
unlimited: boolean;
|
||||
};
|
||||
import { formatSize, Quota, type QuotaBusinessType, QuotaType } from './types';
|
||||
|
||||
@Injectable()
|
||||
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) {
|
||||
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
|
||||
// quota was apply to owner's account
|
||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
||||
@ -116,17 +173,15 @@ export class QuotaManagementService {
|
||||
const memberCount =
|
||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
||||
const {
|
||||
feature: {
|
||||
name,
|
||||
blobLimit,
|
||||
businessBlobLimit,
|
||||
historyPeriod,
|
||||
memberLimit,
|
||||
storageQuota,
|
||||
copilotActionLimit,
|
||||
humanReadable,
|
||||
},
|
||||
} = await this.quota.getUserQuota(owner.id);
|
||||
name,
|
||||
blobLimit,
|
||||
businessBlobLimit,
|
||||
historyPeriod,
|
||||
memberLimit,
|
||||
storageQuota,
|
||||
copilotActionLimit,
|
||||
humanReadable,
|
||||
} = await this.getWorkspaceQuota(owner.id, workspaceId);
|
||||
// get all workspaces size of owner used
|
||||
const usedSize = await this.getUserUsage(owner.id);
|
||||
// relax restrictions if workspace has unlimited feature
|
||||
@ -157,7 +212,7 @@ export class QuotaManagementService {
|
||||
return quota;
|
||||
}
|
||||
|
||||
private mergeUnlimitedQuota(orig: QuotaBusinessType) {
|
||||
private mergeUnlimitedQuota(orig: QuotaBusinessType): QuotaBusinessType {
|
||||
return {
|
||||
...orig,
|
||||
storageQuota: 1000 * OneGB,
|
||||
|
@ -17,27 +17,39 @@ import { ByteUnit, OneDay, OneKB } from './constant';
|
||||
export enum QuotaType {
|
||||
FreePlanV1 = 'free_plan_v1',
|
||||
ProPlanV1 = 'pro_plan_v1',
|
||||
TeamPlanV1 = 'team_plan_v1',
|
||||
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
|
||||
// only for test, smaller quota
|
||||
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([
|
||||
QuotaType.FreePlanV1,
|
||||
QuotaType.ProPlanV1,
|
||||
QuotaType.LifetimeProPlanV1,
|
||||
QuotaType.RestrictedPlanV1,
|
||||
]),
|
||||
configs: z.object({
|
||||
name: z.string(),
|
||||
blobLimit: z.number().positive().int(),
|
||||
storageQuota: z.number().positive().int(),
|
||||
historyPeriod: z.number().positive().int(),
|
||||
memberLimit: z.number().positive().int(),
|
||||
businessBlobLimit: z.number().positive().int().nullish(),
|
||||
copilotActionLimit: z.number().positive().int().nullish(),
|
||||
}),
|
||||
configs: userQuota,
|
||||
});
|
||||
|
||||
const workspaceQuotaPlan = z.object({
|
||||
feature: z.enum([QuotaType.TeamPlanV1]),
|
||||
configs: basicQuota,
|
||||
});
|
||||
|
||||
/// ======== schema infer ========
|
||||
@ -46,9 +58,12 @@ export const QuotaSchema = commonFeatureSchema
|
||||
.extend({
|
||||
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 ========
|
||||
|
||||
@ -120,3 +135,8 @@ export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
export type QuotaBusinessType = QuotaQueryType & {
|
||||
businessBlobLimit: number;
|
||||
unlimited: boolean;
|
||||
};
|
||||
|
@ -12,6 +12,7 @@ import { WorkspaceManagementResolver } from './management';
|
||||
import {
|
||||
DocHistoryResolver,
|
||||
PagePermissionResolver,
|
||||
TeamWorkspaceResolver,
|
||||
WorkspaceBlobResolver,
|
||||
WorkspaceResolver,
|
||||
} from './resolvers';
|
||||
@ -29,6 +30,7 @@ import {
|
||||
controllers: [WorkspacesController],
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
TeamWorkspaceResolver,
|
||||
WorkspaceManagementResolver,
|
||||
PagePermissionResolver,
|
||||
DocHistoryResolver,
|
||||
|
@ -166,7 +166,11 @@ export class WorkspaceBlobResolver {
|
||||
@Args('workspaceId') workspaceId: 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);
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from './blob';
|
||||
export * from './history';
|
||||
export * from './page';
|
||||
export * from './team';
|
||||
export * from './workspace';
|
||||
|
@ -140,7 +140,7 @@ export class PagePermissionResolver {
|
||||
await this.permission.checkWorkspace(
|
||||
docId.workspace,
|
||||
user.id,
|
||||
Permission.Read
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
return this.permission.publishPage(docId.workspace, docId.guid, mode);
|
||||
@ -177,7 +177,7 @@ export class PagePermissionResolver {
|
||||
await this.permission.checkWorkspace(
|
||||
docId.workspace,
|
||||
user.id,
|
||||
Permission.Read
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
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,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import type { FileUpload } from '../../../fundamentals';
|
||||
import {
|
||||
Cache,
|
||||
CantChangeSpaceOwner,
|
||||
DocNotFound,
|
||||
EventEmitter,
|
||||
InternalServerError,
|
||||
MailService,
|
||||
MemberQuotaExceeded,
|
||||
RequestMutex,
|
||||
SpaceAccessDenied,
|
||||
SpaceNotFound,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
UserNotFound,
|
||||
} from '../../../fundamentals';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
@ -78,6 +79,7 @@ export class WorkspaceResolver {
|
||||
private readonly logger = new Logger(WorkspaceResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly cache: Cache,
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
@ -153,31 +155,21 @@ export class WorkspaceResolver {
|
||||
@Args('take', { type: () => Int, nullable: true }) take?: number
|
||||
) {
|
||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
where: { workspaceId: workspace.id },
|
||||
skip,
|
||||
take: take || 8,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: 'asc',
|
||||
},
|
||||
{
|
||||
type: 'desc',
|
||||
},
|
||||
],
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
return data
|
||||
.filter(({ user }) => !!user)
|
||||
.map(({ id, accepted, type, user }) => ({
|
||||
.map(({ id, accepted, status, type, user }) => ({
|
||||
...user,
|
||||
permission: type,
|
||||
inviteId: id,
|
||||
accepted,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -240,7 +232,14 @@ export class WorkspaceResolver {
|
||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
OR: [
|
||||
{
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
workspace: true,
|
||||
@ -287,6 +286,7 @@ export class WorkspaceResolver {
|
||||
type: Permission.Owner,
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -331,7 +331,12 @@ export class WorkspaceResolver {
|
||||
@Args({ name: 'input', type: () => 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({
|
||||
where: {
|
||||
@ -379,7 +384,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
try {
|
||||
// lock to prevent concurrent invite
|
||||
// lock to prevent concurrent invite and grant
|
||||
const lockFlag = `invite:${workspaceId}`;
|
||||
await using lock = await this.mutex.lock(lockFlag);
|
||||
if (!lock) {
|
||||
@ -387,10 +392,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
// member limit check
|
||||
const quota = await this.quota.getWorkspaceUsage(workspaceId);
|
||||
if (quota.memberCount >= quota.memberLimit) {
|
||||
return new MemberQuotaExceeded();
|
||||
}
|
||||
await this.quota.checkWorkspaceSeat(workspaceId);
|
||||
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
if (target) {
|
||||
@ -452,6 +454,10 @@ export class WorkspaceResolver {
|
||||
}
|
||||
return inviteId;
|
||||
} catch (e) {
|
||||
// pass through user friendly error
|
||||
if (e instanceof UserFriendlyError) {
|
||||
return e;
|
||||
}
|
||||
this.logger.error('failed to invite user', e);
|
||||
return new TooManyRequest();
|
||||
}
|
||||
@ -463,16 +469,26 @@ export class WorkspaceResolver {
|
||||
description: 'send workspace invitation',
|
||||
})
|
||||
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
||||
const workspaceId = await this.prisma.workspaceUserPermission
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
})
|
||||
.then(({ workspaceId }) => workspaceId);
|
||||
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({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
})
|
||||
.then(({ workspaceId }) => workspaceId);
|
||||
}
|
||||
|
||||
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
|
||||
|
||||
@ -511,22 +527,81 @@ export class WorkspaceResolver {
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(
|
||||
const isTeam = await this.quota.isTeamWorkspace(workspaceId);
|
||||
const isAdmin = await this.permissions.tryCheckWorkspaceIs(
|
||||
workspaceId,
|
||||
user.id,
|
||||
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(
|
||||
workspaceId,
|
||||
user.id,
|
||||
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)
|
||||
@Public()
|
||||
async acceptInviteById(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('inviteId') inviteId: string,
|
||||
@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 {
|
||||
invitee,
|
||||
user: inviter,
|
||||
@ -538,6 +613,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
if (sendAcceptMail) {
|
||||
// TODO(@darkskygit): team accept mail
|
||||
await this.mailer.sendAcceptedEmail(inviter.email, {
|
||||
inviteeName: invitee.name,
|
||||
workspaceName: workspace.name,
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
PickType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import type { Workspace } from '@prisma/client';
|
||||
import { Workspace, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { Permission } from '../permission';
|
||||
@ -19,6 +19,11 @@ registerEnumType(Permission, {
|
||||
description: 'User permission in workspace',
|
||||
});
|
||||
|
||||
registerEnumType(WorkspaceMemberStatus, {
|
||||
name: 'WorkspaceMemberStatus',
|
||||
description: 'Member invite status in workspace',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class InviteUserType extends OmitType(
|
||||
PartialType(UserType),
|
||||
@ -34,8 +39,16 @@ export class InviteUserType extends OmitType(
|
||||
@Field({ description: 'Invite id' })
|
||||
inviteId!: string;
|
||||
|
||||
@Field({ description: 'User accepted' })
|
||||
@Field({
|
||||
description: 'User accepted',
|
||||
deprecationReason: 'Use `status` instead',
|
||||
})
|
||||
accepted!: boolean;
|
||||
|
||||
@Field(() => WorkspaceMemberStatus, {
|
||||
description: 'Member invite status in workspace',
|
||||
})
|
||||
status!: WorkspaceMemberStatus;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@ -46,6 +59,9 @@ export class WorkspaceType implements Partial<Workspace> {
|
||||
@Field({ description: 'is Public workspace' })
|
||||
public!: boolean;
|
||||
|
||||
@Field({ description: 'Enable AI' })
|
||||
enableAi!: boolean;
|
||||
|
||||
@Field({ description: 'Enable url previous when sharing' })
|
||||
enableUrlPreview!: boolean;
|
||||
|
||||
@ -92,9 +108,38 @@ export class InvitationType {
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput extends PickType(
|
||||
PartialType(WorkspaceType),
|
||||
['public', 'enableUrlPreview'],
|
||||
['public', 'enableAi', 'enableUrlPreview'],
|
||||
InputType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
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 { FeatureModule } from '../../core/features';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
import { UserModule } from '../../core/user';
|
||||
import { Plugin } from '../registry';
|
||||
import { StripeWebhookController } from './controller';
|
||||
@ -11,6 +12,7 @@ import {
|
||||
UserSubscriptionManager,
|
||||
WorkspaceSubscriptionManager,
|
||||
} from './manager';
|
||||
import { TeamQuotaOverride } from './quota';
|
||||
import {
|
||||
SubscriptionResolver,
|
||||
UserSubscriptionResolver,
|
||||
@ -22,7 +24,7 @@ import { StripeWebhook } from './webhook';
|
||||
|
||||
@Plugin({
|
||||
name: 'payment',
|
||||
imports: [FeatureModule, UserModule, PermissionModule],
|
||||
imports: [FeatureModule, QuotaModule, UserModule, PermissionModule],
|
||||
providers: [
|
||||
StripeProvider,
|
||||
SubscriptionService,
|
||||
@ -33,6 +35,7 @@ import { StripeWebhook } from './webhook';
|
||||
WorkspaceSubscriptionManager,
|
||||
SubscriptionCronJobs,
|
||||
WorkspaceSubscriptionResolver,
|
||||
TeamQuotaOverride,
|
||||
],
|
||||
controllers: [StripeWebhookController],
|
||||
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!
|
||||
}
|
||||
|
||||
type InviteResult {
|
||||
email: String!
|
||||
|
||||
"""Invite id, null if invite record create failed"""
|
||||
inviteId: String
|
||||
|
||||
"""Invite email sent success"""
|
||||
sentSuccess: Boolean!
|
||||
}
|
||||
|
||||
type InviteUserType {
|
||||
"""User accepted"""
|
||||
accepted: Boolean!
|
||||
accepted: Boolean! @deprecated(reason: "Use `status` instead")
|
||||
|
||||
"""User avatar url"""
|
||||
avatarUrl: String
|
||||
@ -389,6 +399,9 @@ type InviteUserType {
|
||||
|
||||
"""User permission in workspace"""
|
||||
permission: Permission!
|
||||
|
||||
"""Member invite status in workspace"""
|
||||
status: WorkspaceMemberStatus!
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
@ -451,6 +464,7 @@ type MissingOauthQueryParameterDataType {
|
||||
type Mutation {
|
||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
||||
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!
|
||||
changeEmail(email: String!, token: String!): UserType!
|
||||
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
|
||||
@ -490,7 +504,10 @@ type Mutation {
|
||||
|
||||
"""Create a chat session"""
|
||||
forkCopilotSession(options: ForkChatSessionInput!): String!
|
||||
grantMember(permission: Permission!, userId: String!, 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!
|
||||
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
|
||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||
@ -500,6 +517,7 @@ type Mutation {
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
|
||||
revoke(userId: String!, workspaceId: String!): Boolean!
|
||||
revokeInviteLink(workspaceId: String!): Boolean!
|
||||
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
||||
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
|
||||
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
||||
@ -831,6 +849,9 @@ input UpdateUserInput {
|
||||
}
|
||||
|
||||
input UpdateWorkspaceInput {
|
||||
"""Enable AI"""
|
||||
enableAi: Boolean
|
||||
|
||||
"""Enable url previous when sharing"""
|
||||
enableUrlPreview: Boolean
|
||||
id: ID!
|
||||
@ -902,6 +923,22 @@ type WorkspaceBlobSizes {
|
||||
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 {
|
||||
id: String!
|
||||
mode: PublicPageMode!
|
||||
@ -929,6 +966,9 @@ type WorkspaceType {
|
||||
"""Workspace created date"""
|
||||
createdAt: DateTime!
|
||||
|
||||
"""Enable AI"""
|
||||
enableAi: Boolean!
|
||||
|
||||
"""Enable url previous when sharing"""
|
||||
enableUrlPreview: Boolean!
|
||||
|
||||
@ -976,6 +1016,9 @@ type WorkspaceType {
|
||||
|
||||
"""The team subscription of the workspace, if exists."""
|
||||
subscription: SubscriptionType
|
||||
|
||||
"""if workspace is team workspace"""
|
||||
team: Boolean!
|
||||
}
|
||||
|
||||
type tokenType {
|
||||
|
@ -1,7 +1,6 @@
|
||||
/// <reference types="../src/global.d.ts" />
|
||||
|
||||
import { INestApplication, Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
@ -12,32 +11,10 @@ import {
|
||||
FeatureService,
|
||||
FeatureType,
|
||||
} 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 { Config, ConfigModule } from '../src/fundamentals/config';
|
||||
import { createTestingApp } from './utils';
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
import { WorkspaceResolverMock } from './utils/feature';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
@ -105,7 +82,7 @@ test('should be able to check early access', async t => {
|
||||
const f2 = await management.canEarlyAccess(u1.email);
|
||||
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[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);
|
||||
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[0].id, w1.id, 'should be the same workspace');
|
||||
});
|
||||
|
@ -11,29 +11,41 @@ import {
|
||||
QuotaService,
|
||||
QuotaType,
|
||||
} from '../src/core/quota';
|
||||
import { OneGB, OneMB } from '../src/core/quota/constant';
|
||||
import { FreePlan, ProPlan } from '../src/core/quota/schema';
|
||||
import { StorageModule } from '../src/core/storage';
|
||||
import { WorkspaceResolver } from '../src/core/workspaces/resolvers';
|
||||
import { createTestingModule } from './utils';
|
||||
import { WorkspaceResolverMock } from './utils/feature';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
quota: QuotaService;
|
||||
quotaManager: QuotaManagementService;
|
||||
workspace: WorkspaceResolver;
|
||||
module: TestingModule;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await createTestingModule({
|
||||
imports: [StorageModule, QuotaModule],
|
||||
providers: [WorkspaceResolver],
|
||||
tapModule: module => {
|
||||
module
|
||||
.overrideProvider(WorkspaceResolver)
|
||||
.useClass(WorkspaceResolverMock);
|
||||
},
|
||||
});
|
||||
|
||||
const quota = module.get(QuotaService);
|
||||
const quotaManager = module.get(QuotaManagementService);
|
||||
const workspace = module.get(WorkspaceResolver);
|
||||
const auth = module.get(AuthService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.quota = quota;
|
||||
t.context.quotaManager = quotaManager;
|
||||
t.context.workspace = workspace;
|
||||
t.context.auth = auth;
|
||||
});
|
||||
|
||||
@ -128,3 +140,28 @@ test('should be able to check quota', async t => {
|
||||
'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 { gql } from './common';
|
||||
import { PermissionEnum } from './utils';
|
||||
|
||||
export async function inviteUser(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
permission: string,
|
||||
permission: PermissionEnum,
|
||||
sendInviteMail = false
|
||||
): Promise<string> {
|
||||
const res = await request(app.getHttpServer())
|
||||
@ -24,18 +25,81 @@ export async function inviteUser(
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
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(
|
||||
app: INestApplication,
|
||||
workspaceId: string,
|
||||
inviteId: string,
|
||||
sendAcceptMail = false
|
||||
sendAcceptMail = false,
|
||||
token: string = ''
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
@ -44,6 +108,9 @@ export async function acceptInviteById(
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.acceptInviteById;
|
||||
}
|
||||
|
||||
@ -65,6 +132,9 @@ export async function leaveWorkspace(
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
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 { gql } from './common';
|
||||
|
||||
export type UserAuthedType = UserType & { token: ClientTokenType };
|
||||
|
||||
export async function internalSignIn(app: INestApplication, userId: string) {
|
||||
const auth = app.get(AuthService);
|
||||
|
||||
@ -49,7 +51,7 @@ export async function signUp(
|
||||
email: string,
|
||||
password: string,
|
||||
autoVerifyEmail = true
|
||||
): Promise<UserType & { token: ClientTokenType }> {
|
||||
): Promise<UserAuthedType> {
|
||||
const user = await app.get(UserService).createUser({
|
||||
name,
|
||||
email,
|
||||
@ -176,7 +178,7 @@ export async function changeEmail(
|
||||
userToken: string,
|
||||
token: string,
|
||||
email: string
|
||||
): Promise<UserType & { token: ClientTokenType }> {
|
||||
): Promise<UserAuthedType> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(userToken, { type: 'bearer' })
|
||||
|
@ -14,6 +14,8 @@ import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652
|
||||
import { Config, GlobalExceptionFilter } from '../../src/fundamentals';
|
||||
import { GqlModule } from '../../src/fundamentals/graphql';
|
||||
|
||||
export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read';
|
||||
|
||||
async function flushDB(client: PrismaClient) {
|
||||
const result: { tablename: string }[] =
|
||||
await client.$queryRaw`SELECT tablename
|
||||
|
@ -3,6 +3,7 @@ import request from 'supertest';
|
||||
|
||||
import type { WorkspaceType } from '../../src/core/workspaces';
|
||||
import { gql } from './common';
|
||||
import { PermissionEnum } from './utils';
|
||||
|
||||
export async function createWorkspace(
|
||||
app: INestApplication,
|
||||
@ -150,3 +151,32 @@ export async function revokePublicPage(
|
||||
.expect(200);
|
||||
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 { HttpStatus, INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
@ -182,6 +182,7 @@ test('should be able to get permission granted workspace', async t => {
|
||||
userId: u1.id,
|
||||
type: 1,
|
||||
accepted: true,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { GetEnableUrlPreviewQuery } from '@affine/graphql';
|
||||
import type { GetWorkspaceConfigQuery } from '@affine/graphql';
|
||||
import type { WorkspaceService } from '@toeverything/infra';
|
||||
import {
|
||||
backoffRetry,
|
||||
@ -8,21 +8,22 @@ import {
|
||||
Entity,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
mapInto,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { exhaustMap } from 'rxjs';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspaceShareSettingStore } from '../stores/share-setting';
|
||||
|
||||
type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi'];
|
||||
type EnableUrlPreview =
|
||||
GetEnableUrlPreviewQuery['workspace']['enableUrlPreview'];
|
||||
GetWorkspaceConfigQuery['workspace']['enableUrlPreview'];
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-permission');
|
||||
|
||||
export class WorkspaceShareSetting extends Entity {
|
||||
enableAi$ = new LiveData<EnableAi | null>(null);
|
||||
enableUrlPreview$ = new LiveData<EnableUrlPreview | null>(null);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
@ -38,7 +39,7 @@ export class WorkspaceShareSetting extends Entity {
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(signal =>
|
||||
this.store.fetchWorkspaceEnableUrlPreview(
|
||||
this.store.fetchWorkspaceConfig(
|
||||
this.workspaceService.workspace.id,
|
||||
signal
|
||||
)
|
||||
@ -51,7 +52,13 @@ export class WorkspaceShareSetting extends Entity {
|
||||
when: isBackendError,
|
||||
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 => {
|
||||
logger.error('Failed to fetch enableUrlPreview', error);
|
||||
}),
|
||||
|
@ -1,6 +1,7 @@
|
||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
getEnableUrlPreviewQuery,
|
||||
getWorkspaceConfigQuery,
|
||||
setEnableAiMutation,
|
||||
setEnableUrlPreviewMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
@ -10,15 +11,12 @@ export class WorkspaceShareSettingStore extends Store {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchWorkspaceEnableUrlPreview(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
async fetchWorkspaceConfig(workspaceId: string, signal?: AbortSignal) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getEnableUrlPreviewQuery,
|
||||
query: getWorkspaceConfigQuery,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
},
|
||||
@ -26,7 +24,27 @@ export class WorkspaceShareSettingStore extends Store {
|
||||
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(
|
||||
|
@ -10,6 +10,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
|
||||
inviteId
|
||||
accepted
|
||||
emailVerified
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ query getWorkspaces {
|
||||
workspaces {
|
||||
id
|
||||
initialized
|
||||
team
|
||||
owner {
|
||||
id
|
||||
}
|
||||
|
@ -442,6 +442,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
|
||||
inviteId
|
||||
accepted
|
||||
emailVerified
|
||||
status
|
||||
}
|
||||
}
|
||||
}`,
|
||||
@ -702,6 +703,7 @@ query getWorkspaces {
|
||||
workspaces {
|
||||
id
|
||||
initialized
|
||||
team
|
||||
owner {
|
||||
id
|
||||
}
|
||||
@ -1166,19 +1168,33 @@ mutation verifyEmail($token: String!) {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getEnableUrlPreviewQuery = {
|
||||
id: 'getEnableUrlPreviewQuery' as const,
|
||||
operationName: 'getEnableUrlPreview',
|
||||
export const getWorkspaceConfigQuery = {
|
||||
id: 'getWorkspaceConfigQuery' as const,
|
||||
operationName: 'getWorkspaceConfig',
|
||||
definitionName: 'workspace',
|
||||
containsFile: false,
|
||||
query: `
|
||||
query getEnableUrlPreview($id: String!) {
|
||||
query getWorkspaceConfig($id: String!) {
|
||||
workspace(id: $id) {
|
||||
enableAi
|
||||
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 = {
|
||||
id: 'setEnableUrlPreviewMutation' as const,
|
||||
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 = {
|
||||
id: 'workspaceQuotaQuery' as const,
|
||||
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'];
|
||||
}
|
||||
|
||||
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 {
|
||||
__typename?: 'InviteUserType';
|
||||
/** User accepted */
|
||||
/**
|
||||
* User accepted
|
||||
* @deprecated Use `status` instead
|
||||
*/
|
||||
accepted: Scalars['Boolean']['output'];
|
||||
/** User avatar url */
|
||||
avatarUrl: Maybe<Scalars['String']['output']>;
|
||||
@ -462,6 +474,8 @@ export interface InviteUserType {
|
||||
name: Maybe<Scalars['String']['output']>;
|
||||
/** User permission in workspace */
|
||||
permission: Permission;
|
||||
/** Member invite status in workspace */
|
||||
status: WorkspaceMemberStatus;
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
@ -519,6 +533,7 @@ export interface Mutation {
|
||||
__typename?: 'Mutation';
|
||||
acceptInviteById: Scalars['Boolean']['output'];
|
||||
addWorkspaceFeature: Scalars['Int']['output'];
|
||||
approveMember: Scalars['String']['output'];
|
||||
cancelSubscription: SubscriptionType;
|
||||
changeEmail: UserType;
|
||||
changePassword: Scalars['Boolean']['output'];
|
||||
@ -547,7 +562,10 @@ export interface Mutation {
|
||||
deleteWorkspace: Scalars['Boolean']['output'];
|
||||
/** Create a chat session */
|
||||
forkCopilotSession: Scalars['String']['output'];
|
||||
grantMember: Scalars['String']['output'];
|
||||
invite: Scalars['String']['output'];
|
||||
inviteBatch: Array<InviteResult>;
|
||||
inviteLink: Scalars['String']['output'];
|
||||
leaveWorkspace: Scalars['Boolean']['output'];
|
||||
publishPage: WorkspacePage;
|
||||
recoverDoc: Scalars['DateTime']['output'];
|
||||
@ -556,6 +574,7 @@ export interface Mutation {
|
||||
removeWorkspaceFeature: Scalars['Int']['output'];
|
||||
resumeSubscription: SubscriptionType;
|
||||
revoke: Scalars['Boolean']['output'];
|
||||
revokeInviteLink: Scalars['Boolean']['output'];
|
||||
/** @deprecated use revokePublicPage */
|
||||
revokePage: Scalars['Boolean']['output'];
|
||||
revokePublicPage: WorkspacePage;
|
||||
@ -598,6 +617,11 @@ export interface MutationAddWorkspaceFeatureArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationApproveMemberArgs {
|
||||
userId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationCancelSubscriptionArgs {
|
||||
idempotencyKey?: InputMaybe<Scalars['String']['input']>;
|
||||
plan?: InputMaybe<SubscriptionPlan>;
|
||||
@ -665,6 +689,12 @@ export interface MutationForkCopilotSessionArgs {
|
||||
options: ForkChatSessionInput;
|
||||
}
|
||||
|
||||
export interface MutationGrantMemberArgs {
|
||||
permission: Permission;
|
||||
userId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationInviteArgs {
|
||||
email: Scalars['String']['input'];
|
||||
permission: Permission;
|
||||
@ -672,6 +702,17 @@ export interface MutationInviteArgs {
|
||||
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 {
|
||||
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
workspaceId: Scalars['String']['input'];
|
||||
@ -706,6 +747,10 @@ export interface MutationRevokeArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRevokeInviteLinkArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRevokePageArgs {
|
||||
pageId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
@ -1144,6 +1189,8 @@ export interface UpdateUserInput {
|
||||
}
|
||||
|
||||
export interface UpdateWorkspaceInput {
|
||||
/** Enable AI */
|
||||
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/** Enable url previous when sharing */
|
||||
enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
id: Scalars['ID']['input'];
|
||||
@ -1222,6 +1269,22 @@ export interface WorkspaceBlobSizes {
|
||||
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 {
|
||||
__typename?: 'WorkspacePage';
|
||||
id: Scalars['String']['output'];
|
||||
@ -1248,6 +1311,8 @@ export interface WorkspaceType {
|
||||
blobsSize: Scalars['Int']['output'];
|
||||
/** Workspace created date */
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Enable AI */
|
||||
enableAi: Scalars['Boolean']['output'];
|
||||
/** Enable url previous when sharing */
|
||||
enableUrlPreview: Scalars['Boolean']['output'];
|
||||
/** Enabled features of workspace */
|
||||
@ -1284,6 +1349,8 @@ export interface WorkspaceType {
|
||||
sharedPages: Array<Scalars['String']['output']>;
|
||||
/** The team subscription of the workspace, if exists. */
|
||||
subscription: Maybe<SubscriptionType>;
|
||||
/** if workspace is team workspace */
|
||||
team: Scalars['Boolean']['output'];
|
||||
}
|
||||
|
||||
export interface WorkspaceTypeHistoriesArgs {
|
||||
@ -1706,6 +1773,7 @@ export type GetMembersByWorkspaceIdQuery = {
|
||||
inviteId: string;
|
||||
accepted: boolean;
|
||||
emailVerified: boolean | null;
|
||||
status: WorkspaceMemberStatus;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
@ -1939,6 +2007,7 @@ export type GetWorkspacesQuery = {
|
||||
__typename?: 'WorkspaceType';
|
||||
id: string;
|
||||
initialized: boolean;
|
||||
team: boolean;
|
||||
owner: { __typename?: 'UserType'; id: string };
|
||||
}>;
|
||||
};
|
||||
@ -2361,13 +2430,27 @@ export type VerifyEmailMutation = {
|
||||
verifyEmail: boolean;
|
||||
};
|
||||
|
||||
export type GetEnableUrlPreviewQueryVariables = Exact<{
|
||||
export type GetWorkspaceConfigQueryVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetEnableUrlPreviewQuery = {
|
||||
export type GetWorkspaceConfigQuery = {
|
||||
__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<{
|
||||
@ -2469,6 +2552,41 @@ export type AcceptInviteByInviteIdMutation = {
|
||||
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<{
|
||||
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 =
|
||||
| {
|
||||
name: 'adminServerConfigQuery';
|
||||
@ -2675,9 +2814,9 @@ export type Queries =
|
||||
response: SubscriptionQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getEnableUrlPreviewQuery';
|
||||
variables: GetEnableUrlPreviewQueryVariables;
|
||||
response: GetEnableUrlPreviewQuery;
|
||||
name: 'getWorkspaceConfigQuery';
|
||||
variables: GetWorkspaceConfigQueryVariables;
|
||||
response: GetWorkspaceConfigQuery;
|
||||
}
|
||||
| {
|
||||
name: 'enabledFeaturesQuery';
|
||||
@ -2891,6 +3030,11 @@ export type Mutations =
|
||||
variables: VerifyEmailMutationVariables;
|
||||
response: VerifyEmailMutation;
|
||||
}
|
||||
| {
|
||||
name: 'setEnableAiMutation';
|
||||
variables: SetEnableAiMutationVariables;
|
||||
response: SetEnableAiMutation;
|
||||
}
|
||||
| {
|
||||
name: 'setEnableUrlPreviewMutation';
|
||||
variables: SetEnableUrlPreviewMutationVariables;
|
||||
@ -2920,4 +3064,29 @@ export type Mutations =
|
||||
name: 'acceptInviteByInviteIdMutation';
|
||||
variables: AcceptInviteByInviteIdMutationVariables;
|
||||
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,
|
||||
userId,
|
||||
accepted: true,
|
||||
status: 'Accepted',
|
||||
type: permission,
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user