feat: add workspace experimental features api (#5525)

This commit is contained in:
DarkSky
2024-01-06 11:04:49 +00:00
parent 9650a5a6a1
commit 443908da22
10 changed files with 259 additions and 59 deletions

View File

@@ -19,7 +19,13 @@ import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { FeatureManagementService } from '../features'; import { FeatureManagementService } from '../features';
import { QuotaService } from '../quota'; import { QuotaService } from '../quota';
import { AvatarStorage } from '../storage'; import { AvatarStorage } from '../storage';
import { DeleteAccount, RemoveAvatar, UserQuotaType, UserType } from './types'; import {
DeleteAccount,
RemoveAvatar,
UserOrLimitedUser,
UserQuotaType,
UserType,
} from './types';
import { UsersService } from './users'; import { UsersService } from './users';
/** /**
@@ -77,14 +83,17 @@ export class UserResolver {
ttl: 60, ttl: 60,
}, },
}) })
@Query(() => UserType, { @Query(() => UserOrLimitedUser, {
name: 'user', name: 'user',
description: 'Get user by email', description: 'Get user by email',
nullable: true, nullable: true,
}) })
@Public() @Public()
async user(@Args('email') email: string) { async user(
if (!(await this.feature.canEarlyAccess(email))) { @CurrentUser() currentUser?: UserType,
@Args('email') email?: string
) {
if (!email || !(await this.feature.canEarlyAccess(email))) {
return new GraphQLError( return new GraphQLError(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`, `You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
{ {
@@ -95,13 +104,16 @@ export class UserResolver {
} }
); );
} }
// TODO: need to limit a user can only get another user witch is in the same workspace // TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.users.findUserByEmail(email); const user = await this.users.findUserByEmail(email);
if (user?.password) { if (currentUser) return user;
const userResponse: UserType = user;
userResponse.hasPassword = true; // only return limited info when not logged in
} return {
return user; email: user?.email,
hasPassword: !!user?.password,
};
} }
@Throttle({ default: { limit: 10, ttl: 60 } }) @Throttle({ default: { limit: 10, ttl: 60 } })

View File

@@ -1,4 +1,4 @@
import { Field, Float, ID, ObjectType } from '@nestjs/graphql'; import { createUnionType, Field, Float, ID, ObjectType } from '@nestjs/graphql';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
@ObjectType('UserQuotaHumanReadable') @ObjectType('UserQuotaHumanReadable')
@@ -67,6 +67,29 @@ export class UserType implements Partial<User> {
hasPassword?: boolean; hasPassword?: boolean;
} }
@ObjectType()
export class LimitedUserType implements Partial<User> {
@Field({ description: 'User email' })
email!: string;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
}
export const UserOrLimitedUser = createUnionType({
name: 'UserOrLimitedUser',
types: () => [UserType, LimitedUserType] as const,
resolveType(value) {
if (value.id) {
return UserType;
}
return LimitedUserType;
},
});
@ObjectType() @ObjectType()
export class DeleteAccount { export class DeleteAccount {
@Field() @Field()

View File

@@ -13,13 +13,17 @@ import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { Auth, CurrentUser } from '../auth'; import { Auth, CurrentUser } from '../auth';
import { FeatureManagementService, FeatureType } from '../features'; import { FeatureManagementService, FeatureType } from '../features';
import { UserType } from '../users'; import { UserType } from '../users';
import { PermissionService } from './permission';
import { WorkspaceType } from './types'; import { WorkspaceType } from './types';
@UseGuards(CloudThrottlerGuard) @UseGuards(CloudThrottlerGuard)
@Auth() @Auth()
@Resolver(() => WorkspaceType) @Resolver(() => WorkspaceType)
export class WorkspaceManagementResolver { export class WorkspaceManagementResolver {
constructor(private readonly feature: FeatureManagementService) {} constructor(
private readonly feature: FeatureManagementService,
private readonly permission: PermissionService
) {}
@Throttle({ @Throttle({
default: { default: {
@@ -77,6 +81,51 @@ export class WorkspaceManagementResolver {
return this.feature.listFeatureWorkspaces(feature); return this.feature.listFeatureWorkspaces(feature);
} }
@Mutation(() => Boolean)
async setWorkspaceExperimentalFeature(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
@Args('enable') enable: boolean
): Promise<boolean> {
if (!(await this.feature.canEarlyAccess(user.email))) {
throw new ForbiddenException('You are not allowed to do this');
}
const owner = await this.permission.getWorkspaceOwner(workspaceId);
const availableFeatures = await this.availableFeatures(user);
if (owner.user.id !== user.id || !availableFeatures.includes(feature)) {
throw new ForbiddenException('You are not allowed to do this');
}
if (enable) {
return await this.feature
.addWorkspaceFeatures(
workspaceId,
feature,
undefined,
'add by experimental feature api'
)
.then(id => id > 0);
} else {
return await this.feature.removeWorkspaceFeature(workspaceId, feature);
}
}
@ResolveField(() => [FeatureType], {
description: 'Available features of workspace',
complexity: 2,
})
async availableFeatures(
@CurrentUser() user: UserType
): Promise<FeatureType[]> {
if (await this.feature.canEarlyAccess(user.email)) {
return [FeatureType.Copilot];
} else {
return [];
}
}
@ResolveField(() => [FeatureType], { @ResolveField(() => [FeatureType], {
description: 'Enabled features of workspace', description: 'Enabled features of workspace',
complexity: 2, complexity: 2,

View File

@@ -131,6 +131,9 @@ type WorkspaceType {
"""Owner of workspace""" """Owner of workspace"""
owner: UserType! owner: UserType!
"""Available features of workspace"""
availableFeatures: [FeatureType!]!
"""Enabled features of workspace""" """Enabled features of workspace"""
features: [FeatureType!]! features: [FeatureType!]!
@@ -299,11 +302,21 @@ type Query {
currentUser: UserType currentUser: UserType
"""Get user by email""" """Get user by email"""
user(email: String!): UserType user(email: String!): UserOrLimitedUser
earlyAccessUsers: [UserType!]! earlyAccessUsers: [UserType!]!
prices: [SubscriptionPrice!]! prices: [SubscriptionPrice!]!
} }
union UserOrLimitedUser = UserType | LimitedUserType
type LimitedUserType {
"""User email"""
email: String!
"""User password has been set"""
hasPassword: Boolean
}
type Mutation { type Mutation {
signUp(name: String!, email: String!, password: String!): UserType! signUp(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType! signIn(email: String!, password: String!): UserType!
@@ -326,6 +339,7 @@ type Mutation {
leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean! leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean!
addWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int! addWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int!
removeWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int! removeWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int!
setWorkspaceExperimentalFeature(workspaceId: String!, feature: FeatureType!, enable: Boolean!): Boolean!
sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage") sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage")
publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage! publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage!
revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage")

View File

@@ -114,8 +114,12 @@ test('should find default user', async t => {
query: ` query: `
query { query {
user(email: "alex.yang@example.org") { user(email: "alex.yang@example.org") {
email ... on UserType {
avatarUrl email
}
... on LimitedUserType {
email
}
} }
} }
`, `,

View File

@@ -1,9 +1,16 @@
query getUser($email: String!) { query getUser($email: String!) {
user(email: $email) { user(email: $email) {
id __typename
name ... on UserType {
avatarUrl id
email name
hasPassword avatarUrl
email
hasPassword
}
... on LimitedUserType {
email
hasPassword
}
} }
} }

View File

@@ -345,11 +345,31 @@ export const getUserQuery = {
query: ` query: `
query getUser($email: String!) { query getUser($email: String!) {
user(email: $email) { user(email: $email) {
id __typename
name ... on UserType {
avatarUrl id
email name
hasPassword avatarUrl
email
hasPassword
}
... on LimitedUserType {
email
hasPassword
}
}
}`,
};
export const getWorkspaceFeaturesQuery = {
id: 'getWorkspaceFeaturesQuery' as const,
operationName: 'getWorkspaceFeatures',
definitionName: 'workspace',
containsFile: false,
query: `
query getWorkspaceFeatures($workspaceId: String!) {
workspace(id: $workspaceId) {
features
} }
}`, }`,
}; };
@@ -383,19 +403,6 @@ query getWorkspacePublicPages($workspaceId: String!) {
}`, }`,
}; };
export const getWorkspaceFeaturesQuery = {
id: 'getWorkspaceFeaturesQuery' as const,
operationName: 'getWorkspaceFeatures',
definitionName: 'workspace',
containsFile: false,
query: `
query getWorkspaceFeatures($workspaceId: String!) {
workspace(id: $workspaceId) {
features
}
}`,
};
export const getWorkspaceQuery = { export const getWorkspaceQuery = {
id: 'getWorkspaceQuery' as const, id: 'getWorkspaceQuery' as const,
operationName: 'getWorkspace', operationName: 'getWorkspace',
@@ -773,6 +780,34 @@ mutation uploadAvatar($avatar: Upload!) {
}`, }`,
}; };
export const availableFeaturesQuery = {
id: 'availableFeaturesQuery' as const,
operationName: 'availableFeatures',
definitionName: 'workspace',
containsFile: false,
query: `
query availableFeatures($id: String!) {
workspace(id: $id) {
availableFeatures
}
}`,
};
export const setWorkspaceExperimentalFeatureMutation = {
id: 'setWorkspaceExperimentalFeatureMutation' as const,
operationName: 'setWorkspaceExperimentalFeature',
definitionName: 'setWorkspaceExperimentalFeature',
containsFile: false,
query: `
mutation setWorkspaceExperimentalFeature($workspaceId: String!, $feature: FeatureType!, $enable: Boolean!) {
setWorkspaceExperimentalFeature(
workspaceId: $workspaceId
feature: $feature
enable: $enable
)
}`,
};
export const addWorkspaceFeatureMutation = { export const addWorkspaceFeatureMutation = {
id: 'addWorkspaceFeatureMutation' as const, id: 'addWorkspaceFeatureMutation' as const,
operationName: 'addWorkspaceFeature', operationName: 'addWorkspaceFeature',

View File

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

View File

@@ -0,0 +1,11 @@
mutation setWorkspaceExperimentalFeature(
$workspaceId: String!
$feature: FeatureType!
$enable: Boolean!
) {
setWorkspaceExperimentalFeature(
workspaceId: $workspaceId
feature: $feature
enable: $enable
)
}

View File

@@ -363,14 +363,30 @@ export type GetUserQueryVariables = Exact<{
export type GetUserQuery = { export type GetUserQuery = {
__typename?: 'Query'; __typename?: 'Query';
user: { user:
__typename?: 'UserType'; | {
id: string; __typename: 'LimitedUserType';
name: string; email: string;
avatarUrl: string | null; hasPassword: boolean | null;
email: string; }
hasPassword: boolean | null; | {
} | null; __typename: 'UserType';
id: string;
name: string;
avatarUrl: string | null;
email: string;
hasPassword: boolean | null;
}
| null;
};
export type GetWorkspaceFeaturesQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type GetWorkspaceFeaturesQuery = {
__typename?: 'Query';
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> };
}; };
export type GetWorkspacePublicByIdQueryVariables = Exact<{ export type GetWorkspacePublicByIdQueryVariables = Exact<{
@@ -398,15 +414,6 @@ export type GetWorkspacePublicPagesQuery = {
}; };
}; };
export type GetWorkspaceFeaturesQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
}>;
export type GetWorkspaceFeaturesQuery = {
__typename?: 'Query';
workspace: { __typename?: 'WorkspaceType'; features: Array<FeatureType> };
};
export type GetWorkspaceQueryVariables = Exact<{ export type GetWorkspaceQueryVariables = Exact<{
id: Scalars['String']['input']; id: Scalars['String']['input'];
}>; }>;
@@ -739,6 +746,29 @@ export type UploadAvatarMutation = {
}; };
}; };
export type AvailableFeaturesQueryVariables = Exact<{
id: Scalars['String']['input'];
}>;
export type AvailableFeaturesQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
availableFeatures: Array<FeatureType>;
};
};
export type SetWorkspaceExperimentalFeatureMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
feature: FeatureType;
enable: Scalars['Boolean']['input'];
}>;
export type SetWorkspaceExperimentalFeatureMutation = {
__typename?: 'Mutation';
setWorkspaceExperimentalFeature: boolean;
};
export type AddWorkspaceFeatureMutationVariables = Exact<{ export type AddWorkspaceFeatureMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
feature: FeatureType; feature: FeatureType;
@@ -857,6 +887,11 @@ export type Queries =
variables: GetUserQueryVariables; variables: GetUserQueryVariables;
response: GetUserQuery; response: GetUserQuery;
} }
| {
name: 'getWorkspaceFeaturesQuery';
variables: GetWorkspaceFeaturesQueryVariables;
response: GetWorkspaceFeaturesQuery;
}
| { | {
name: 'getWorkspacePublicByIdQuery'; name: 'getWorkspacePublicByIdQuery';
variables: GetWorkspacePublicByIdQueryVariables; variables: GetWorkspacePublicByIdQueryVariables;
@@ -867,11 +902,6 @@ export type Queries =
variables: GetWorkspacePublicPagesQueryVariables; variables: GetWorkspacePublicPagesQueryVariables;
response: GetWorkspacePublicPagesQuery; response: GetWorkspacePublicPagesQuery;
} }
| {
name: 'getWorkspaceFeaturesQuery';
variables: GetWorkspaceFeaturesQueryVariables;
response: GetWorkspaceFeaturesQuery;
}
| { | {
name: 'getWorkspaceQuery'; name: 'getWorkspaceQuery';
variables: GetWorkspaceQueryVariables; variables: GetWorkspaceQueryVariables;
@@ -917,6 +947,11 @@ export type Queries =
variables: SubscriptionQueryVariables; variables: SubscriptionQueryVariables;
response: SubscriptionQuery; response: SubscriptionQuery;
} }
| {
name: 'availableFeaturesQuery';
variables: AvailableFeaturesQueryVariables;
response: AvailableFeaturesQuery;
}
| { | {
name: 'listWorkspaceFeaturesQuery'; name: 'listWorkspaceFeaturesQuery';
variables: ListWorkspaceFeaturesQueryVariables; variables: ListWorkspaceFeaturesQueryVariables;
@@ -1064,6 +1099,11 @@ export type Mutations =
variables: UploadAvatarMutationVariables; variables: UploadAvatarMutationVariables;
response: UploadAvatarMutation; response: UploadAvatarMutation;
} }
| {
name: 'setWorkspaceExperimentalFeatureMutation';
variables: SetWorkspaceExperimentalFeatureMutationVariables;
response: SetWorkspaceExperimentalFeatureMutation;
}
| { | {
name: 'addWorkspaceFeatureMutation'; name: 'addWorkspaceFeatureMutation';
variables: AddWorkspaceFeatureMutationVariables; variables: AddWorkspaceFeatureMutationVariables;