feat(server): port resolvers to node server (#2026)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
liuyi 2023-04-28 07:02:05 +08:00 committed by GitHub
parent 3df3498523
commit b4bb57b2a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 807 additions and 191 deletions

View File

@ -1 +1,2 @@
SECRET_KEY="secret"
DATABASE_URL="postgresql://affine@localhost:5432/affine"

View File

@ -1,67 +0,0 @@
-- CreateTable
CREATE TABLE "google_users" (
"id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"google_id" VARCHAR NOT NULL,
CONSTRAINT "google_users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"user_id" VARCHAR,
"user_email" TEXT,
"type" SMALLINT NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "seaql_migrations" (
"version" VARCHAR NOT NULL,
"applied_at" BIGINT NOT NULL,
CONSTRAINT "seaql_migrations_pkey" PRIMARY KEY ("version")
);
-- CreateTable
CREATE TABLE "users" (
"id" VARCHAR NOT NULL,
"name" VARCHAR NOT NULL,
"email" VARCHAR NOT NULL,
"avatar_url" VARCHAR,
"token_nonce" SMALLINT DEFAULT 0,
"password" VARCHAR,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspaces" (
"id" VARCHAR NOT NULL,
"public" BOOLEAN NOT NULL,
"type" SMALLINT NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "google_users_google_id_key" ON "google_users"("google_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- AddForeignKey
ALTER TABLE "google_users" ADD CONSTRAINT "google_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "users" (
"id" VARCHAR NOT NULL,
"name" VARCHAR NOT NULL,
"email" VARCHAR NOT NULL,
"token_nonce" SMALLINT NOT NULL DEFAULT 0,
"avatar_url" VARCHAR,
"password" VARCHAR,
"fulfilled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspaces" (
"id" VARCHAR NOT NULL,
"public" BOOLEAN NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "connected_accounts" (
"id" VARCHAR NOT NULL,
"user_id" TEXT NOT NULL,
"provider" VARCHAR NOT NULL,
"provider_user_id" VARCHAR NOT NULL,
CONSTRAINT "connected_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_workspace_permissions" (
"id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"entity_id" VARCHAR NOT NULL,
"type" SMALLINT NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_workspace_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "connected_accounts_provider_user_id_key" ON "connected_accounts"("provider_user_id");
-- AddForeignKey
ALTER TABLE "connected_accounts" ADD CONSTRAINT "connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -10,7 +10,8 @@
"scripts": {
"dev": "nodemon ./src/index.ts",
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all"
"test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
"postinstall": "prisma generate"
},
"dependencies": {
"@apollo/server": "^4.7.0",
@ -21,8 +22,10 @@
"@nestjs/platform-express": "^9.4.0",
"@prisma/client": "^4.13.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"graphql": "^16.6.0",
"graphql-type-json": "^0.3.2",
"jsonwebtoken": "^9.0.0",
"lodash-es": "^4.17.21",
"prisma": "^4.13.0",
"reflect-metadata": "^0.1.13",
@ -30,6 +33,8 @@
},
"devDependencies": {
"@nestjs/testing": "^9.4.0",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.1",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.16.2",
"@types/supertest": "^2.0.12",

View File

@ -7,46 +7,57 @@ generator client {
provider = "prisma-client-js"
}
model google_users {
id String @id @db.VarChar
user_id String @db.VarChar
google_id String @unique @db.VarChar
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
model User {
id String @id @default(uuid()) @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt
avatarUrl String? @map("avatar_url") @db.VarChar
/// Available if user signed up through OAuth providers
password String? @db.VarChar
/// User may created by email collobration invitation before signup.
/// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up
/// This implementation is convenient for handing unregistered user permissoin
fulfilled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
connectedAccounts ConnectedAccount[]
workspaces UserWorkspacePermission[]
@@map("users")
}
model permissions {
id String @id @db.VarChar
workspace_id String @db.VarChar
user_id String? @db.VarChar
user_email String?
type Int @db.SmallInt
accepted Boolean @default(false)
created_at DateTime? @default(now()) @db.Timestamptz(6)
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
@@map("workspaces")
}
model seaql_migrations {
version String @id @db.VarChar
applied_at BigInt
model ConnectedAccount {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id")
/// the general provider name, e.g. google, github, facebook
provider String @db.VarChar
/// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub
providerUserId String @unique @map("provider_user_id") @db.VarChar
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("connected_accounts")
}
model users {
id String @id @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
avatar_url String? @db.VarChar
token_nonce Int? @default(0) @db.SmallInt
password String? @db.VarChar
created_at DateTime? @default(now()) @db.Timestamptz(6)
google_users google_users[]
permissions permissions[]
}
model UserWorkspacePermission {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("entity_id") @db.VarChar
/// Read/Write/Admin/Owner
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(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
model workspaces {
id String @id @db.VarChar
public Boolean
type Int @db.SmallInt
created_at DateTime? @default(now()) @db.Timestamptz(6)
permissions permissions[]
@@map("user_workspace_permissions")
}

View File

@ -1,15 +1,10 @@
import { randomUUID } from 'node:crypto';
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.users.create({
data: {
id: randomUUID(),
...userA,
},
await prisma.user.create({
data: userA,
});
}

View File

@ -1,3 +1,4 @@
/// <reference types="./global.d.ts" />
import { Module } from '@nestjs/common';
import { ConfigModule } from './config';

View File

@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
*/
export interface AFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/**
* Application sign key secret
*/
readonly secret: string;
/**
* System version
*/

View File

@ -2,6 +2,7 @@ import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def';
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
secret: 'secret',
version: pkg.version,
ENV_MAP: {},
env: process.env.NODE_ENV ?? 'development',

5
apps/server/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare namespace Express {
interface Request {
user?: import('@prisma/client').User | null;
}
}

View File

@ -0,0 +1,82 @@
import {
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
UseGuards,
} from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { getRequestResponseFromContext } from '../../utils/nestjs';
import { AuthService } from './service';
export function getUserFromContext(context: ExecutionContext) {
const req = getRequestResponseFromContext(context).req;
return req.user;
}
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
@Injectable()
class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
if (!token) {
return false;
}
const claims = this.auth.verify(token);
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
return !!req.user;
}
}
/**
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
*
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
* fast throw if user is not logged in.
*
* @example
*
* ```typescript
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: User) {
* return user;
* }
* ```
*/
export const Auth = () => {
return UseGuards(AuthGuard);
};

View File

@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@Global()
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
})
export class AuthModule {}
export * from './guard';

View File

@ -0,0 +1,54 @@
import { ForbiddenException } from '@nestjs/common';
import {
Args,
Context,
Field,
Mutation,
ObjectType,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { Request } from 'express';
import { UserType } from '../users/resolver';
import { CurrentUser } from './guard';
import { AuthService } from './service';
@ObjectType()
export class TokenType {
@Field()
token!: string;
@Field()
refresh!: string;
}
@Resolver(() => UserType)
export class AuthResolver {
constructor(private auth: AuthService) {}
@ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
if (user !== currentUser) {
throw new ForbiddenException();
}
return {
token: this.auth.sign(user),
// TODO: impl
refresh: '',
};
}
@Mutation(() => UserType)
async signIn(
@Context() ctx: { req: Request },
@Args('email') email: string,
@Args('password') password: string
) {
const user = await this.auth.signIn(email, password);
ctx.req.user = user;
return user;
}
}

View File

@ -0,0 +1,45 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { User } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
@Injectable()
export class AuthService {
constructor(private config: Config, private prisma: PrismaService) {}
sign(user: UserClaim) {
return jwt.sign(user, this.config.secret);
}
verify(token: string) {
try {
const claims = jwt.verify(token, this.config.secret) as UserClaim;
return claims;
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
}
async signIn(email: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
email,
password,
},
});
if (!user) {
throw new BadRequestException('Invalid email or password');
}
return user;
}
}

View File

@ -1,4 +1,5 @@
import { AuthModule } from './auth';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
export const BusinessModules = [WorkspaceModule, UsersModule];
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];

View File

@ -1,36 +1,36 @@
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
import type { users } from '@prisma/client';
import type { User } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
@ObjectType()
export class User implements users {
export class UserType implements Partial<User> {
@Field(() => ID)
id!: string;
@Field({ description: 'User name' })
name!: string;
@Field({ description: 'User email' })
email!: string;
@Field({ description: 'User password', nullable: true })
password!: string;
@Field({ description: 'User avatar url', nullable: true })
avatar_url!: string;
@Field({ description: 'User token nonce', nullable: true })
token_nonce!: number;
avatarUrl!: string;
@Field({ description: 'User created date', nullable: true })
created_at!: Date;
createdAt!: Date;
}
@Resolver(() => User)
@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly prisma: PrismaService) {}
@Query(() => User, {
@Query(() => UserType, {
name: 'user',
description: 'Get user by email',
})
async user(@Args('email') email: string) {
return this.prisma.users.findUnique({
return this.prisma.user.findUnique({
where: { email },
});
}

View File

@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver],
providers: [WorkspaceResolver, PermissionService],
exports: [PermissionService],
})
export class WorkspaceModule {}
export { WorkspaceType } from './resolver';

View File

@ -0,0 +1,134 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma';
import { Permission } from './types';
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async get(ws: string, user: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
userId: user,
accepted: true,
},
});
return data?.type as Permission;
}
async check(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
if (!(await this.tryCheck(ws, user, permission))) {
throw new ForbiddenException('Permission denied');
}
}
async tryCheck(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.count({
where: {
workspaceId: ws,
userId: user,
accepted: true,
type: {
gte: permission,
},
},
});
if (data > 0) {
return true;
}
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
return data > 0;
}
return false;
}
async grant(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
userId: user,
accepted: true,
},
});
if (data) {
const [p] = await this.prisma.$transaction(
[
this.prisma.userWorkspacePermission.update({
where: {
id: data.id,
},
data: {
type: permission,
},
}),
// If the new permission is owner, we need to revoke old owner
permission === Permission.Owner
? this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
type: Permission.Owner,
userId: {
not: user,
},
},
data: {
type: Permission.Admin,
},
})
: null,
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p;
}
return this.prisma.userWorkspacePermission.create({
data: {
workspaceId: ws,
userId: user,
type: permission,
},
});
}
async revoke(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
}

View File

@ -1,85 +1,206 @@
import { randomUUID } from 'node:crypto';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
ID,
InputType,
Int,
Mutation,
ObjectType,
Parent,
PartialType,
PickType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { workspaces } from '@prisma/client';
import type { User, Workspace } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
import { PrismaService } from '../../prisma';
import { Auth, CurrentUser } from '../auth';
import { UserType } from '../users/resolver';
import { PermissionService } from './permission';
import { Permission } from './types';
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
registerEnumType(WorkspaceType, {
name: 'WorkspaceType',
description: 'Workspace type',
valuesMap: {
Normal: {
description: 'Normal workspace',
},
Private: {
description: 'Private workspace',
},
},
registerEnumType(Permission, {
name: 'Permission',
description: 'User permission in workspace',
});
@ObjectType()
export class Workspace implements workspaces {
export class WorkspaceType implements Partial<Workspace> {
@Field(() => ID)
id!: string;
@Field({ description: 'is Public workspace' })
public!: boolean;
@Field(() => WorkspaceType, { description: 'Workspace type' })
type!: WorkspaceType;
@Field({ description: 'Workspace created date' })
created_at!: Date;
createdAt!: Date;
}
@Resolver(() => Workspace)
@InputType()
export class UpdateWorkspaceInput extends PickType(
PartialType(WorkspaceType),
['public'],
InputType
) {
@Field(() => ID)
id!: string;
}
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService
) {}
// debug only query should be removed
@Query(() => [Workspace], {
name: 'workspaces',
description: 'Get all workspaces',
@ResolveField(() => Permission, {
description: 'Permission of current signed in user in workspace',
complexity: 2,
})
async workspaces() {
return this.prisma.workspaces.findMany();
async permission(
@CurrentUser() user: User,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
if ('permission' in workspace) {
return workspace.permission;
}
const permission = this.permissionProvider.get(workspace.id, user.id);
if (!permission) {
throw new ForbiddenException();
}
return permission;
}
@Query(() => Workspace, {
name: 'workspace',
description: 'Get workspace by id',
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
async workspace(@Args('id') id: string) {
return this.prisma.workspaces.findUnique({
where: { id },
});
}
// create workspace
@Mutation(() => Workspace, {
name: 'createWorkspace',
description: 'Create workspace',
})
async createWorkspace() {
return this.prisma.workspaces.create({
data: {
id: randomUUID(),
type: WorkspaceType.Private,
public: false,
created_at: new Date(),
memberCount(@Parent() workspace: WorkspaceType) {
return this.prisma.userWorkspacePermission.count({
where: {
workspaceId: workspace.id,
accepted: true,
},
});
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId: workspace.id,
type: Permission.Owner,
},
include: {
user: true,
},
});
return data.user;
}
@Query(() => [WorkspaceType], {
description: 'Get all accessible workspaces for current user',
complexity: 2,
})
async workspaces(@CurrentUser() user: User) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
userId: user.id,
accepted: true,
},
include: {
workspace: true,
},
});
return data.map(({ workspace, type }) => {
return {
...workspace,
permission: type,
};
});
}
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) {
throw new NotFoundException("Workspace doesn't exist");
}
return workspace;
}
@Mutation(() => WorkspaceType, {
description: 'Create a new workspace',
})
async createWorkspace(@CurrentUser() user: User) {
return this.prisma.workspace.create({
data: {
public: false,
users: {
create: {
type: Permission.Owner,
user: {
connect: {
id: user.id,
},
},
accepted: true,
},
},
},
});
}
@Mutation(() => WorkspaceType, {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: User,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissionProvider.check('id', user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
id,
},
data: updates,
});
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
where: {
id,
},
});
// TODO:
// delete all related data, like websocket connections, blobs, etc.
return true;
}
}

View File

@ -0,0 +1,6 @@
export enum Permission {
Read = 0,
Write = 1,
Admin = 10,
Owner = 99,
}

View File

@ -8,3 +8,4 @@ import { PrismaService } from './service';
exports: [PrismaService],
})
export class PrismaModule {}
export { PrismaService } from './service';

View File

@ -34,22 +34,46 @@ describe('AppModule', () => {
.post(gql)
.send({
query: `
query {
error
}
`,
query {
error
}
`,
})
.expect(400);
let token;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
await request(app.getHttpServer())
.post(gql)
.set({ Authorization: token })
.send({
query: `
mutation {
createWorkspace {
id
type
public
created_at
createdAt
}
}
`,
@ -64,16 +88,12 @@ describe('AppModule', () => {
typeof res.body.data.createWorkspace.id === 'string',
'res.body.data.createWorkspace.id is not a string'
);
ok(
typeof res.body.data.createWorkspace.type === 'string',
'res.body.data.createWorkspace.type is not a string'
);
ok(
typeof res.body.data.createWorkspace.public === 'boolean',
'res.body.data.createWorkspace.public is not a boolean'
);
ok(
typeof res.body.data.createWorkspace.created_at === 'string',
typeof res.body.data.createWorkspace.createdAt === 'string',
'res.body.data.createWorkspace.created_at is not a string'
);
});
@ -87,7 +107,7 @@ describe('AppModule', () => {
query {
user(email: "alex.yang@example.org") {
email
avatar_url
avatarUrl
}
}
`,

View File

@ -0,0 +1,57 @@
import { ArgumentsHost, ExecutionContext } from '@nestjs/common';
import {
GqlArgumentsHost,
GqlContextType,
GqlExecutionContext,
} from '@nestjs/graphql';
import { Request, Response } from 'express';
export function getRequestResponseFromContext(context: ExecutionContext) {
switch (context.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlExecutionContext.create(context).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res!,
};
}
case 'http': {
const http = context.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
default:
throw new Error('Unknown context type for getting request and response');
}
}
export function getRequestResponseFromHost(host: ArgumentsHost) {
switch (host.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlArgumentsHost.create(host).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res!,
};
}
case 'http': {
const http = host.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
default:
throw new Error('Unknown host type for getting request and response');
}
}
export function getRequestFromHost(host: ArgumentsHost) {
return getRequestResponseFromHost(host).req;
}

View File

@ -7,5 +7,5 @@
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["./scripts", "package.json"]
"include": ["scripts", "package.json"]
}

View File

@ -46,6 +46,9 @@
{
"path": "./apps/web"
},
{
"path": "./apps/server"
},
{
"path": "./packages/component"
},

View File

@ -248,13 +248,17 @@ __metadata:
"@nestjs/platform-express": ^9.4.0
"@nestjs/testing": ^9.4.0
"@prisma/client": ^4.13.0
"@types/express": ^4.17.17
"@types/jsonwebtoken": ^9.0.1
"@types/lodash-es": ^4.17.7
"@types/node": ^18.16.2
"@types/supertest": ^2.0.12
c8: ^7.13.0
dotenv: ^16.0.3
express: ^4.18.2
graphql: ^16.6.0
graphql-type-json: ^0.3.2
jsonwebtoken: ^9.0.0
lodash-es: ^4.17.21
nodemon: ^2.0.22
prisma: ^4.13.0
@ -8269,7 +8273,7 @@ __metadata:
languageName: node
linkType: hard
"@types/express@npm:^4.17.13, @types/express@npm:^4.7.0":
"@types/express@npm:^4.17.13, @types/express@npm:^4.17.17, @types/express@npm:^4.7.0":
version: 4.17.17
resolution: "@types/express@npm:4.17.17"
dependencies:
@ -8438,6 +8442,15 @@ __metadata:
languageName: node
linkType: hard
"@types/jsonwebtoken@npm:^9.0.1":
version: 9.0.1
resolution: "@types/jsonwebtoken@npm:9.0.1"
dependencies:
"@types/node": "*"
checksum: a7f0925e9a42ad3ae970364c63c5986d40da5c83d51d3f4e624eb0f064a380376f9e3fb3f2f837390a9ab80143f5d75fd51866da30e110f6b486a3379e1c768f
languageName: node
linkType: hard
"@types/keyv@npm:^3.1.4":
version: 3.1.4
resolution: "@types/keyv@npm:3.1.4"
@ -10609,6 +10622,13 @@ __metadata:
languageName: node
linkType: hard
"buffer-equal-constant-time@npm:1.0.1":
version: 1.0.1
resolution: "buffer-equal-constant-time@npm:1.0.1"
checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab
languageName: node
linkType: hard
"buffer-equal@npm:^1.0.0":
version: 1.0.1
resolution: "buffer-equal@npm:1.0.1"
@ -12575,6 +12595,15 @@ __metadata:
languageName: node
linkType: hard
"ecdsa-sig-formatter@npm:1.0.11":
version: 1.0.11
resolution: "ecdsa-sig-formatter@npm:1.0.11"
dependencies:
safe-buffer: ^5.0.1
checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03
languageName: node
linkType: hard
"ee-first@npm:1.1.1":
version: 1.1.1
resolution: "ee-first@npm:1.1.1"
@ -13749,7 +13778,7 @@ __metadata:
languageName: node
linkType: hard
"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3":
"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2":
version: 4.18.2
resolution: "express@npm:4.18.2"
dependencies:
@ -17588,6 +17617,18 @@ __metadata:
languageName: node
linkType: hard
"jsonwebtoken@npm:^9.0.0":
version: 9.0.0
resolution: "jsonwebtoken@npm:9.0.0"
dependencies:
jws: ^3.2.2
lodash: ^4.17.21
ms: ^2.1.1
semver: ^7.3.8
checksum: b9181cecf9df99f1dc0253f91ba000a1aa4d91f5816d1608c0dba61a5623726a0bfe200b51df25de18c1a6000825d231ad7ce2788aa54fd48dcb760ad9eb9514
languageName: node
linkType: hard
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.3":
version: 3.3.3
resolution: "jsx-ast-utils@npm:3.3.3"
@ -17605,6 +17646,27 @@ __metadata:
languageName: node
linkType: hard
"jwa@npm:^1.4.1":
version: 1.4.1
resolution: "jwa@npm:1.4.1"
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: ^5.0.1
checksum: ff30ea7c2dcc61f3ed2098d868bf89d43701605090c5b21b5544b512843ec6fd9e028381a4dda466cbcdb885c2d1150f7c62e7168394ee07941b4098e1035e2f
languageName: node
linkType: hard
"jws@npm:^3.2.2":
version: 3.2.2
resolution: "jws@npm:3.2.2"
dependencies:
jwa: ^1.4.1
safe-buffer: ^5.0.1
checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2
languageName: node
linkType: hard
"keyv@npm:^4.0.0, keyv@npm:^4.5.2":
version: 4.5.2
resolution: "keyv@npm:4.5.2"