feat(server): team quota (#8955)

This commit is contained in:
DarkSky
2024-12-09 17:51:54 +08:00
committed by GitHub
parent 8fe188e773
commit 9365958a02
51 changed files with 1997 additions and 218 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
export * from './blob';
export * from './history';
export * from './page';
export * from './team';
export * from './workspace';

View File

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

View 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();
}
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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