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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1997 additions and 218 deletions

View File

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

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "workspaces" ADD COLUMN "enable_ai" BOOLEAN NOT NULL DEFAULT true;

View File

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

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 {

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) {
inviteId
accepted
emailVerified
status
}
}
}

View File

@ -2,6 +2,7 @@ query getWorkspaces {
workspaces {
id
initialized
team
owner {
id
}

View File

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

View File

@ -0,0 +1,6 @@
query getWorkspaceConfig($id: String!) {
workspace(id: $id) {
enableAi
enableUrlPreview
}
}

View File

@ -0,0 +1,5 @@
mutation setEnableAi($id: ID!, $enableAi: Boolean!) {
updateWorkspace(input: { id: $id, enableAi: $enableAi }) {
id
}
}

View File

@ -1,5 +0,0 @@
query getEnableUrlPreview($id: String!) {
workspace(id: $id) {
enableUrlPreview
}
}

View File

@ -0,0 +1,15 @@
mutation inviteBatch(
$workspaceId: String!
$emails: [String!]!
$sendInviteMail: Boolean
) {
inviteBatch(
workspaceId: $workspaceId
emails: $emails
sendInviteMail: $sendInviteMail
) {
email
inviteId
sentSuccess
}
}

View File

@ -0,0 +1,6 @@
mutation inviteLink(
$workspaceId: String!
$expireTime: WorkspaceInviteLinkExpireTime!
) {
inviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
}

View File

@ -0,0 +1,3 @@
mutation revokeInviteLink($workspaceId: String!) {
revokeInviteLink(workspaceId: $workspaceId)
}

View File

@ -0,0 +1,3 @@
mutation approveWorkspaceTeamMember($workspaceId: String!, $userId: String!) {
approveMember(workspaceId: $workspaceId, userId: $userId)
}

View File

@ -0,0 +1,11 @@
mutation grantWorkspaceTeamMember(
$workspaceId: String!
$userId: String!
$permission: Permission!
) {
grantMember(
workspaceId: $workspaceId
userId: $userId
permission: $permission
)
}

View File

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

View File

@ -90,6 +90,7 @@ export async function addUserToWorkspace(
workspaceId: workspace.id,
userId,
accepted: true,
status: 'Accepted',
type: permission,
},
});