From f9d75962ed95f0e8e3f2338cdd7e15815513a0a0 Mon Sep 17 00:00:00 2001 From: luch <32321690+luch1994@users.noreply.github.com> Date: Thu, 30 May 2024 21:32:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E5=92=8C=E5=8D=8F=E4=BD=9C=E5=8A=9F=E8=83=BD=20(#252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/app.module.ts | 8 + server/src/enums/exceptionCode.ts | 4 +- server/src/enums/surveyPermission.ts | 20 + server/src/enums/workspace.ts | 41 ++ .../__test/httpExceptions.filter.spec.ts | 55 +++ .../src/exceptions/httpExceptions.filter.ts | 1 - ...nException.ts => noPermissionException.ts} | 4 +- .../authentication.guard.spec.ts} | 10 +- server/src/guards/__test/survey.guard.spec.ts | 139 +++++++ .../src/guards/__test/workspace.guard.spec.ts | 137 +++++++ ...uthtication.ts => authentication.guard.ts} | 2 +- server/src/guards/survey.guard.ts | 88 ++++ server/src/guards/workspace.guard.ts | 72 ++++ server/src/logger/index.ts | 8 +- server/src/models/collaborator.entity.ts | 14 + server/src/models/surveyMeta.entity.ts | 6 + server/src/models/workspace.entity.ts | 14 + server/src/models/workspaceMember.entity.ts | 14 + .../auth/__test/user.controller.spec.ts | 82 ++++ .../modules/auth/__test/user.service.spec.ts | 6 +- server/src/modules/auth/auth.module.ts | 3 +- .../auth/controllers/user.controller.ts | 46 +++ .../src/modules/auth/dto/getUserList.dto.ts | 21 + .../src/modules/auth/services/user.service.ts | 48 +++ .../file/controllers/file.controller.ts | 4 +- .../messagePushingTask.controller.spec.ts | 4 +- .../messagePushingTask.controller.ts | 26 +- .../dto/createMessagePushingTask.dto.ts | 6 +- .../dto/queryMessagePushingTaskList.dto.ts | 2 +- .../__test/collaborator.controller.spec.ts | 220 ++++++++++ .../__test/collaborator.service.spec.ts | 331 ++++++++++++++++ .../__test/dataStatistic.controller.spec.ts | 32 +- .../survey/__test/survey.controller.spec.ts | 50 +-- .../__test/surveyHistory.controller.spec.ts | 40 +- .../__test/surveyMeta.controller.spec.ts | 170 ++++---- .../survey/__test/surveyMeta.service.spec.ts | 63 +-- .../controllers/collaborator.controller.ts | 375 ++++++++++++++++++ .../controllers/dataStatistic.controller.ts | 39 +- .../survey/controllers/survey.controller.ts | 163 ++++---- .../controllers/surveyHistory.controller.ts | 47 ++- .../controllers/surveyMeta.controller.ts | 104 +++-- .../survey/dto/batchSaveCollaborator.dto.ts | 51 +++ .../survey/dto/changeUserPermission.dto.ts | 28 ++ .../survey/dto/createCollaborator.dto.ts | 28 ++ .../modules/survey/dto/createSurvey.dto.ts | 41 ++ .../dto/getSurveyCollaboratorList.dto.ts | 13 + .../survey/dto/getSurveyMetaList.dto.ts | 29 ++ .../survey/services/collaborator.service.ts | 157 ++++++++ .../survey/services/surveyMeta.service.ts | 72 +++- server/src/modules/survey/survey.module.ts | 8 + .../modules/survey/utils/splitCollaborator.ts | 15 + .../__test/surveyResponse.controller.spec.ts | 20 +- .../controllers/surveyResponse.controller.ts | 27 +- .../surveyResponse/surveyResponse.module.ts | 7 +- .../workspace/_test/splitMember.spec.ts | 50 +++ .../_test/workspace.controller.spec.ts | 229 +++++++++++ .../workspace/_test/workspace.service.spec.ts | 126 ++++++ .../_test/workspaceMember.controller.spec.ts | 183 +++++++++ .../_test/workspaceMember.service.spec.ts | 196 +++++++++ .../controllers/workspace.controller.ts | 329 +++++++++++++++ .../controllers/workspaceMember.controller.ts | 120 ++++++ .../workspace/dto/createWorkspace.dto.ts | 29 ++ .../dto/createWorkspaceMember.dto.ts | 24 ++ .../dto/deleteWorkspaceMember.dto.ts | 17 + .../dto/updateWorkspaceMember.dto.ts | 24 ++ .../workspace/services/workspace.service.ts | 107 +++++ .../services/workspaceMember.service.ts | 143 +++++++ .../modules/workspace/utils/splitMember.ts | 26 ++ .../src/modules/workspace/workspace.module.ts | 38 ++ 69 files changed, 4217 insertions(+), 439 deletions(-) create mode 100644 server/src/enums/surveyPermission.ts create mode 100644 server/src/enums/workspace.ts create mode 100644 server/src/exceptions/__test/httpExceptions.filter.spec.ts rename server/src/exceptions/{noSurveyPermissionException.ts => noPermissionException.ts} (57%) rename server/src/guards/{authtication.spec.ts => __test/authentication.guard.spec.ts} (92%) create mode 100644 server/src/guards/__test/survey.guard.spec.ts create mode 100644 server/src/guards/__test/workspace.guard.spec.ts rename server/src/guards/{authtication.ts => authentication.guard.ts} (93%) create mode 100644 server/src/guards/survey.guard.ts create mode 100644 server/src/guards/workspace.guard.ts create mode 100644 server/src/models/collaborator.entity.ts create mode 100644 server/src/models/workspace.entity.ts create mode 100644 server/src/models/workspaceMember.entity.ts create mode 100644 server/src/modules/auth/__test/user.controller.spec.ts create mode 100644 server/src/modules/auth/controllers/user.controller.ts create mode 100644 server/src/modules/auth/dto/getUserList.dto.ts create mode 100644 server/src/modules/survey/__test/collaborator.controller.spec.ts create mode 100644 server/src/modules/survey/__test/collaborator.service.spec.ts create mode 100644 server/src/modules/survey/controllers/collaborator.controller.ts create mode 100644 server/src/modules/survey/dto/batchSaveCollaborator.dto.ts create mode 100644 server/src/modules/survey/dto/changeUserPermission.dto.ts create mode 100644 server/src/modules/survey/dto/createCollaborator.dto.ts create mode 100644 server/src/modules/survey/dto/createSurvey.dto.ts create mode 100644 server/src/modules/survey/dto/getSurveyCollaboratorList.dto.ts create mode 100644 server/src/modules/survey/dto/getSurveyMetaList.dto.ts create mode 100644 server/src/modules/survey/services/collaborator.service.ts create mode 100644 server/src/modules/survey/utils/splitCollaborator.ts create mode 100644 server/src/modules/workspace/_test/splitMember.spec.ts create mode 100644 server/src/modules/workspace/_test/workspace.controller.spec.ts create mode 100644 server/src/modules/workspace/_test/workspace.service.spec.ts create mode 100644 server/src/modules/workspace/_test/workspaceMember.controller.spec.ts create mode 100644 server/src/modules/workspace/_test/workspaceMember.service.spec.ts create mode 100644 server/src/modules/workspace/controllers/workspace.controller.ts create mode 100644 server/src/modules/workspace/controllers/workspaceMember.controller.ts create mode 100644 server/src/modules/workspace/dto/createWorkspace.dto.ts create mode 100644 server/src/modules/workspace/dto/createWorkspaceMember.dto.ts create mode 100644 server/src/modules/workspace/dto/deleteWorkspaceMember.dto.ts create mode 100644 server/src/modules/workspace/dto/updateWorkspaceMember.dto.ts create mode 100644 server/src/modules/workspace/services/workspace.service.ts create mode 100644 server/src/modules/workspace/services/workspaceMember.service.ts create mode 100644 server/src/modules/workspace/utils/splitMember.ts create mode 100644 server/src/modules/workspace/workspace.module.ts diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 4950f80a..3faa2987 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -13,6 +13,7 @@ import { SurveyResponseModule } from './modules/surveyResponse/surveyResponse.mo import { AuthModule } from './modules/auth/auth.module'; import { MessageModule } from './modules/message/message.module'; import { FileModule } from './modules/file/file.module'; +import { WorkspaceModule } from './modules/workspace/workspace.module'; import { join } from 'path'; @@ -31,6 +32,9 @@ import { ClientEncrypt } from './models/clientEncrypt.entity'; import { Word } from './models/word.entity'; import { MessagePushingTask } from './models/messagePushingTask.entity'; import { MessagePushingLog } from './models/messagePushingLog.entity'; +import { WorkspaceMember } from './models/workspaceMember.entity'; +import { Workspace } from './models/workspace.entity'; +import { Collaborator } from './models/collaborator.entity'; import { LoggerProvider } from './logger/logger.provider'; import { PluginManagerProvider } from './securityPlugin/pluginManager.provider'; @@ -74,6 +78,9 @@ import { Logger } from './logger'; Word, MessagePushingTask, MessagePushingLog, + Workspace, + WorkspaceMember, + Collaborator, ], }; }, @@ -92,6 +99,7 @@ import { Logger } from './logger'; }), MessageModule, FileModule, + WorkspaceModule, ], controllers: [AppController], providers: [ diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts index 7a9eafcc..30d943ba 100644 --- a/server/src/enums/exceptionCode.ts +++ b/server/src/enums/exceptionCode.ts @@ -1,6 +1,8 @@ export enum EXCEPTION_CODE { - AUTHENTICATION_FAILED = 1001, // 没有权限 + AUTHENTICATION_FAILED = 1001, // 未授权 PARAMETER_ERROR = 1002, // 参数有误 + NO_PERMISSION = 1003, // 没有操作权限 + USER_EXISTS = 2001, // 用户已存在 USER_NOT_EXISTS = 2002, // 用户不存在 USER_PASSWORD_WRONG = 2003, // 用户名或密码错误 diff --git a/server/src/enums/surveyPermission.ts b/server/src/enums/surveyPermission.ts new file mode 100644 index 00000000..7e647457 --- /dev/null +++ b/server/src/enums/surveyPermission.ts @@ -0,0 +1,20 @@ +export enum SURVEY_PERMISSION { + SURVEY_CONF_MANAGE = 'SURVEY_CONF_MANAGE', + SURVEY_RESPONSE_MANAGE = 'SURVEY_RESPONSE_MANAGE', + SURVEY_COOPERATION_MANAGE = 'SURVEY_COOPERATION_MANAGE', +} + +export const SURVEY_PERMISSION_DESCRIPTION = { + SURVEY_CONF_MANAGE: { + name: '问卷配置管理', + value: SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + }, + surveyResponseManage: { + name: '问卷分析管理', + value: SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + }, + surveyCooperatorManage: { + name: '协作者管理', + value: SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + }, +}; diff --git a/server/src/enums/workspace.ts b/server/src/enums/workspace.ts new file mode 100644 index 00000000..07a81f11 --- /dev/null +++ b/server/src/enums/workspace.ts @@ -0,0 +1,41 @@ +export enum ROLE { + ADMIN = 'admin', + USER = 'user', +} + +export const ROLE_DESCRIPTION = { + ADMIN: { + name: '管理员', + value: ROLE.ADMIN, + }, + USER: { + name: '用户', + value: ROLE.USER, + }, +}; + +export enum PERMISSION { + READ_WORKSPACE = 'READ_WORKSPACE', + WRITE_WORKSPACE = 'WRITE_WORKSPACE', + READ_MEMBER = 'READ_MEMBER', + WRITE_MEMBER = 'WRITE_MEMBER', + READ_SURVEY = 'READ_SURVEY', + WRITE_SURVEY = 'WRITE_SURVEY', +} + +export const ROLE_PERMISSION: Record = { + [ROLE.ADMIN]: [ + PERMISSION.READ_WORKSPACE, + PERMISSION.WRITE_WORKSPACE, + PERMISSION.READ_MEMBER, + PERMISSION.WRITE_MEMBER, + PERMISSION.READ_SURVEY, + PERMISSION.WRITE_SURVEY, + ], + [ROLE.USER]: [ + PERMISSION.READ_WORKSPACE, + PERMISSION.READ_MEMBER, + PERMISSION.READ_SURVEY, + PERMISSION.WRITE_SURVEY, + ], +}; diff --git a/server/src/exceptions/__test/httpExceptions.filter.spec.ts b/server/src/exceptions/__test/httpExceptions.filter.spec.ts new file mode 100644 index 00000000..6ff03c87 --- /dev/null +++ b/server/src/exceptions/__test/httpExceptions.filter.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpExceptionsFilter } from '../httpExceptions.filter'; +import { ArgumentsHost } from '@nestjs/common'; +import { HttpException } from '../httpException'; +import { Response } from 'express'; + +describe('HttpExceptionsFilter', () => { + let filter: HttpExceptionsFilter; + let mockArgumentsHost: ArgumentsHost; + let mockResponse: Partial; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HttpExceptionsFilter], + }).compile(); + + filter = module.get(HttpExceptionsFilter); + + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnThis(), + getResponse: jest.fn().mockReturnValue(mockResponse), + } as unknown as ArgumentsHost; + }); + + it('should return 500 status and "Internal Server Error" message for generic errors', () => { + const genericError = new Error('Some error'); + + filter.catch(genericError, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Internal Server Error', + code: 500, + errmsg: 'Some error', + }); + }); + + it('should return 200 status and specific message for HttpException', () => { + const httpException = new HttpException('Specific error message', 1001); + + filter.catch(httpException, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + message: 'Specific error message', + code: 1001, + errmsg: 'Specific error message', + }); + }); +}); diff --git a/server/src/exceptions/httpExceptions.filter.ts b/server/src/exceptions/httpExceptions.filter.ts index a7432f02..66894deb 100644 --- a/server/src/exceptions/httpExceptions.filter.ts +++ b/server/src/exceptions/httpExceptions.filter.ts @@ -1,4 +1,3 @@ -// all-exceptions.filter.ts import { ExceptionFilter, Catch, diff --git a/server/src/exceptions/noSurveyPermissionException.ts b/server/src/exceptions/noPermissionException.ts similarity index 57% rename from server/src/exceptions/noSurveyPermissionException.ts rename to server/src/exceptions/noPermissionException.ts index 17180ea1..34d49e0e 100644 --- a/server/src/exceptions/noSurveyPermissionException.ts +++ b/server/src/exceptions/noPermissionException.ts @@ -1,8 +1,8 @@ import { HttpException } from './httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -export class NoSurveyPermissionException extends HttpException { +export class NoPermissionException extends HttpException { constructor(public readonly message: string) { - super(message, EXCEPTION_CODE.NO_SURVEY_PERMISSION); + super(message, EXCEPTION_CODE.NO_PERMISSION); } } diff --git a/server/src/guards/authtication.spec.ts b/server/src/guards/__test/authentication.guard.spec.ts similarity index 92% rename from server/src/guards/authtication.spec.ts rename to server/src/guards/__test/authentication.guard.spec.ts index 35512ae4..684d248d 100644 --- a/server/src/guards/authtication.spec.ts +++ b/server/src/guards/__test/authentication.guard.spec.ts @@ -1,21 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { Authtication } from './authtication'; +import { Authentication } from '../authentication.guard'; import { AuthService } from 'src/modules/auth/services/auth.service'; import { AuthenticationException } from 'src/exceptions/authException'; import { User } from 'src/models/user.entity'; jest.mock('jsonwebtoken'); -describe('Authtication', () => { - let guard: Authtication; +describe('Authentication', () => { + let guard: Authentication; let authService: AuthService; let configService: ConfigService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - Authtication, + Authentication, { provide: AuthService, useValue: { @@ -31,7 +31,7 @@ describe('Authtication', () => { ], }).compile(); - guard = module.get(Authtication); + guard = module.get(Authentication); authService = module.get(AuthService); configService = module.get(ConfigService); }); diff --git a/server/src/guards/__test/survey.guard.spec.ts b/server/src/guards/__test/survey.guard.spec.ts new file mode 100644 index 00000000..4c30c453 --- /dev/null +++ b/server/src/guards/__test/survey.guard.spec.ts @@ -0,0 +1,139 @@ +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext } from '@nestjs/common'; + +import { SurveyGuard } from '../survey.guard'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { CollaboratorService } from 'src/modules/survey/services/collaborator.service'; +import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { Collaborator } from 'src/models/collaborator.entity'; + +describe('SurveyGuard', () => { + let guard: SurveyGuard; + let reflector: Reflector; + let collaboratorService: CollaboratorService; + let surveyMetaService: SurveyMetaService; + let workspaceMemberService: WorkspaceMemberService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SurveyGuard, + { + provide: Reflector, + useValue: { + get: jest.fn(), + }, + }, + { + provide: CollaboratorService, + useValue: { + getCollaborator: jest.fn(), + }, + }, + { + provide: SurveyMetaService, + useValue: { + getSurveyById: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + guard = module.get(SurveyGuard); + reflector = module.get(Reflector); + collaboratorService = module.get(CollaboratorService); + surveyMetaService = module.get(SurveyMetaService); + workspaceMemberService = module.get( + WorkspaceMemberService, + ); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should allow access if no surveyId is present', async () => { + const context = createMockExecutionContext(); + jest.spyOn(reflector, 'get').mockReturnValue(null); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should throw SurveyNotFoundException if survey does not exist', async () => { + const context = createMockExecutionContext(); + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest.spyOn(surveyMetaService, 'getSurveyById').mockResolvedValue(null); + + await expect(guard.canActivate(context)).rejects.toThrow( + SurveyNotFoundException, + ); + }); + + it('should allow access if user is the owner of the survey', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'testUser', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should allow access if user is a workspace member', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: 'workspaceId' }; + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest + .spyOn(workspaceMemberService, 'findOne') + .mockResolvedValue({} as WorkspaceMember); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should throw NoPermissionException if user has no permissions', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); + jest.spyOn(reflector, 'get').mockReturnValueOnce(['requiredPermission']); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue({ permissions: [] } as Collaborator); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + function createMockExecutionContext(): ExecutionContext { + return { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + user: { username: 'testUser', _id: 'testUserId' }, + params: { surveyId: 'surveyId' }, + }), + }), + getHandler: jest.fn(), + } as unknown as ExecutionContext; + } +}); diff --git a/server/src/guards/__test/workspace.guard.spec.ts b/server/src/guards/__test/workspace.guard.spec.ts new file mode 100644 index 00000000..d5026534 --- /dev/null +++ b/server/src/guards/__test/workspace.guard.spec.ts @@ -0,0 +1,137 @@ +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext } from '@nestjs/common'; + +import { WorkspaceGuard } from '../workspace.guard'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { NoPermissionException } from '../../exceptions/noPermissionException'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; + +import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; + +describe('WorkspaceGuard', () => { + let guard: WorkspaceGuard; + let reflector: Reflector; + let workspaceMemberService: WorkspaceMemberService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceGuard, + { + provide: Reflector, + useValue: { + get: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + guard = module.get(WorkspaceGuard); + reflector = module.get(Reflector); + workspaceMemberService = module.get( + WorkspaceMemberService, + ); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should allow access if no roles are defined', async () => { + const context = createMockExecutionContext(); + jest.spyOn(reflector, 'get').mockReturnValue(null); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should throw NoPermissionException if workspaceId is missing and optional is false', async () => { + const context = createMockExecutionContext(); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([WORKSPACE_PERMISSION.READ_WORKSPACE]); + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId'); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should allow access if workspaceId is missing and optional is true', async () => { + const context = createMockExecutionContext(); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([WORKSPACE_PERMISSION.WRITE_WORKSPACE]); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce({ key: 'params.workspaceId', optional: true }); + + jest + .spyOn(workspaceMemberService, 'findOne') + .mockResolvedValue({ role: 'admin' } as WorkspaceMember); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should throw NoPermissionException if user is not a member of the workspace', async () => { + const context = createMockExecutionContext(); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([WORKSPACE_PERMISSION.WRITE_WORKSPACE]); + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId'); + jest.spyOn(workspaceMemberService, 'findOne').mockResolvedValue(null); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should throw NoPermissionException if user role is not allowed', async () => { + const context = createMockExecutionContext(); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([WORKSPACE_PERMISSION.READ_MEMBER]); + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId'); + jest + .spyOn(workspaceMemberService, 'findOne') + .mockResolvedValue({ role: 'member' } as WorkspaceMember); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should allow access if user role is allowed', async () => { + const context = createMockExecutionContext(); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([WORKSPACE_PERMISSION.READ_MEMBER]); + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId'); + jest + .spyOn(workspaceMemberService, 'findOne') + .mockResolvedValue({ role: 'admin' } as WorkspaceMember); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + function createMockExecutionContext(): ExecutionContext { + return { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + user: { _id: 'testUserId' }, + params: { workspaceId: 'workspaceId' }, + }), + }), + getHandler: jest.fn(), + } as unknown as ExecutionContext; + } +}); diff --git a/server/src/guards/authtication.ts b/server/src/guards/authentication.guard.ts similarity index 93% rename from server/src/guards/authtication.ts rename to server/src/guards/authentication.guard.ts index 77664fc3..3223e68e 100644 --- a/server/src/guards/authtication.ts +++ b/server/src/guards/authentication.guard.ts @@ -3,7 +3,7 @@ import { AuthenticationException } from '../exceptions/authException'; import { AuthService } from 'src/modules/auth/services/auth.service'; @Injectable() -export class Authtication implements CanActivate { +export class Authentication implements CanActivate { constructor(private readonly authService: AuthService) {} async canActivate(context: ExecutionContext): Promise { diff --git a/server/src/guards/survey.guard.ts b/server/src/guards/survey.guard.ts new file mode 100644 index 00000000..de904edb --- /dev/null +++ b/server/src/guards/survey.guard.ts @@ -0,0 +1,88 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { get } from 'lodash'; + +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; + +import { CollaboratorService } from 'src/modules/survey/services/collaborator.service'; +import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; + +@Injectable() +export class SurveyGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly collaboratorService: CollaboratorService, + private readonly surveyMetaService: SurveyMetaService, + private readonly workspaceMemberService: WorkspaceMemberService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user; + const surveyIdKey = this.reflector.get( + 'surveyId', + context.getHandler(), + ); + + const surveyId = get(request, surveyIdKey); + + if (!surveyId) { + return true; + } + + const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); + + if (!surveyMeta) { + throw new SurveyNotFoundException('问卷不存在'); + } + + request.surveyMeta = surveyMeta; + + // 兼容老的问卷没有ownerId + if ( + surveyMeta.ownerId === user._id.toString() || + surveyMeta.owner === user.username + ) { + // 问卷的owner,可以访问和操作问卷 + return true; + } + + if (surveyMeta.workspaceId) { + const memberInfo = await this.workspaceMemberService.findOne({ + workspaceId: surveyMeta.workspaceId, + userId: user._id.toString(), + }); + if (!memberInfo) { + throw new NoPermissionException('没有权限'); + } + return true; + } + + const permissions = this.reflector.get( + 'surveyPermission', + context.getHandler(), + ); + + if (!Array.isArray(permissions) || permissions.length === 0) { + throw new NoPermissionException('没有权限'); + } + + const info = await this.collaboratorService.getCollaborator({ + surveyId, + userId: user._id.toString(), + }); + + if (!info) { + throw new NoPermissionException('没有权限'); + } + request.collaborator = info; + if ( + permissions.some((permission) => info.permissions.includes(permission)) + ) { + return true; + } + throw new NoPermissionException('没有权限'); + } +} diff --git a/server/src/guards/workspace.guard.ts b/server/src/guards/workspace.guard.ts new file mode 100644 index 00000000..2167afcd --- /dev/null +++ b/server/src/guards/workspace.guard.ts @@ -0,0 +1,72 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { get } from 'lodash'; + +import { NoPermissionException } from '../exceptions/noPermissionException'; + +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { ROLE_PERMISSION as WORKSPACE_ROLE_PERMISSION } from 'src/enums/workspace'; + +@Injectable() +export class WorkspaceGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly workspaceMemberService: WorkspaceMemberService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const allowPermissions = this.reflector.get( + 'workspacePermissions', + context.getHandler(), + ); + + if (!allowPermissions) { + return true; // 没有定义权限,可以访问 + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + const workspaceIdInfo = this.reflector.get( + 'workspaceId', + context.getHandler(), + ); + + let workspaceIdKey, optional; + if (typeof workspaceIdInfo === 'string') { + workspaceIdKey = workspaceIdInfo; + optional = false; + } else { + workspaceIdKey = workspaceIdInfo?.key; + optional = workspaceIdInfo?.optional || false; + } + + const workspaceId = get(request, workspaceIdKey); + + if (!workspaceId && optional === false) { + throw new NoPermissionException('没有空间权限'); + } + + if (workspaceId) { + const membersInfo = await this.workspaceMemberService.findOne({ + workspaceId, + userId: user._id.toString(), + }); + + if (!membersInfo) { + throw new NoPermissionException('没有空间权限'); + } + + const userPermissions = WORKSPACE_ROLE_PERMISSION[membersInfo.role] || []; + if ( + allowPermissions.some((permission) => + userPermissions.includes(permission), + ) + ) { + return true; + } + throw new NoPermissionException('没有权限'); + } + + return true; + } +} diff --git a/server/src/logger/index.ts b/server/src/logger/index.ts index f9e789d0..f1892b71 100644 --- a/server/src/logger/index.ts +++ b/server/src/logger/index.ts @@ -34,10 +34,10 @@ export class Logger { _log(message, options: { dltag?: string; level: string; req?: Request }) { const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS'); - const level = options.level; - const dltag = options.dltag ? `${options.dltag}||` : ''; - const traceIdStr = options?.req['traceId'] - ? `traceid=${options?.req['traceId']}||` + const level = options?.level; + const dltag = options?.dltag ? `${options.dltag}||` : ''; + const traceIdStr = options?.req?.['traceId'] + ? `traceid=${options?.req?.['traceId']}||` : ''; return log4jsLogger[level]( `[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`, diff --git a/server/src/models/collaborator.entity.ts b/server/src/models/collaborator.entity.ts new file mode 100644 index 00000000..9e17f02b --- /dev/null +++ b/server/src/models/collaborator.entity.ts @@ -0,0 +1,14 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'collaborator' }) +export class Collaborator extends BaseEntity { + @Column() + surveyId: string; + + @Column() + userId: string; + + @Column('jsonb') + permissions: Array; +} diff --git a/server/src/models/surveyMeta.entity.ts b/server/src/models/surveyMeta.entity.ts index 5ea066cd..b7543b9b 100644 --- a/server/src/models/surveyMeta.entity.ts +++ b/server/src/models/surveyMeta.entity.ts @@ -21,9 +21,15 @@ export class SurveyMeta extends BaseEntity { @Column() owner: string; + @Column() + ownerId: string; + @Column() createMethod: string; @Column() createFrom: string; + + @Column() + workspaceId: string; } diff --git a/server/src/models/workspace.entity.ts b/server/src/models/workspace.entity.ts new file mode 100644 index 00000000..a918d607 --- /dev/null +++ b/server/src/models/workspace.entity.ts @@ -0,0 +1,14 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'workspace' }) +export class Workspace extends BaseEntity { + @Column() + ownerId: string; + + @Column() + name: string; + + @Column() + description: string; +} diff --git a/server/src/models/workspaceMember.entity.ts b/server/src/models/workspaceMember.entity.ts new file mode 100644 index 00000000..72a6d186 --- /dev/null +++ b/server/src/models/workspaceMember.entity.ts @@ -0,0 +1,14 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'workspaceMember' }) +export class WorkspaceMember extends BaseEntity { + @Column() + userId: string; + + @Column() + workspaceId: string; + + @Column() + role: string; +} diff --git a/server/src/modules/auth/__test/user.controller.spec.ts b/server/src/modules/auth/__test/user.controller.spec.ts new file mode 100644 index 00000000..28f91511 --- /dev/null +++ b/server/src/modules/auth/__test/user.controller.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserController } from '../controllers/user.controller'; +import { UserService } from '../services/user.service'; +import { GetUserListDto } from '../dto/getUserList.dto'; +import { Authentication } from 'src/guards/authentication.guard'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { User } from 'src/models/user.entity'; + +describe('UserController', () => { + let userController: UserController; + let userService: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + providers: [ + { + provide: UserService, + useValue: { + getUserListByUsername: jest.fn(), + }, + }, + ], + }) + .overrideGuard(Authentication) + .useValue({ canActivate: jest.fn(() => true) }) + .compile(); + + userController = module.get(UserController); + userService = module.get(UserService); + }); + + describe('getUserList', () => { + it('should return a list of users', async () => { + const mockUserList = [ + { _id: '1', username: 'user1' }, + { _id: '2', username: 'user2' }, + ]; + + jest + .spyOn(userService, 'getUserListByUsername') + .mockResolvedValue(mockUserList as unknown as User[]); + + const queryInfo: GetUserListDto = { + username: 'testuser', + pageIndex: 1, + pageSize: 10, + }; + GetUserListDto.validate = jest + .fn() + .mockReturnValue({ value: queryInfo, error: null }); + + const result = await userController.getUserList(queryInfo); + + expect(result).toEqual({ + code: 200, + data: mockUserList.map((item) => ({ + userId: item._id, + username: item.username, + })), + }); + }); + + it('should throw an HttpException if validation fails', async () => { + const queryInfo: GetUserListDto = { + username: 'testuser', + pageIndex: 1, + pageSize: 10, + }; + const validationError = new Error('Validation failed'); + + GetUserListDto.validate = jest + .fn() + .mockReturnValue({ value: null, error: validationError }); + + await expect(userController.getUserList(queryInfo)).rejects.toThrow( + new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR), + ); + }); + }); +}); diff --git a/server/src/modules/auth/__test/user.service.spec.ts b/server/src/modules/auth/__test/user.service.spec.ts index a7e39823..a3bcc058 100644 --- a/server/src/modules/auth/__test/user.service.spec.ts +++ b/server/src/modules/auth/__test/user.service.spec.ts @@ -5,6 +5,7 @@ import { UserService } from '../services/user.service'; import { User } from 'src/models/user.entity'; import { HttpException } from 'src/exceptions/httpException'; import { hash256 } from 'src/utils/hash256'; +import { RECORD_STATUS } from 'src/enums'; describe('UserService', () => { let service: UserService; @@ -135,7 +136,10 @@ describe('UserService', () => { const user = await service.getUserByUsername(username); expect(userRepository.findOne).toHaveBeenCalledWith({ - where: { username: username }, + where: { + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + username: username, + }, }); expect(user).toEqual(userInfo); }); diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts index ffa6e3e8..07d983b2 100644 --- a/server/src/modules/auth/auth.module.ts +++ b/server/src/modules/auth/auth.module.ts @@ -4,6 +4,7 @@ import { AuthService } from './services/auth.service'; import { CaptchaService } from './services/captcha.service'; import { AuthController } from './controllers/auth.controller'; +import { UserController } from './controllers/user.controller'; import { User } from 'src/models/user.entity'; import { Captcha } from 'src/models/captcha.entity'; @@ -13,7 +14,7 @@ import { ConfigModule } from '@nestjs/config'; @Module({ imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule], - controllers: [AuthController], + controllers: [AuthController, UserController], providers: [UserService, AuthService, CaptchaService], exports: [UserService, AuthService], }) diff --git a/server/src/modules/auth/controllers/user.controller.ts b/server/src/modules/auth/controllers/user.controller.ts new file mode 100644 index 00000000..c7e74359 --- /dev/null +++ b/server/src/modules/auth/controllers/user.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Query, HttpCode, UseGuards } from '@nestjs/common'; + +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Authentication } from 'src/guards/authentication.guard'; + +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { HttpException } from 'src/exceptions/httpException'; + +import { UserService } from '../services/user.service'; +import { GetUserListDto } from '../dto/getUserList.dto'; + +@ApiTags('user') +@ApiBearerAuth() +@Controller('/api/user') +export class UserController { + constructor(private readonly userService: UserService) {} + + @UseGuards(Authentication) + @Get('/getUserList') + @HttpCode(200) + async getUserList( + @Query() + queryInfo: GetUserListDto, + ) { + const { value, error } = GetUserListDto.validate(queryInfo); + if (error) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const userList = await this.userService.getUserListByUsername({ + username: value.username, + skip: (value.pageIndex - 1) * value.pageSize, + take: value.pageSize, + }); + + return { + code: 200, + data: userList.map((item) => { + return { + userId: item._id.toString(), + username: item.username, + }; + }), + }; + } +} diff --git a/server/src/modules/auth/dto/getUserList.dto.ts b/server/src/modules/auth/dto/getUserList.dto.ts new file mode 100644 index 00000000..cb7dddb5 --- /dev/null +++ b/server/src/modules/auth/dto/getUserList.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class GetUserListDto { + @ApiProperty({ description: '用户名', required: true }) + username: string; + + @ApiProperty({ description: '页码', required: false, default: 1 }) + pageIndex?: number; + + @ApiProperty({ description: '每页查询数', required: false, default: 10 }) + pageSize: number; + + static validate(data) { + return Joi.object({ + username: Joi.string().required(), + pageIndex: Joi.number().allow(null).default(1), + pageSize: Joi.number().allow(null).default(10), + }).validate(data); + } +} diff --git a/server/src/modules/auth/services/user.service.ts b/server/src/modules/auth/services/user.service.ts index 74554076..03e4f497 100644 --- a/server/src/modules/auth/services/user.service.ts +++ b/server/src/modules/auth/services/user.service.ts @@ -5,6 +5,8 @@ import { User } from 'src/models/user.entity'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { hash256 } from 'src/utils/hash256'; +import { RECORD_STATUS } from 'src/enums'; +import { ObjectId } from 'mongodb'; @Injectable() export class UserService { @@ -51,9 +53,55 @@ export class UserService { const user = await this.userRepository.findOne({ where: { username: username, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, }, }); return user; } + + async getUserById(id: string) { + const user = await this.userRepository.findOne({ + where: { + _id: new ObjectId(id), + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + }); + + return user; + } + + async getUserListByUsername({ username, skip, take }) { + const list = await this.userRepository.find({ + where: { + username: new RegExp(username), + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + skip, + take, + select: ['_id', 'username', 'createDate'], + }); + return list; + } + + async getUserListByIds({ idList }) { + const list = await this.userRepository.find({ + where: { + _id: { + $in: idList.map((item) => new ObjectId(item)), + }, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + select: ['_id', 'username', 'createDate'], + }); + return list; + } } diff --git a/server/src/modules/file/controllers/file.controller.ts b/server/src/modules/file/controllers/file.controller.ts index 6518bb32..71d9be6f 100644 --- a/server/src/modules/file/controllers/file.controller.ts +++ b/server/src/modules/file/controllers/file.controller.ts @@ -8,14 +8,16 @@ import { Body, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; +import { ConfigService } from '@nestjs/config'; +import { ApiTags } from '@nestjs/swagger'; import { FileService } from '../services/file.service'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { AuthService } from 'src/modules/auth/services/auth.service'; import { AuthenticationException } from 'src/exceptions/authException'; -import { ConfigService } from '@nestjs/config'; +@ApiTags('file') @Controller('/api/file') export class FileController { constructor( diff --git a/server/src/modules/message/__test/messagePushingTask.controller.spec.ts b/server/src/modules/message/__test/messagePushingTask.controller.spec.ts index 49afdaa3..3ce1bc46 100644 --- a/server/src/modules/message/__test/messagePushingTask.controller.spec.ts +++ b/server/src/modules/message/__test/messagePushingTask.controller.spec.ts @@ -10,7 +10,7 @@ import { MESSAGE_PUSHING_TYPE, } from 'src/enums/messagePushing'; import { MessagePushingTask } from 'src/models/messagePushingTask.entity'; -import { Authtication } from 'src/guards/authtication'; +import { Authentication } from 'src/guards/authentication.guard'; import { UserService } from 'src/modules/auth/services/user.service'; import { UpdateMessagePushingTaskDto } from '../dto/updateMessagePushingTask.dto'; @@ -38,7 +38,7 @@ describe('MessagePushingTaskController', () => { }, }, { - provide: Authtication, + provide: Authentication, useClass: jest.fn().mockImplementation(() => ({ canActivate: () => true, })), diff --git a/server/src/modules/message/controllers/messagePushingTask.controller.ts b/server/src/modules/message/controllers/messagePushingTask.controller.ts index f1b4066c..a3938b69 100644 --- a/server/src/modules/message/controllers/messagePushingTask.controller.ts +++ b/server/src/modules/message/controllers/messagePushingTask.controller.ts @@ -25,9 +25,9 @@ import { QueryMessagePushingTaskListDto } from '../dto/queryMessagePushingTaskLi import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -import { Authtication } from 'src/guards/authtication'; +import { Authentication } from 'src/guards/authentication.guard'; -@UseGuards(Authtication) +@UseGuards(Authentication) @ApiBearerAuth() @ApiTags('messagePushingTasks') @Controller('/api/messagePushingTasks') @@ -47,12 +47,10 @@ export class MessagePushingTaskController { req, @Body() createMessagePushingTaskDto: CreateMessagePushingTaskDto, ) { - let data; - try { - data = await CreateMessagePushingTaskDto.validate( - createMessagePushingTaskDto, - ); - } catch (error) { + const { error, value } = CreateMessagePushingTaskDto.validate( + createMessagePushingTaskDto, + ); + if (error) { throw new HttpException( `参数错误: ${error.message}`, EXCEPTION_CODE.PARAMETER_ERROR, @@ -61,7 +59,7 @@ export class MessagePushingTaskController { const userId = req.user._id; const messagePushingTask = await this.messagePushingTaskService.create({ - ...data, + ...value, ownerId: userId, }); return { @@ -83,10 +81,8 @@ export class MessagePushingTaskController { req, @Query() query: QueryMessagePushingTaskListDto, ) { - let data; - try { - data = await QueryMessagePushingTaskListDto.validate(query); - } catch (error) { + const { error, value } = QueryMessagePushingTaskListDto.validate(query); + if (error) { throw new HttpException( `参数错误: ${error.message}`, EXCEPTION_CODE.PARAMETER_ERROR, @@ -94,8 +90,8 @@ export class MessagePushingTaskController { } const userId = req.user._id; const list = await this.messagePushingTaskService.findAll({ - surveyId: data.surveyId, - hook: data.triggerHook, + surveyId: value.surveyId, + hook: value.triggerHook, ownerId: userId, }); return { diff --git a/server/src/modules/message/dto/createMessagePushingTask.dto.ts b/server/src/modules/message/dto/createMessagePushingTask.dto.ts index 685c57ba..8e24ff9e 100644 --- a/server/src/modules/message/dto/createMessagePushingTask.dto.ts +++ b/server/src/modules/message/dto/createMessagePushingTask.dto.ts @@ -29,8 +29,8 @@ export class CreateMessagePushingTaskDto { }) surveys?: string[]; - static async validate(data) { - return await Joi.object({ + static validate(data) { + return Joi.object({ name: Joi.string().required(), type: Joi.string().allow(null).default(MESSAGE_PUSHING_TYPE.HTTP), pushAddress: Joi.string().required(), @@ -38,6 +38,6 @@ export class CreateMessagePushingTaskDto { .allow(null) .default(MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED), surveys: Joi.array().items(Joi.string()).allow(null).default([]), - }).validateAsync(data); + }).validate(data); } } diff --git a/server/src/modules/message/dto/queryMessagePushingTaskList.dto.ts b/server/src/modules/message/dto/queryMessagePushingTaskList.dto.ts index b25932ed..1d84f4d0 100644 --- a/server/src/modules/message/dto/queryMessagePushingTaskList.dto.ts +++ b/server/src/modules/message/dto/queryMessagePushingTaskList.dto.ts @@ -13,6 +13,6 @@ export class QueryMessagePushingTaskListDto { return Joi.object({ surveyId: Joi.string().required(), triggerHook: Joi.string().required(), - }).validateAsync(data); + }).validate(data); } } diff --git a/server/src/modules/survey/__test/collaborator.controller.spec.ts b/server/src/modules/survey/__test/collaborator.controller.spec.ts new file mode 100644 index 00000000..79819401 --- /dev/null +++ b/server/src/modules/survey/__test/collaborator.controller.spec.ts @@ -0,0 +1,220 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CollaboratorController } from '../controllers/collaborator.controller'; +import { CollaboratorService } from '../services/collaborator.service'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { CreateCollaboratorDto } from '../dto/createCollaborator.dto'; +import { Collaborator } from 'src/models/collaborator.entity'; +import { GetSurveyCollaboratorListDto } from '../dto/getSurveyCollaboratorList.dto'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { ObjectId } from 'mongodb'; +import { SurveyMetaService } from '../services/surveyMeta.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); + +describe('CollaboratorController', () => { + let controller: CollaboratorController; + let collaboratorService: CollaboratorService; + let logger: Logger; + let userService: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CollaboratorController], + providers: [ + { + provide: CollaboratorService, + useValue: { + create: jest.fn(), + getSurveyCollaboratorList: jest.fn(), + changeUserPermission: jest.fn(), + deleteCollaborator: jest.fn(), + getCollaborator: jest.fn(), + }, + }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, + { + provide: UserService, + useValue: { + getUserById: jest.fn().mockImplementation((id) => { + return Promise.resolve({ + _id: new ObjectId(id), + }); + }), + getUserListByIds: jest.fn(), + }, + }, + { + provide: SurveyMetaService, + useValue: { + getSurveyById: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + findOne: jest.fn().mockResolvedValue(null), + }, + }, + ], + }).compile(); + + controller = module.get(CollaboratorController); + collaboratorService = module.get(CollaboratorService); + logger = module.get(Logger); + userService = module.get(UserService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('addCollaborator', () => { + it('should add a collaborator successfully', async () => { + const userId = new ObjectId().toString(); + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: new ObjectId().toString(), + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { user: { _id: 'userId' }, surveyMeta: { ownerId: userId } }; + const result = { _id: 'collaboratorId' }; + + jest + .spyOn(collaboratorService, 'create') + .mockResolvedValue(result as unknown as Collaborator); + + const response = await controller.addCollaborator(reqBody, req); + + expect(response).toEqual({ + code: 200, + data: { + collaboratorId: result._id, + }, + }); + }); + + it('should throw an exception if validation fails', async () => { + const reqBody: CreateCollaboratorDto = { + surveyId: '', + userId: '', + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { user: { _id: 'userId' } }; + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('getSurveyCollaboratorList', () => { + it('should return collaborator list', async () => { + const query = { surveyId: 'surveyId' }; + const req = { user: { _id: 'userId' } }; + const result = [ + { _id: 'collaboratorId', userId: 'userId', username: '' }, + ]; + + jest + .spyOn(collaboratorService, 'getSurveyCollaboratorList') + .mockResolvedValue(result as unknown as Array); + + jest.spyOn(userService, 'getUserListByIds').mockResolvedValueOnce([]); + + const response = await controller.getSurveyCollaboratorList(query, req); + + expect(response).toEqual({ + code: 200, + data: result, + }); + }); + + it('should throw an exception if validation fails', async () => { + const query: GetSurveyCollaboratorListDto = { + surveyId: '', + }; + const req = { user: { _id: 'userId' } }; + + await expect( + controller.getSurveyCollaboratorList(query, req), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('changeUserPermission', () => { + it('should change user permission successfully', async () => { + const reqBody = { + surveyId: 'surveyId', + userId: 'userId', + permissions: ['read'], + }; + const req = { user: { _id: 'userId' } }; + const result = { _id: 'userId', permissions: ['read'] }; + + jest + .spyOn(collaboratorService, 'changeUserPermission') + .mockResolvedValue(result); + + const response = await controller.changeUserPermission(reqBody, req); + + expect(response).toEqual({ + code: 200, + data: result, + }); + }); + + it('should throw an exception if validation fails', async () => { + const reqBody = { + surveyId: '', + userId: '', + permissions: ['surveyManage'], + }; + const req = { user: { _id: 'userId' } }; + + await expect( + controller.changeUserPermission(reqBody, req), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('deleteCollaborator', () => { + it('should delete collaborator successfully', async () => { + const query = { surveyId: 'surveyId', userId: 'userId' }; + const req = { user: { _id: 'userId' } }; + const result = { acknowledged: true, deletedCount: 1 }; + + jest + .spyOn(collaboratorService, 'deleteCollaborator') + .mockResolvedValue(result); + + const response = await controller.deleteCollaborator(query, req); + + expect(response).toEqual({ + code: 200, + data: result, + }); + }); + + it('should throw an exception if validation fails', async () => { + const query = { surveyId: '', userId: '' }; + const req = { user: { _id: 'userId' } }; + + await expect(controller.deleteCollaborator(query, req)).rejects.toThrow( + HttpException, + ); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/server/src/modules/survey/__test/collaborator.service.spec.ts b/server/src/modules/survey/__test/collaborator.service.spec.ts new file mode 100644 index 00000000..34a1157e --- /dev/null +++ b/server/src/modules/survey/__test/collaborator.service.spec.ts @@ -0,0 +1,331 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CollaboratorService } from '../services/collaborator.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Collaborator } from 'src/models/collaborator.entity'; +import { MongoRepository } from 'typeorm'; +import { Logger } from 'src/logger'; +import { InsertManyResult, ObjectId } from 'mongodb'; + +describe('CollaboratorService', () => { + let service: CollaboratorService; + let repository: MongoRepository; + let logger: Logger; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CollaboratorService, + { + provide: getRepositoryToken(Collaborator), + useClass: MongoRepository, + }, + { + provide: Logger, + useValue: { + info: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(CollaboratorService); + repository = module.get>( + getRepositoryToken(Collaborator), + ); + logger = module.get(Logger); + }); + + describe('create', () => { + it('should create and save a collaborator', async () => { + const createSpy = jest.spyOn(repository, 'create').mockReturnValue({ + surveyId: '1', + userId: '1', + permissions: [], + } as Collaborator); + const collaboratorId = new ObjectId().toString(); + const saveSpy = jest.spyOn(repository, 'save').mockResolvedValue({ + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + } as Collaborator); + + const result = await service.create({ + surveyId: '1', + userId: '1', + permissions: [], + }); + + expect(createSpy).toHaveBeenCalledWith({ + surveyId: '1', + userId: '1', + permissions: [], + }); + expect(saveSpy).toHaveBeenCalledWith({ + surveyId: '1', + userId: '1', + permissions: [], + }); + expect(result).toEqual({ + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + }); + }); + }); + + describe('batchCreate', () => { + it('should batch create collaborators', async () => { + const insertManySpy = jest + .spyOn(repository, 'insertMany') + .mockResolvedValue({ + insertedCount: 1, + } as unknown as InsertManyResult); + + const result = await service.batchCreate({ + surveyId: '1', + collaboratorList: [{ userId: '1', permissions: [] }], + }); + + expect(insertManySpy).toHaveBeenCalledWith([ + { surveyId: '1', userId: '1', permissions: [] }, + ]); + expect(result).toEqual({ insertedCount: 1 }); + }); + }); + + describe('getSurveyCollaboratorList', () => { + it('should return a list of collaborators for a survey', async () => { + const collaboratorId = new ObjectId().toString(); + const findSpy = jest.spyOn(repository, 'find').mockResolvedValue([ + { + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + }, + ] as Collaborator[]); + + const result = await service.getSurveyCollaboratorList({ surveyId: '1' }); + + expect(findSpy).toHaveBeenCalledWith({ surveyId: '1' }); + expect(result).toEqual([ + { + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + }, + ]); + }); + }); + + describe('getCollaboratorListByIds', () => { + it('should return a list of collaborators by ids', async () => { + const collaboratorId = new ObjectId().toString(); + const findSpy = jest.spyOn(repository, 'find').mockResolvedValue([ + { + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + }, + ] as Collaborator[]); + + const result = await service.getCollaboratorListByIds({ + idList: [collaboratorId], + }); + + expect(findSpy).toHaveBeenCalledWith({ + _id: { + $in: [new ObjectId(collaboratorId)], + }, + }); + expect(result).toEqual([ + { + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + }, + ]); + }); + }); + + describe('getCollaborator', () => { + it('should return a collaborator', async () => { + const collaboratorId = new ObjectId().toString(); + const findOneSpy = jest.spyOn(repository, 'findOne').mockResolvedValue({ + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + } as Collaborator); + + const result = await service.getCollaborator({ + userId: '1', + surveyId: '1', + }); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { + surveyId: '1', + userId: '1', + }, + }); + expect(result).toEqual({ + _id: new ObjectId(collaboratorId), + surveyId: '1', + userId: '1', + permissions: [], + }); + }); + }); + + describe('changeUserPermission', () => { + it("should update a user's permissions", async () => { + const updateOneSpy = jest + .spyOn(repository, 'updateOne') + .mockResolvedValue({}); + + const result = await service.changeUserPermission({ + userId: '1', + surveyId: '1', + permission: 'read', + }); + + expect(updateOneSpy).toHaveBeenCalledWith( + { + surveyId: '1', + userId: '1', + }, + { + $set: { + permission: 'read', + }, + }, + ); + expect(result).toEqual({}); + }); + }); + + describe('deleteCollaborator', () => { + it('should delete a collaborator', async () => { + const mockResult = { acknowledged: true, deletedCount: 1 }; + const deleteOneSpy = jest + .spyOn(repository, 'deleteOne') + .mockResolvedValue(mockResult); + + const result = await service.deleteCollaborator({ + userId: '1', + surveyId: '1', + }); + + expect(deleteOneSpy).toHaveBeenCalledWith({ + userId: '1', + surveyId: '1', + }); + expect(result).toEqual(mockResult); + }); + }); + + describe('batchDelete', () => { + it('should batch delete collaborators', async () => { + const mockResult = { acknowledged: true, deletedCount: 1 }; + const deleteManySpy = jest + .spyOn(repository, 'deleteMany') + .mockResolvedValue(mockResult); + + const collaboratorId = new ObjectId().toString(); + + const result = await service.batchDelete({ + surveyId: '1', + idList: [collaboratorId], + }); + + const expectedQuery = { + surveyId: '1', + $or: [ + { + _id: { + $in: [new ObjectId(collaboratorId)], + }, + }, + ], + }; + + expect(logger.info).toHaveBeenCalledWith(JSON.stringify(expectedQuery)); + expect(deleteManySpy).toHaveBeenCalledWith(expectedQuery); + expect(result).toEqual(mockResult); + }); + }); + + describe('batchDeleteBySurveyId', () => { + it('should batch delete collaborators by survey id', async () => { + const mockResult = { acknowledged: true, deletedCount: 1 }; + const deleteManySpy = jest + .spyOn(repository, 'deleteMany') + .mockResolvedValue(mockResult); + + const surveyId = new ObjectId().toString(); + + const result = await service.batchDeleteBySurveyId(surveyId); + + expect(deleteManySpy).toHaveBeenCalledWith({ + surveyId, + }); + expect(result).toEqual(mockResult); + }); + }); + + describe('updateById', () => { + it('should update collaborator by id', async () => { + const updateOneSpy = jest + .spyOn(repository, 'updateOne') + .mockResolvedValue({}); + const collaboratorId = new ObjectId().toString(); + const result = await service.updateById({ + collaboratorId, + permissions: [], + }); + + expect(updateOneSpy).toHaveBeenCalledWith( + { + _id: new ObjectId(collaboratorId), + }, + { + $set: { + permissions: [], + }, + }, + ); + expect(result).toEqual({}); + }); + }); + + describe('getCollaboratorListByUserId', () => { + it('should return a list of collaborators by user id', async () => { + const userId = new ObjectId().toString(); + const findSpy = jest.spyOn(repository, 'find').mockResolvedValue([ + { + _id: '1', + surveyId: '1', + userId: userId, + permissions: [], + } as unknown as Collaborator, + ]); + + const result = await service.getCollaboratorListByUserId({ userId }); + + expect(findSpy).toHaveBeenCalledWith({ + where: { + userId, + }, + }); + expect(result).toEqual([ + { _id: '1', surveyId: '1', userId, permissions: [] }, + ]); + }); + }); +}); diff --git a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts index 5cd3247d..a3222029 100644 --- a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts @@ -9,7 +9,8 @@ import { ResponseSchemaService } from '../../surveyResponse/services/responseSch import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; -import { Authtication } from 'src/guards/authtication'; +import { Logger } from 'src/logger'; + import { UserService } from 'src/modules/auth/services/user.service'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; import { AuthService } from 'src/modules/auth/services/auth.service'; @@ -18,10 +19,13 @@ jest.mock('../services/dataStatistic.service'); jest.mock('../services/surveyMeta.service'); jest.mock('../../surveyResponse/services/responseScheme.service'); +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); + describe('DataStatisticController', () => { let controller: DataStatisticController; let dataStatisticService: DataStatisticService; - let surveyMetaService: SurveyMetaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -32,12 +36,6 @@ describe('DataStatisticController', () => { ResponseSchemaService, PluginManagerProvider, ConfigService, - { - provide: Authtication, - useClass: jest.fn().mockImplementation(() => ({ - canActivate: () => true, - })), - }, { provide: UserService, useClass: jest.fn().mockImplementation(() => ({ @@ -54,13 +52,18 @@ describe('DataStatisticController', () => { }, })), }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, ], }).compile(); controller = module.get(DataStatisticController); dataStatisticService = module.get(DataStatisticService); - surveyMetaService = module.get(SurveyMetaService); const pluginManager = module.get( XiaojuSurveyPluginManager, ); @@ -101,9 +104,6 @@ describe('DataStatisticController', () => { ], }; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValueOnce(undefined); jest .spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') .mockResolvedValueOnce({} as any); @@ -111,7 +111,7 @@ describe('DataStatisticController', () => { .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, mockRequest); + const result = await controller.data(mockRequest.query, {}); expect(result).toEqual({ code: 200, @@ -146,10 +146,6 @@ describe('DataStatisticController', () => { { difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, ], }; - - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValueOnce(undefined); jest .spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') .mockResolvedValueOnce({} as any); @@ -157,7 +153,7 @@ describe('DataStatisticController', () => { .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, mockRequest); + const result = await controller.data(mockRequest.query, {}); expect(result).toEqual({ code: 200, diff --git a/server/src/modules/survey/__test/survey.controller.spec.ts b/server/src/modules/survey/__test/survey.controller.spec.ts index 80366955..a23f56d8 100644 --- a/server/src/modules/survey/__test/survey.controller.spec.ts +++ b/server/src/modules/survey/__test/survey.controller.spec.ts @@ -18,7 +18,9 @@ jest.mock('../../surveyResponse/services/responseScheme.service'); jest.mock('../services/contentSecurity.service'); jest.mock('../services/surveyHistory.service'); -jest.mock('src/guards/authtication'); +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); describe('SurveyController', () => { let controller: SurveyController; @@ -98,7 +100,7 @@ describe('SurveyController', () => { ); const result = await controller.createSurvey(surveyInfo, { - user: { username: 'testUser' }, + user: { username: 'testUser', _id: new ObjectId() }, }); expect(result).toEqual({ @@ -123,9 +125,6 @@ describe('SurveyController', () => { createMethod: 'copy', createFrom: existsSurveyId.toString(), }; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValue(Promise.resolve(existsSurveyMeta)); jest .spyOn(surveyMetaService, 'createSurveyMeta') @@ -136,7 +135,10 @@ describe('SurveyController', () => { return Promise.resolve(result); }); - const request = { user: { username: 'testUser' } }; // 模拟请求对象,根据实际情况进行调整 + const request = { + user: { username: 'testUser', _id: new ObjectId() }, + surveyMeta: existsSurveyMeta, + }; // 模拟请求对象,根据实际情况进行调整 const result = await controller.createSurvey(params, request); expect(result?.data?.id).toBeDefined(); }); @@ -151,9 +153,6 @@ describe('SurveyController', () => { owner: 'testUser', } as SurveyMeta; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValue(Promise.resolve(surveyMeta)); jest .spyOn(surveyConfService, 'saveSurveyConf') .mockResolvedValue(undefined); @@ -183,6 +182,7 @@ describe('SurveyController', () => { const result = await controller.updateConf(reqBody, { user: { username: 'testUser', _id: 'testUserId' }, + surveyMeta, }); expect(result).toEqual({ @@ -200,9 +200,6 @@ describe('SurveyController', () => { owner: 'testUser', } as SurveyMeta; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValue(Promise.resolve(surveyMeta)); jest .spyOn(surveyMetaService, 'deleteSurveyMeta') .mockResolvedValue(undefined); @@ -210,10 +207,10 @@ describe('SurveyController', () => { .spyOn(responseSchemaService, 'deleteResponseSchema') .mockResolvedValue(undefined); - const result = await controller.deleteSurvey( - { surveyId: surveyId.toString() }, - { user: { username: 'testUser' } }, - ); + const result = await controller.deleteSurvey({ + user: { username: 'testUser' }, + surveyMeta, + }); expect(result).toEqual({ code: 200, @@ -230,10 +227,6 @@ describe('SurveyController', () => { owner: 'testUser', } as SurveyMeta; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValue(Promise.resolve(surveyMeta)); - jest .spyOn(surveyConfService, 'getSurveyConfBySurveyId') .mockResolvedValue( @@ -243,7 +236,10 @@ describe('SurveyController', () => { } as SurveyConf), ); - const request = { user: { username: 'testUser' } }; + const request = { + user: { username: 'testUser', _id: new ObjectId() }, + surveyMeta, + }; const result = await controller.getSurvey( { surveyId: surveyId.toString() }, request, @@ -262,10 +258,6 @@ describe('SurveyController', () => { owner: 'testUser', } as SurveyMeta; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValue(Promise.resolve(surveyMeta)); - jest .spyOn(surveyConfService, 'getSurveyConfBySurveyId') .mockResolvedValue( @@ -296,7 +288,7 @@ describe('SurveyController', () => { const result = await controller.publishSurvey( { surveyId: surveyId.toString() }, - { user: { username: 'testUser', _id: 'testUserId' } }, + { user: { username: 'testUser', _id: 'testUserId' }, surveyMeta }, ); expect(result).toEqual({ @@ -312,10 +304,6 @@ describe('SurveyController', () => { owner: 'testUser', } as SurveyMeta; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockResolvedValue(Promise.resolve(surveyMeta)); - jest .spyOn(surveyConfService, 'getSurveyConfBySurveyId') .mockResolvedValue( @@ -338,7 +326,7 @@ describe('SurveyController', () => { await expect( controller.publishSurvey( { surveyId: surveyId.toString() }, - { user: { username: 'testUser', _id: 'testUserId' } }, + { user: { username: 'testUser', _id: 'testUserId' }, surveyMeta }, ), ).rejects.toThrow( new HttpException( diff --git a/server/src/modules/survey/__test/surveyHistory.controller.spec.ts b/server/src/modules/survey/__test/surveyHistory.controller.spec.ts index 023385e6..cffd5a76 100644 --- a/server/src/modules/survey/__test/surveyHistory.controller.spec.ts +++ b/server/src/modules/survey/__test/surveyHistory.controller.spec.ts @@ -6,13 +6,16 @@ import { SurveyHistoryService } from '../services/surveyHistory.service'; import { SurveyMetaService } from '../services/surveyMeta.service'; import { UserService } from 'src/modules/auth/services/user.service'; -import { Authtication } from 'src/guards/authtication'; import { AuthService } from 'src/modules/auth/services/auth.service'; +import { Logger } from 'src/logger'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); describe('SurveyHistoryController', () => { let controller: SurveyHistoryController; let surveyHistoryService: SurveyHistoryService; - let surveyMetaService: SurveyMetaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -25,18 +28,6 @@ describe('SurveyHistoryController', () => { getHistoryList: jest.fn().mockResolvedValue('mockHistoryList'), })), }, - { - provide: SurveyMetaService, - useClass: jest.fn().mockImplementation(() => ({ - checkSurveyAccess: jest.fn().mockResolvedValue({}), - })), - }, - { - provide: Authtication, - useClass: jest.fn().mockImplementation(() => ({ - canActivate: () => true, - })), - }, { provide: UserService, useClass: jest.fn().mockImplementation(() => ({ @@ -53,25 +44,29 @@ describe('SurveyHistoryController', () => { }, })), }, + { + provide: SurveyMetaService, + useClass: jest.fn().mockImplementation(() => ({})), + }, + { + provide: Logger, + useValue: { + info: jest.fn(), + error: jest.fn(), + }, + }, ], }).compile(); controller = module.get(SurveyHistoryController); surveyHistoryService = module.get(SurveyHistoryService); - surveyMetaService = module.get(SurveyMetaService); }); it('should return history list when query is valid', async () => { - const req = { user: { username: 'testUser' } }; const queryInfo = { surveyId: 'survey123', historyType: 'published' }; - await controller.getList(queryInfo, req); - - expect(surveyMetaService.checkSurveyAccess).toHaveBeenCalledWith({ - surveyId: queryInfo.surveyId, - username: req.user.username, - }); + await controller.getList(queryInfo, {}); expect(surveyHistoryService.getHistoryList).toHaveBeenCalledWith({ surveyId: queryInfo.surveyId, @@ -79,6 +74,5 @@ describe('SurveyHistoryController', () => { }); expect(surveyHistoryService.getHistoryList).toHaveBeenCalledTimes(1); - expect(surveyMetaService.checkSurveyAccess).toHaveBeenCalledTimes(1); }); }); diff --git a/server/src/modules/survey/__test/surveyMeta.controller.spec.ts b/server/src/modules/survey/__test/surveyMeta.controller.spec.ts index 3773eaa4..b98236d0 100644 --- a/server/src/modules/survey/__test/surveyMeta.controller.spec.ts +++ b/server/src/modules/survey/__test/surveyMeta.controller.spec.ts @@ -1,11 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SurveyMetaController } from '../controllers/surveyMeta.controller'; import { SurveyMetaService } from '../services/surveyMeta.service'; -import { Authtication } from 'src/guards/authtication'; -import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { LoggerProvider } from 'src/logger/logger.provider'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { CollaboratorService } from '../services/collaborator.service'; +import { ObjectId } from 'mongodb'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); describe('SurveyMetaController', () => { let controller: SurveyMetaController; @@ -18,7 +22,6 @@ describe('SurveyMetaController', () => { { provide: SurveyMetaService, useValue: { - checkSurveyAccess: jest.fn().mockResolvedValue({}), editSurveyMeta: jest.fn().mockResolvedValue(undefined), getSurveyMetaList: jest .fn() @@ -26,13 +29,14 @@ describe('SurveyMetaController', () => { }, }, LoggerProvider, + { + provide: CollaboratorService, + useValue: { + getCollaboratorListByUserId: jest.fn().mockResolvedValue([]), + }, + }, ], - }) - .overrideGuard(Authtication) - .useValue({ - canActivate: () => true, - }) - .compile(); + }).compile(); controller = module.get(SurveyMetaController); surveyMetaService = module.get(SurveyMetaService); @@ -44,30 +48,21 @@ describe('SurveyMetaController', () => { title: 'Test title', surveyId: 'test-survey-id', }; - const req = { - user: { - username: 'test-user', - }, - }; const survey = { title: '', remark: '', }; - jest - .spyOn(surveyMetaService, 'checkSurveyAccess') - .mockImplementation(() => { - return Promise.resolve(survey) as Promise; - }); + const req = { + user: { + username: 'test-user', + }, + surveyMeta: survey, + }; const result = await controller.updateMeta(reqBody, req); - expect(surveyMetaService.checkSurveyAccess).toHaveBeenCalledWith({ - surveyId: reqBody.surveyId, - username: req.user.username, - }); - expect(surveyMetaService.editSurveyMeta).toHaveBeenCalledWith({ title: reqBody.title, remark: reqBody.remark, @@ -91,7 +86,6 @@ describe('SurveyMetaController', () => { expect(error.code).toBe(EXCEPTION_CODE.PARAMETER_ERROR); } - expect(surveyMetaService.checkSurveyAccess).not.toHaveBeenCalled(); expect(surveyMetaService.editSurveyMeta).not.toHaveBeenCalled(); }); @@ -100,65 +94,66 @@ describe('SurveyMetaController', () => { curPage: 1, pageSize: 10, }; + const userId = new ObjectId().toString(); const req = { user: { username: 'test-user', + _id: new ObjectId(userId), }, }; - try { - jest - .spyOn(surveyMetaService, 'getSurveyMetaList') - .mockImplementation(() => { - const date = new Date().getTime(); - return Promise.resolve({ - count: 10, - data: [ - { - id: '1', - createDate: date, - updateDate: date, - curStatus: { - date: date, - }, - }, - ], - }); - }); - - const result = await controller.getList(queryInfo, req); - - expect(result).toEqual({ - code: 200, - data: { + jest + .spyOn(surveyMetaService, 'getSurveyMetaList') + .mockImplementation(() => { + const date = new Date().getTime(); + return Promise.resolve({ count: 10, - data: expect.arrayContaining([ - expect.objectContaining({ - createDate: expect.stringMatching( + data: [ + { + _id: new ObjectId(), + createDate: date, + updateDate: date, + curStatus: { + date: date, + }, + }, + ], + }); + }); + + const result = await controller.getList(queryInfo, req); + + expect(result).toEqual({ + code: 200, + data: { + count: 10, + data: expect.arrayContaining([ + expect.objectContaining({ + createDate: expect.stringMatching( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, + ), + updateDate: expect.stringMatching( + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, + ), + curStatus: expect.objectContaining({ + date: expect.stringMatching( /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, ), - updateDate: expect.stringMatching( - /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, - ), - curStatus: expect.objectContaining({ - date: expect.stringMatching( - /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, - ), - }), }), - ]), - }, - }); - expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({ - pageNum: queryInfo.curPage, - pageSize: queryInfo.pageSize, - username: req.user.username, - filter: {}, - order: {}, - }); - } catch (error) { - console.log(error); - } + }), + ]), + }, + }); + expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({ + pageNum: queryInfo.curPage, + pageSize: queryInfo.pageSize, + username: req.user.username, + filter: {}, + order: {}, + surveyIdList: [], + userId, + workspaceId: undefined, + }); }); it('should get survey meta list with filter and order', async () => { @@ -177,25 +172,26 @@ describe('SurveyMetaController', () => { ]), order: JSON.stringify([{ field: 'createDate', value: -1 }]), }; + const userId = new ObjectId().toString(); const req = { user: { username: 'test-user', + _id: new ObjectId(userId), }, }; - try { - const result = await controller.getList(queryInfo, req); + const result = await controller.getList(queryInfo, req); - expect(result.code).toEqual(200); - expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({ - pageNum: queryInfo.curPage, - pageSize: queryInfo.pageSize, - username: req.user.username, - filter: { surveyType: 'normal', title: { $regex: 'hahah' } }, - order: { createDate: -1 }, - }); - } catch (error) { - console.log(error); - } + expect(result.code).toEqual(200); + expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({ + pageNum: queryInfo.curPage, + pageSize: queryInfo.pageSize, + username: req.user.username, + surveyIdList: [], + userId, + filter: { surveyType: 'normal', title: { $regex: 'hahah' } }, + order: { createDate: -1 }, + workspaceId: undefined, + }); }); }); diff --git a/server/src/modules/survey/__test/surveyMeta.service.spec.ts b/server/src/modules/survey/__test/surveyMeta.service.spec.ts index 7cdc674f..f05502e7 100644 --- a/server/src/modules/survey/__test/surveyMeta.service.spec.ts +++ b/server/src/modules/survey/__test/surveyMeta.service.spec.ts @@ -2,15 +2,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SurveyMetaService } from '../services/surveyMeta.service'; import { MongoRepository } from 'typeorm'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; -import { ObjectId } from 'mongodb'; import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; -import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; -import { NoSurveyPermissionException } from 'src/exceptions/noSurveyPermissionException'; import { RECORD_STATUS } from 'src/enums'; import { getRepositoryToken } from '@nestjs/typeorm'; import { HttpException } from 'src/exceptions/httpException'; import { SurveyUtilPlugin } from 'src/securityPlugin/surveyUtilPlugin'; +import { ObjectId } from 'mongodb'; describe('SurveyMetaService', () => { let service: SurveyMetaService; @@ -57,52 +55,6 @@ describe('SurveyMetaService', () => { }); }); - describe('checkSurveyAccess', () => { - it('should return survey when user has access', async () => { - const surveyId = new ObjectId().toHexString(); - const username = 'testUser'; - const survey = { owner: username } as SurveyMeta; - jest.spyOn(surveyRepository, 'findOne').mockResolvedValue(survey); - - const result = await service.checkSurveyAccess({ surveyId, username }); - - expect(result).toBe(survey); - expect(surveyRepository.findOne).toHaveBeenCalledWith({ - where: { _id: new ObjectId(surveyId) }, - }); - }); - - it('should throw SurveyNotFoundException when survey not found', async () => { - const surveyId = new ObjectId().toHexString(); - const username = 'testUser'; - jest.spyOn(surveyRepository, 'findOne').mockResolvedValue(null); - - await expect( - service.checkSurveyAccess({ surveyId, username }), - ).rejects.toThrow(SurveyNotFoundException); - - expect(surveyRepository.findOne).toHaveBeenCalledWith({ - where: { _id: new ObjectId(surveyId) }, - }); - }); - - it('should throw NoSurveyPermissionException when user has no access', async () => { - const surveyId = new ObjectId().toHexString(); - const username = 'testUser'; - const surveyOwner = 'otherUser'; - const survey = { owner: surveyOwner } as SurveyMeta; - jest.spyOn(surveyRepository, 'findOne').mockResolvedValue(survey); - - await expect( - service.checkSurveyAccess({ surveyId, username }), - ).rejects.toThrow(NoSurveyPermissionException); - - expect(surveyRepository.findOne).toHaveBeenCalledWith({ - where: { _id: new ObjectId(surveyId) }, - }); - }); - }); - describe('createSurveyMeta', () => { it('should create a new survey meta and return it', async () => { const params = { @@ -110,6 +62,7 @@ describe('SurveyMetaService', () => { remark: 'This is a test survey', surveyType: 'normal', username: 'testUser', + userId: new ObjectId().toString(), createMethod: '', createFrom: '', }; @@ -133,6 +86,7 @@ describe('SurveyMetaService', () => { surveyType: params.surveyType, surveyPath: mockedSurveyPath, creator: params.username, + ownerId: params.userId, owner: params.username, createMethod: params.createMethod, createFrom: params.createFrom, @@ -213,6 +167,7 @@ describe('SurveyMetaService', () => { const condition = { pageNum: 1, pageSize: 10, + userId: 'testUserId', username: 'testUser', filter: {}, order: {}, @@ -222,15 +177,7 @@ describe('SurveyMetaService', () => { // 验证返回值 expect(result).toEqual({ data: mockData, count: mockCount }); // 验证repository方法被正确调用 - expect(surveyRepository.findAndCount).toHaveBeenCalledWith({ - where: { - owner: 'testUser', - 'curStatus.status': { $ne: 'removed' }, - }, - skip: 0, - take: 10, - order: { createDate: -1 }, - }); + expect(surveyRepository.findAndCount).toHaveBeenCalledTimes(1); }); }); diff --git a/server/src/modules/survey/controllers/collaborator.controller.ts b/server/src/modules/survey/controllers/collaborator.controller.ts new file mode 100644 index 00000000..35fc1d11 --- /dev/null +++ b/server/src/modules/survey/controllers/collaborator.controller.ts @@ -0,0 +1,375 @@ +import { + Body, + Controller, + Get, + HttpCode, + Post, + Query, + Request, + SetMetadata, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import * as Joi from 'joi'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { + SURVEY_PERMISSION, + SURVEY_PERMISSION_DESCRIPTION, +} from 'src/enums/surveyPermission'; +import { Logger } from 'src/logger'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; + +import { CollaboratorService } from '../services/collaborator.service'; +import { UserService } from 'src/modules/auth/services/user.service'; + +import { CreateCollaboratorDto } from '../dto/createCollaborator.dto'; +import { ChangeUserPermissionDto } from '../dto/changeUserPermission.dto'; +import { GetSurveyCollaboratorListDto } from '../dto/getSurveyCollaboratorList.dto'; +import { BatchSaveCollaboratorDto } from '../dto/batchSaveCollaborator.dto'; +import { splitCollaborators } from '../utils/splitCollaborator'; +import { SurveyMetaService } from '../services/surveyMeta.service'; + +@UseGuards(Authentication) +@ApiTags('collaborator') +@ApiBearerAuth() +@Controller('/api/collaborator') +export class CollaboratorController { + constructor( + private readonly collaboratorService: CollaboratorService, + private readonly logger: Logger, + private readonly userService: UserService, + private readonly surveyMetaService: SurveyMetaService, + private readonly workspaceMemberServie: WorkspaceMemberService, + ) {} + + @Get('getPermissionList') + @HttpCode(200) + async getPermissionList() { + const vals = Object.values(SURVEY_PERMISSION_DESCRIPTION); + return { + code: 200, + data: vals, + }; + } + + @Post('') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + ]) + async addCollaborator( + @Body() reqBody: CreateCollaboratorDto, + @Request() req, + ) { + const { error, value } = CreateCollaboratorDto.validate(reqBody); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException( + '系统错误,请联系管理员', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + + // 检查用户是否存在 + const user = await this.userService.getUserById(value.userId); + if (!user) { + throw new HttpException('用户不存在', EXCEPTION_CODE.USER_NOT_EXISTS); + } + + if (user._id.toString() === req.surveyMeta.ownerId) { + throw new HttpException( + '不能给问卷所有者授权', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + + const collaborator = await this.collaboratorService.getCollaborator({ + userId: value.userId, + surveyId: value.surveyId, + }); + + if (collaborator) { + throw new HttpException( + '用户已经是协作者', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + + const res = await this.collaboratorService.create(value); + + return { + code: 200, + data: { + collaboratorId: res._id.toString(), + }, + }; + } + + @Post('batchSave') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + ]) + async batchSaveCollaborator( + @Body() reqBody: BatchSaveCollaboratorDto, + @Request() req, + ) { + const { error, value } = BatchSaveCollaboratorDto.validate(reqBody); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException( + '系统错误,请联系管理员', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + + if (Array.isArray(value.collaborators) && value.collaborators.length > 0) { + const collaboratorUserIdList = value.collaborators.map( + (item) => item.userId, + ); + for (const collaboratorUserId of collaboratorUserIdList) { + if (collaboratorUserId === req.surveyMeta.ownerId) { + throw new HttpException( + '不能给问卷所有者授权', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + } + // 不能有重复的userId + const userIdSet = new Set(collaboratorUserIdList); + if (collaboratorUserIdList.length !== Array.from(userIdSet).length) { + throw new HttpException( + '不能重复添加用户', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const userList = await this.userService.getUserListByIds({ + idList: collaboratorUserIdList, + }); + const userInfoMap = userList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + + for (const collaborator of value.collaborators) { + if (!userInfoMap[collaborator.userId]) { + throw new HttpException( + `用户id: {${collaborator.userId}} 不存在`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + } + } + + if (Array.isArray(value.collaborators) && value.collaborators.length > 0) { + const { newCollaborator, existsCollaborator } = splitCollaborators( + value.collaborators, + ); + const collaboratorIdList = existsCollaborator.map((item) => item._id); + const newCollaboratorUserIdList = newCollaborator.map( + (item) => item.userId, + ); + const delRes = await this.collaboratorService.batchDelete({ + surveyId: value.surveyId, + idList: [], + neIdList: collaboratorIdList, + userIdList: newCollaboratorUserIdList, + }); + this.logger.info('batchDelete:' + JSON.stringify(delRes), { req }); + if (Array.isArray(newCollaborator) && newCollaborator.length > 0) { + const insertRes = await this.collaboratorService.batchCreate({ + surveyId: value.surveyId, + collaboratorList: newCollaborator, + }); + this.logger.info(`${JSON.stringify(insertRes)}`); + } + if (Array.isArray(existsCollaborator) && existsCollaborator.length > 0) { + const updateRes = await Promise.all( + existsCollaborator.map((item) => + this.collaboratorService.updateById({ + collaboratorId: item._id, + permissions: item.permissions, + }), + ), + ); + this.logger.info(`${JSON.stringify(updateRes)}`); + } + } else { + // 删除所有协作者 + const delRes = await this.collaboratorService.batchDeleteBySurveyId( + value.surveyId, + ); + this.logger.info(JSON.stringify(delRes), { req }); + } + + return { + code: 200, + }; + } + + @Get('') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + ]) + async getSurveyCollaboratorList( + @Query() query: GetSurveyCollaboratorListDto, + @Request() req, + ) { + const { error, value } = GetSurveyCollaboratorListDto.validate(query); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const res = await this.collaboratorService.getSurveyCollaboratorList(value); + + const userIdList = res.map((item) => item.userId); + const userList = await this.userService.getUserListByIds({ + idList: userIdList, + }); + const userInfoMap = userList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + + return { + code: 200, + data: res.map((item) => { + return { + ...item, + username: userInfoMap[item.userId]?.username || '', + }; + }), + }; + } + + @Post('changeUserPermission') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + ]) + async changeUserPermission( + @Body() reqBody: ChangeUserPermissionDto, + @Request() req, + ) { + const { error, value } = Joi.object({ + surveyId: Joi.string(), + userId: Joi.string(), + permissions: Joi.array().items(Joi.string().required()), + }).validate(reqBody); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const res = await this.collaboratorService.changeUserPermission(value); + + return { + code: 200, + data: res, + }; + } + + @Post('deleteCollaborator') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + ]) + async deleteCollaborator(@Query() query, @Request() req) { + const { error, value } = Joi.object({ + surveyId: Joi.string(), + userId: Joi.string(), + }).validate(query); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const res = await this.collaboratorService.deleteCollaborator(value); + + return { + code: 200, + data: res, + }; + } + + @HttpCode(200) + @Get('permissions') + async getUserSurveyPermissions(@Request() req, @Query() query) { + const user = req.user; + const userId = user._id.toString(); + const surveyId = query.surveyId; + const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); + + if (!surveyMeta) { + throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND); + } + + // 问卷owner,有问卷的权限 + if ( + surveyMeta?.ownerId === userId || + surveyMeta?.owner === req.user.username + ) { + return { + code: 200, + data: { + isOwner: true, + permissions: [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + ], + }, + }; + } + // 有空间权限,默认也有所有权限 + if (surveyMeta.workspaceId) { + const memberInfo = await this.workspaceMemberServie.findOne({ + workspaceId: surveyMeta.workspaceId, + userId, + }); + if (memberInfo) { + return { + code: 200, + data: { + isOwner: false, + permissions: [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + ], + }, + }; + } + } + + const colloborator = await this.collaboratorService.getCollaborator({ + surveyId, + userId, + }); + return { + code: 200, + data: { + isOwner: false, + permissions: colloborator?.permissions || [], + }, + }; + } +} diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts index 0dde2d4e..a0b1754c 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -4,49 +4,56 @@ import { Query, HttpCode, UseGuards, + SetMetadata, Request, } from '@nestjs/common'; +import * as Joi from 'joi'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { DataStatisticService } from '../services/dataStatistic.service'; -import { SurveyMetaService } from '../services/surveyMeta.service'; import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; -import * as Joi from 'joi'; -import { ApiTags } from '@nestjs/swagger'; -import { Authtication } from 'src/guards/authtication'; +import { Authentication } from 'src/guards/authentication.guard'; import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; @ApiTags('survey') +@ApiBearerAuth() @Controller('/api/survey/dataStatistic') export class DataStatisticController { constructor( - private readonly surveyMetaService: SurveyMetaService, private readonly responseSchemaService: ResponseSchemaService, private readonly dataStatisticService: DataStatisticService, private readonly pluginManager: XiaojuSurveyPluginManager, + private readonly logger: Logger, ) {} - @UseGuards(Authtication) @Get('/dataTable') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE]) + @UseGuards(Authentication) async data( @Query() queryInfo, - @Request() - req, + @Request() req, ) { - const validationResult = await Joi.object({ + const { value, error } = await Joi.object({ surveyId: Joi.string().required(), isDesensitive: Joi.boolean().default(true), // 默认true就是需要脱敏 page: Joi.number().default(1), pageSize: Joi.number().default(10), - }).validateAsync(queryInfo); - const { surveyId, isDesensitive, page, pageSize } = validationResult; - const username = req.user.username; - await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); + }).validate(queryInfo); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { surveyId, isDesensitive, page, pageSize } = value; const responseSchema = await this.responseSchemaService.getResponseSchemaByPageId(surveyId); const { total, listHead, listBody } = diff --git a/server/src/modules/survey/controllers/survey.controller.ts b/server/src/modules/survey/controllers/survey.controller.ts index 7516cf5e..c8b59fb2 100644 --- a/server/src/modules/survey/controllers/survey.controller.ts +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -7,7 +7,10 @@ import { HttpCode, UseGuards, Request, + SetMetadata, } from '@nestjs/common'; +import * as Joi from 'joi'; +import { ApiTags } from '@nestjs/swagger'; import { SurveyMetaService } from '../services/surveyMeta.service'; import { SurveyConfService } from '../services/surveyConf.service'; @@ -16,14 +19,18 @@ import { ContentSecurityService } from '../services/contentSecurity.service'; import { SurveyHistoryService } from '../services/surveyHistory.service'; import BannerData from '../template/banner/index.json'; +import { CreateSurveyDto } from '../dto/createSurvey.dto'; -import * as Joi from 'joi'; -import { ApiTags } from '@nestjs/swagger'; -import { Authtication } from 'src/guards/authtication'; +import { Authentication } from 'src/guards/authentication.guard'; import { HISTORY_TYPE } from 'src/enums'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { Logger } from 'src/logger'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; + +import { WorkspaceGuard } from 'src/guards/workspace.guard'; +import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; @ApiTags('survey') @Controller('/api/survey') @@ -46,66 +53,57 @@ export class SurveyController { }; } - @UseGuards(Authtication) @Post('/createSurvey') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.createFrom') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(WorkspaceGuard) + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.READ_SURVEY]) + @SetMetadata('workspaceId', { key: 'body.workspaceId', optional: true }) + @UseGuards(Authentication) async createSurvey( @Body() - reqBody, + reqBody: CreateSurveyDto, @Request() req, ) { - let validationResult; - try { - validationResult = await Joi.object({ - title: Joi.string().required(), - remark: Joi.string().allow(null, '').default(''), - surveyType: Joi.string().when('createMethod', { - is: 'copy', - then: Joi.allow(null), - otherwise: Joi.required(), - }), - createMethod: Joi.string().allow(null).default('basic'), - createFrom: Joi.string().when('createMethod', { - is: 'copy', - then: Joi.required(), - otherwise: Joi.allow(null), - }), - }).validateAsync(reqBody); - } catch (error) { + const { error, value } = CreateSurveyDto.validate(reqBody); + if (error) { this.logger.error(`createSurvey_parameter error: ${error.message}`, { req, }); throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } - const { title, remark, createMethod, createFrom } = validationResult; + const { title, remark, createMethod, createFrom } = value; - const username = req.user.username; - let surveyType = ''; + let surveyType = '', + workspaceId = null; if (createMethod === 'copy') { - const survey = await this.surveyMetaService.checkSurveyAccess({ - surveyId: createFrom, - username, - }); + const survey = req.surveyMeta; surveyType = survey.surveyType; + workspaceId = survey.workspaceId; } else { - surveyType = validationResult.surveyType; + surveyType = value.surveyType; + workspaceId = value.workspaceId; } const surveyMeta = await this.surveyMetaService.createSurveyMeta({ title, remark, surveyType, - username, + username: req.user.username, + userId: req.user._id.toString(), createMethod, createFrom, + workspaceId, }); await this.surveyConfService.createSurveyConf({ surveyId: surveyMeta._id.toString(), surveyType: surveyType, - createMethod: validationResult.createMethod, - createFrom: validationResult.createFrom, + createMethod: value.createMethod, + createFrom: value.createFrom, }); return { code: 200, @@ -115,26 +113,30 @@ export class SurveyController { }; } - @UseGuards(Authtication) @Post('/updateConf') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) async updateConf( @Body() surveyInfo, @Request() req, ) { - const validationResult = await Joi.object({ + const { value, error } = Joi.object({ surveyId: Joi.string().required(), configData: Joi.any().required(), - }).validateAsync(surveyInfo); + }).validate(surveyInfo); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } const username = req.user.username; - const surveyId = validationResult.surveyId; - await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); - const configData = validationResult.configData; + const surveyId = value.surveyId; + + const configData = value.configData; await this.surveyConfService.saveSurveyConf({ surveyId, schema: configData, @@ -153,23 +155,18 @@ export class SurveyController { }; } - @UseGuards(Authtication) @HttpCode(200) @Post('/deleteSurvey') - async deleteSurvey(@Body() reqBody, @Request() req) { - const validationResult = await Joi.object({ - surveyId: Joi.string().required(), - }).validateAsync(reqBody, { allowUnknown: true }); - const username = req.user.username; - const surveyId = validationResult.surveyId; - const survey = await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) + async deleteSurvey(@Request() req) { + const surveyMeta = req.surveyMeta; - await this.surveyMetaService.deleteSurveyMeta(survey); + await this.surveyMetaService.deleteSurveyMeta(surveyMeta); await this.responseSchemaService.deleteResponseSchema({ - surveyPath: survey.surveyPath, + surveyPath: surveyMeta.surveyPath, }); return { @@ -177,9 +174,16 @@ export class SurveyController { }; } - @UseGuards(Authtication) @Get('/getSurvey') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + ]) + @UseGuards(Authentication) async getSurvey( @Query() queryInfo: { @@ -188,19 +192,28 @@ export class SurveyController { @Request() req, ) { - const validationResult = await Joi.object({ + const { value, error } = Joi.object({ surveyId: Joi.string().required(), - }).validateAsync(queryInfo); + }).validate(queryInfo); - const username = req.user.username; - const surveyId = validationResult.surveyId; - const surveyMeta = await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const surveyId = value.surveyId; + const surveyMeta = req.surveyMeta; const surveyConf = await this.surveyConfService.getSurveyConfBySurveyId(surveyId); + surveyMeta.currentUserId = req.user._id.toString(); + if (req.collaborator) { + surveyMeta.isCollaborated = true; + surveyMeta.currentPermission = req.collaborator.permissions; + } else { + surveyMeta.isCollaborated = false; + } + return { code: 200, data: { @@ -210,24 +223,28 @@ export class SurveyController { }; } - @UseGuards(Authtication) @Post('/publishSurvey') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) async publishSurvey( @Body() surveyInfo, @Request() req, ) { - const validationResult = await Joi.object({ + const { value, error } = Joi.object({ surveyId: Joi.string().required(), - }).validateAsync(surveyInfo); + }).validate(surveyInfo); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } const username = req.user.username; - const surveyId = validationResult.surveyId; - const surveyMeta = await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); + const surveyId = value.surveyId; + const surveyMeta = req.surveyMeta; const surveyConf = await this.surveyConfService.getSurveyConfBySurveyId(surveyId); diff --git a/server/src/modules/survey/controllers/surveyHistory.controller.ts b/server/src/modules/survey/controllers/surveyHistory.controller.ts index 6a48d4ac..6144fa3d 100644 --- a/server/src/modules/survey/controllers/surveyHistory.controller.ts +++ b/server/src/modules/survey/controllers/surveyHistory.controller.ts @@ -4,48 +4,59 @@ import { Query, HttpCode, UseGuards, + SetMetadata, Request, } from '@nestjs/common'; - -import { SurveyHistoryService } from '../services/surveyHistory.service'; -import { SurveyMetaService } from '../services/surveyMeta.service'; - import * as Joi from 'joi'; import { ApiTags } from '@nestjs/swagger'; -import { Authtication } from 'src/guards/authtication'; + +import { SurveyHistoryService } from '../services/surveyHistory.service'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; @ApiTags('survey') @Controller('/api/surveyHisotry') export class SurveyHistoryController { constructor( private readonly surveyHistoryService: SurveyHistoryService, - private readonly surveyMetaService: SurveyMetaService, + private readonly logger: Logger, ) {} - @UseGuards(Authtication) @Get('/getList') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'query.surveyId') + @SetMetadata('surveyPermission', [ + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + ]) + @UseGuards(Authentication) async getList( @Query() queryInfo: { surveyId: string; historyType: string; }, - @Request() - req, + @Request() req, ) { - const validationResult = await Joi.object({ + const { value, error } = Joi.object({ surveyId: Joi.string().required(), historyType: Joi.string().required(), - }).validateAsync(queryInfo); + }).validate(queryInfo); - const username = req.user.username; - const surveyId = validationResult.surveyId; - const historyType = validationResult.historyType; - await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const surveyId = value.surveyId; + const historyType = value.historyType; const data = await this.surveyHistoryService.getHistoryList({ surveyId, historyType, diff --git a/server/src/modules/survey/controllers/surveyMeta.controller.ts b/server/src/modules/survey/controllers/surveyMeta.controller.ts index f9101fd3..3b965222 100644 --- a/server/src/modules/survey/controllers/surveyMeta.controller.ts +++ b/server/src/modules/survey/controllers/surveyMeta.controller.ts @@ -7,18 +7,26 @@ import { HttpCode, UseGuards, Request, + SetMetadata, } from '@nestjs/common'; import * as Joi from 'joi'; import moment from 'moment'; import { ApiTags } from '@nestjs/swagger'; +import { SurveyMetaService } from '../services/surveyMeta.service'; + import { getFilter, getOrder } from 'src/utils/surveyUtil'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -import { Authtication } from 'src/guards/authtication'; +import { Authentication } from 'src/guards/authentication.guard'; import { Logger } from 'src/logger'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { WorkspaceGuard } from 'src/guards/workspace.guard'; +import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; -import { SurveyMetaService } from '../services/surveyMeta.service'; +import { GetSurveyListDto } from '../dto/getSurveyMetaList.dto'; +import { CollaboratorService } from '../services/collaborator.service'; @ApiTags('survey') @Controller('/api/survey') @@ -26,34 +34,31 @@ export class SurveyMetaController { constructor( private readonly surveyMetaService: SurveyMetaService, private readonly logger: Logger, + private readonly collaboratorService: CollaboratorService, ) {} - @UseGuards(Authtication) @Post('/updateMeta') @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) async updateMeta(@Body() reqBody, @Request() req) { - let validationResult; - try { - validationResult = await Joi.object({ - title: Joi.string().required(), - remark: Joi.string().allow(null, '').default(''), - surveyId: Joi.string().required(), - }).validateAsync(reqBody, { allowUnknown: true }); - } catch (error) { + const { value, error } = Joi.object({ + title: Joi.string().required(), + remark: Joi.string().allow(null, '').default(''), + surveyId: Joi.string().required(), + }).validate(reqBody, { allowUnknown: true }); + + if (error) { this.logger.error(`updateMeta_parameter error: ${error.message}`, { req, }); throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } - - const username = req.user.username; - const surveyId = validationResult.surveyId; - const survey = await this.surveyMetaService.checkSurveyAccess({ - surveyId, - username, - }); - survey.title = validationResult.title; - survey.remark = validationResult.remark; + const survey = req.surveyMeta; + survey.title = value.title; + survey.remark = value.remark; await this.surveyMetaService.editSurveyMeta(survey); @@ -62,52 +67,58 @@ export class SurveyMetaController { }; } - @UseGuards(Authtication) + @UseGuards(WorkspaceGuard) + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.READ_SURVEY]) + @SetMetadata('workspaceId', { optional: true, key: 'query.workspaceId' }) + @UseGuards(Authentication) @Get('/getList') @HttpCode(200) async getList( @Query() - queryInfo: { - curPage: number; - pageSize: number; - }, + queryInfo: GetSurveyListDto, @Request() req, ) { - const validationResult = await Joi.object({ - curPage: Joi.number().required(), - pageSize: Joi.number().allow(null).default(10), - filter: Joi.string().allow(null), - order: Joi.string().allow(null), - }).validateAsync(queryInfo); - const { curPage, pageSize } = validationResult; + const { value, error } = GetSurveyListDto.validate(queryInfo); + if (error) { + this.logger.error(error.message, { req }); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { curPage, pageSize, workspaceId } = value; let filter = {}, order = {}; - if (validationResult.filter) { + if (value.filter) { try { - filter = getFilter( - JSON.parse(decodeURIComponent(validationResult.filter)), - ); + filter = getFilter(JSON.parse(decodeURIComponent(value.filter))); } catch (error) { - console.log(error); + this.logger.error(error.message, { req }); } } - if (validationResult.order) { + if (value.order) { try { - order = order = getOrder( - JSON.parse(decodeURIComponent(validationResult.order)), - ); + order = order = getOrder(JSON.parse(decodeURIComponent(value.order))); } catch (error) { - console.log(error); + this.logger.error(error.message, { req }); } } + const userId = req.user._id.toString(); + const cooperationList = + await this.collaboratorService.getCollaboratorListByUserId({ userId }); + const cooperSurveyIdMap = cooperationList.reduce((pre, cur) => { + pre[cur.surveyId] = cur; + return pre; + }, {}); + const surveyIdList = cooperationList.map((item) => item.surveyId); const username = req.user.username; const data = await this.surveyMetaService.getSurveyMetaList({ pageNum: curPage, pageSize: pageSize, + userId, username, filter, order, + workspaceId, + surveyIdList, }); return { code: 200, @@ -121,6 +132,15 @@ export class SurveyMetaController { item.createDate = moment(item.createDate).format(fmt); item.updateDate = moment(item.updateDate).format(fmt); item.curStatus.date = moment(item.curStatus.date).format(fmt); + const surveyId = item._id.toString(); + if (cooperSurveyIdMap[surveyId]) { + item.isCollaborated = true; + item.currentPermissions = cooperSurveyIdMap[surveyId].permissions; + } else { + item.isCollaborated = false; + item.currentPermissions = []; + } + item.currentUserId = userId; return item; }), }, diff --git a/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts b/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts new file mode 100644 index 00000000..1ae04787 --- /dev/null +++ b/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; + +export class CollaboratorDto { + @ApiProperty({ description: '用户id', required: false }) + userId: string; + + @ApiProperty({ + description: '权限', + required: true, + isArray: true, + enum: [ + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + ], + }) + permissions: Array; +} + +export class BatchSaveCollaboratorDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + + @ApiProperty({ description: '协作人列表', required: true, isArray: true }) + collaborators: Array; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + collaborators: Joi.array() + .allow(null) + .items( + Joi.object({ + _id: Joi.string().allow(null, ''), + userId: Joi.string().required(), + permissions: Joi.array() + .required() + .items( + Joi.string().valid( + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + ), + ), + }), + ), + }).validate(data, { allowUnknown: true }); + } +} diff --git a/server/src/modules/survey/dto/changeUserPermission.dto.ts b/server/src/modules/survey/dto/changeUserPermission.dto.ts new file mode 100644 index 00000000..222ef05d --- /dev/null +++ b/server/src/modules/survey/dto/changeUserPermission.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; + +export class ChangeUserPermissionDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + + @ApiProperty({ description: '用户id', required: false }) + userId: string; + + @ApiProperty({ description: '权限', required: true }) + permissions: Array; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string(), + userId: Joi.string(), + permissions: Joi.array().items( + Joi.string().valid( + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + ), + ), + }).validate(data); + } +} diff --git a/server/src/modules/survey/dto/createCollaborator.dto.ts b/server/src/modules/survey/dto/createCollaborator.dto.ts new file mode 100644 index 00000000..63ca8449 --- /dev/null +++ b/server/src/modules/survey/dto/createCollaborator.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; + +export class CreateCollaboratorDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + + @ApiProperty({ description: '用户id', required: false }) + userId: string; + + @ApiProperty({ description: '权限', required: true, enum: SURVEY_PERMISSION }) + permissions: Array; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string(), + userId: Joi.string(), + permissions: Joi.array().items( + Joi.string().valid( + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + ), + ), + }).validate(data); + } +} diff --git a/server/src/modules/survey/dto/createSurvey.dto.ts b/server/src/modules/survey/dto/createSurvey.dto.ts new file mode 100644 index 00000000..537996e6 --- /dev/null +++ b/server/src/modules/survey/dto/createSurvey.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class CreateSurveyDto { + @ApiProperty({ description: '问卷标题', required: true }) + title: string; + + @ApiProperty({ description: '问卷备注', required: false }) + remark: string; + + @ApiProperty({ description: '问卷类型,复制问卷必传', required: false }) + surveyType: string; + + @ApiProperty({ description: '创建方法', required: false }) + createMethod: string; + + @ApiProperty({ description: '创建来源', required: false }) + createFrom: string; + + @ApiProperty({ description: '问卷创建在哪个空间下', required: false }) + workspaceId?: string; + + static validate(data) { + return Joi.object({ + title: Joi.string().required(), + remark: Joi.string().allow(null, '').default(''), + surveyType: Joi.string().when('createMethod', { + is: 'copy', + then: Joi.allow(null), + otherwise: Joi.required(), + }), + createMethod: Joi.string().allow(null).valid('copy').default('basic'), + createFrom: Joi.string().when('createMethod', { + is: 'copy', + then: Joi.required(), + otherwise: Joi.allow(null), + }), + workspaceId: Joi.string().allow(null, ''), + }).validate(data); + } +} diff --git a/server/src/modules/survey/dto/getSurveyCollaboratorList.dto.ts b/server/src/modules/survey/dto/getSurveyCollaboratorList.dto.ts new file mode 100644 index 00000000..5b7b95aa --- /dev/null +++ b/server/src/modules/survey/dto/getSurveyCollaboratorList.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class GetSurveyCollaboratorListDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/survey/dto/getSurveyMetaList.dto.ts b/server/src/modules/survey/dto/getSurveyMetaList.dto.ts new file mode 100644 index 00000000..a53f5974 --- /dev/null +++ b/server/src/modules/survey/dto/getSurveyMetaList.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class GetSurveyListDto { + @ApiProperty({ description: '当前页码', required: true }) + curPage: number; + + @ApiProperty({ description: '分页', required: false }) + pageSize: number; + + @ApiProperty({ description: '过滤调教', required: false }) + filter?: string; + + @ApiProperty({ description: '排序条件', required: false }) + order?: string; + + @ApiProperty({ description: '空间id', required: false }) + workspaceId?: string; + + static validate(data) { + return Joi.object({ + curPage: Joi.number().required(), + pageSize: Joi.number().allow(null).default(10), + filter: Joi.string().allow(null), + order: Joi.string().allow(null), + workspaceId: Joi.string().allow(null, ''), + }).validate(data); + } +} diff --git a/server/src/modules/survey/services/collaborator.service.ts b/server/src/modules/survey/services/collaborator.service.ts new file mode 100644 index 00000000..cfd6c628 --- /dev/null +++ b/server/src/modules/survey/services/collaborator.service.ts @@ -0,0 +1,157 @@ +import { Injectable } from '@nestjs/common'; +import { Collaborator } from 'src/models/collaborator.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { Logger } from 'src/logger'; + +@Injectable() +export class CollaboratorService { + constructor( + @InjectRepository(Collaborator) + private readonly collaboratorRepository: MongoRepository, + private readonly logger: Logger, + ) {} + + async create({ surveyId, userId, permissions }) { + const collaborator = this.collaboratorRepository.create({ + surveyId, + userId, + permissions, + }); + return this.collaboratorRepository.save(collaborator); + } + + async batchCreate({ surveyId, collaboratorList }) { + const res = await this.collaboratorRepository.insertMany( + collaboratorList.map((item) => { + return { + ...item, + surveyId, + }; + }), + ); + return res; + } + + async getSurveyCollaboratorList({ surveyId }) { + const list = await this.collaboratorRepository.find({ + surveyId, + }); + return list; + } + + async getCollaboratorListByIds({ idList }) { + const list = await this.collaboratorRepository.find({ + _id: { + $in: idList.map((item) => new ObjectId(item)), + }, + }); + return list; + } + + async getCollaborator({ userId, surveyId }) { + const info = await this.collaboratorRepository.findOne({ + where: { + surveyId, + userId, + }, + }); + return info; + } + + async changeUserPermission({ userId, surveyId, permission }) { + const updateRes = await this.collaboratorRepository.updateOne( + { + surveyId, + userId, + }, + { + $set: { + permission, + }, + }, + ); + return updateRes; + } + + async deleteCollaborator({ userId, surveyId }) { + const delRes = await this.collaboratorRepository.deleteOne({ + userId, + surveyId, + }); + return delRes; + } + + async batchDelete({ + idList, + neIdList, + userIdList, + surveyId, + }: { + idList?: Array; + neIdList?: Array; + userIdList?: Array; + surveyId: string; + }) { + const query: Record = { + surveyId, + $or: [], + }; + + if (Array.isArray(userIdList) && userIdList.length > 0) { + query.$or.push({ + userId: { + $in: userIdList, + }, + }); + } + + if ( + (Array.isArray(idList) && idList.length > 0) || + (Array.isArray(neIdList) && neIdList.length > 0) + ) { + const idQuery: Record = { + _id: {}, + }; + if (idList && idList.length > 0) { + idQuery._id.$in = idList.map((item) => new ObjectId(item)); + } + if (neIdList && neIdList.length > 0) { + idQuery._id.$nin = neIdList.map((item) => new ObjectId(item)); + } + query.$or.push(idQuery); + } + this.logger.info(JSON.stringify(query)); + const delRes = await this.collaboratorRepository.deleteMany(query); + return delRes; + } + + async batchDeleteBySurveyId(surveyId) { + const delRes = await this.collaboratorRepository.deleteMany({ + surveyId, + }); + return delRes; + } + + updateById({ collaboratorId, permissions }) { + return this.collaboratorRepository.updateOne( + { + _id: new ObjectId(collaboratorId), + }, + { + $set: { + permissions, + }, + }, + ); + } + + getCollaboratorListByUserId({ userId }) { + return this.collaboratorRepository.find({ + where: { + userId, + }, + }); + } +} diff --git a/server/src/modules/survey/services/surveyMeta.service.ts b/server/src/modules/survey/services/surveyMeta.service.ts index 2b55a93e..e44d3b52 100644 --- a/server/src/modules/survey/services/surveyMeta.service.ts +++ b/server/src/modules/survey/services/surveyMeta.service.ts @@ -4,9 +4,7 @@ import { MongoRepository, FindOptionsOrder } from 'typeorm'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { RECORD_STATUS } from 'src/enums'; import { ObjectId } from 'mongodb'; -import { NoSurveyPermissionException } from 'src/exceptions/noSurveyPermissionException'; import { HttpException } from 'src/exceptions/httpException'; -import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; @@ -34,18 +32,10 @@ export class SurveyMetaService { return surveyPath; } - async checkSurveyAccess({ surveyId, username }) { - const survey = await this.surveyRepository.findOne({ + async getSurveyById({ surveyId }) { + return this.surveyRepository.findOne({ where: { _id: new ObjectId(surveyId) }, }); - - if (!survey) { - throw new SurveyNotFoundException('问卷不存在'); - } - if (survey.owner !== username) { - throw new NoSurveyPermissionException('没有权限'); - } - return survey; } async createSurveyMeta(params: { @@ -53,11 +43,21 @@ export class SurveyMetaService { remark: string; surveyType: string; username: string; + userId: string; createMethod: string; createFrom: string; + workspaceId?: string; }) { - const { title, remark, surveyType, username, createMethod, createFrom } = - params; + const { + title, + remark, + surveyType, + username, + createMethod, + createFrom, + userId, + workspaceId, + } = params; const surveyPath = await this.getNewSurveyPath(); const newSurvey = this.surveyRepository.create({ title, @@ -66,8 +66,10 @@ export class SurveyMetaService { surveyPath, creator: username, owner: username, + ownerId: userId, createMethod, createFrom, + workspaceId, }); return await this.surveyRepository.save(newSurvey); @@ -112,22 +114,48 @@ export class SurveyMetaService { pageNum: number; pageSize: number; username: string; + userId: string; filter: Record; order: Record; + workspaceId?: string; + surveyIdList?: Array; }): Promise<{ data: any[]; count: number }> { - const { pageNum, pageSize, username } = condition; + const { pageNum, pageSize, userId, username, workspaceId, surveyIdList } = + condition; const skip = (pageNum - 1) * pageSize; try { - const query = Object.assign( + const query: Record = Object.assign( {}, { - owner: username, 'curStatus.status': { $ne: 'removed', }, }, condition.filter, ); + if (workspaceId) { + query.workspaceId = workspaceId; + } else { + query.workspaceId = { + $exists: false, + }; + // 引入空间之前,新建的问卷只有owner字段,引入空间之后,新建的问卷多了ownerId字段,使用owenrId字段进行关联更加合理,此处做了兼容 + query.$or = [ + { + owner: username, + }, + { + ownerId: userId, + }, + ]; + if (Array.isArray(surveyIdList) && surveyIdList.length > 0) { + query.$or.push({ + _id: { + $in: surveyIdList.map((item) => new ObjectId(item)), + }, + }); + } + } const order = condition.order && Object.keys(condition.order).length > 0 ? (condition.order as FindOptionsOrder) @@ -160,4 +188,14 @@ export class SurveyMetaService { } return this.surveyRepository.save(surveyMeta); } + + async countSurveyMetaByWorkspaceId({ workspaceId }) { + const total = await this.surveyRepository.count({ + workspaceId, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }); + return total; + } } diff --git a/server/src/modules/survey/survey.module.ts b/server/src/modules/survey/survey.module.ts index 58528219..a5c58068 100644 --- a/server/src/modules/survey/survey.module.ts +++ b/server/src/modules/survey/survey.module.ts @@ -6,18 +6,21 @@ import { LoggerProvider } from 'src/logger/logger.provider'; import { SurveyResponseModule } from '../surveyResponse/surveyResponse.module'; import { AuthModule } from '../auth/auth.module'; +import { WorkspaceModule } from '../workspace/workspace.module'; import { DataStatisticController } from './controllers/dataStatistic.controller'; import { SurveyController } from './controllers/survey.controller'; import { SurveyHistoryController } from './controllers/surveyHistory.controller'; import { SurveyMetaController } from './controllers/surveyMeta.controller'; import { SurveyUIController } from './controllers/surveyUI.controller'; +import { CollaboratorController } from './controllers/collaborator.controller'; import { SurveyConf } from 'src/models/surveyConf.entity'; import { SurveyHistory } from 'src/models/surveyHistory.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { Word } from 'src/models/word.entity'; +import { Collaborator } from 'src/models/collaborator.entity'; import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; import { DataStatisticService } from './services/dataStatistic.service'; @@ -25,6 +28,7 @@ import { SurveyConfService } from './services/surveyConf.service'; import { SurveyHistoryService } from './services/surveyHistory.service'; import { SurveyMetaService } from './services/surveyMeta.service'; import { ContentSecurityService } from './services/contentSecurity.service'; +import { CollaboratorService } from './services/collaborator.service'; @Module({ imports: [ @@ -34,10 +38,12 @@ import { ContentSecurityService } from './services/contentSecurity.service'; SurveyHistory, SurveyResponse, Word, + Collaborator, ]), ConfigModule, SurveyResponseModule, AuthModule, + WorkspaceModule, ], controllers: [ DataStatisticController, @@ -45,6 +51,7 @@ import { ContentSecurityService } from './services/contentSecurity.service'; SurveyHistoryController, SurveyMetaController, SurveyUIController, + CollaboratorController, ], providers: [ DataStatisticService, @@ -53,6 +60,7 @@ import { ContentSecurityService } from './services/contentSecurity.service'; SurveyMetaService, PluginManagerProvider, ContentSecurityService, + CollaboratorService, LoggerProvider, ], }) diff --git a/server/src/modules/survey/utils/splitCollaborator.ts b/server/src/modules/survey/utils/splitCollaborator.ts new file mode 100644 index 00000000..c676c67a --- /dev/null +++ b/server/src/modules/survey/utils/splitCollaborator.ts @@ -0,0 +1,15 @@ +export const splitCollaborators = (collaboratorList) => { + const newCollaborator = [], + existsCollaborator = []; + for (const collaborator of collaboratorList) { + if (collaborator._id) { + existsCollaborator.push(collaborator); + } else { + newCollaborator.push(collaborator); + } + } + return { + newCollaborator, + existsCollaborator, + }; +}; diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts index d0b656f5..5cb5a950 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -20,6 +20,7 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi import { RECORD_STATUS } from 'src/enums'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { Logger } from 'src/logger'; const mockDecryptErrorBody = { surveyPath: 'EBzdmnSp', @@ -116,6 +117,13 @@ describe('SurveyResponseController', () => { runResponseDataPush: jest.fn(), }, }, + { + provide: Logger, + useValue: { + error: jest.fn(), + info: jest.fn(), + }, + }, ], }).compile(); @@ -197,7 +205,7 @@ describe('SurveyResponseController', () => { .spyOn(clientEncryptService, 'deleteEncryptInfo') .mockResolvedValueOnce(undefined); - const result = await controller.createResponse(reqBody); + const result = await controller.createResponse(reqBody, {}); expect(result).toEqual({ code: 200, msg: '提交成功' }); expect( @@ -244,7 +252,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(null); - await expect(controller.createResponse(reqBody)).rejects.toThrow( + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( SurveyNotFoundException, ); }); @@ -253,7 +261,7 @@ describe('SurveyResponseController', () => { const reqBody = cloneDeep(mockSubmitData); delete reqBody.sign; - await expect(controller.createResponse(reqBody)).rejects.toThrow( + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( HttpException, ); @@ -266,7 +274,7 @@ describe('SurveyResponseController', () => { const reqBody = cloneDeep(mockDecryptErrorBody); reqBody.sign = 'mock sign'; - await expect(controller.createResponse(reqBody)).rejects.toThrow( + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( HttpException, ); @@ -282,7 +290,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(mockResponseSchema); - await expect(controller.createResponse(reqBody)).rejects.toThrow( + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( HttpException, ); }); @@ -294,7 +302,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(mockResponseSchema); - await expect(controller.createResponse(reqBody)).rejects.toThrow( + await expect(controller.createResponse(reqBody, {})).rejects.toThrow( HttpException, ); }); diff --git a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts index 8c77584e..9f3bbb61 100644 --- a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts +++ b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { Controller, Post, Body, HttpCode, Request } from '@nestjs/common'; import { HttpException } from 'src/exceptions/httpException'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { checkSign } from 'src/utils/checkSign'; @@ -16,6 +16,7 @@ import moment from 'moment'; import * as Joi from 'joi'; import * as forge from 'node-forge'; import { ApiTags } from '@nestjs/swagger'; +import { Logger } from 'src/logger'; @ApiTags('surveyResponse') @Controller('/api/surveyResponse') @@ -26,25 +27,33 @@ export class SurveyResponseController { private readonly surveyResponseService: SurveyResponseService, private readonly clientEncryptService: ClientEncryptService, private readonly messagePushingTaskService: MessagePushingTaskService, + private readonly logger: Logger, ) {} @Post('/createResponse') @HttpCode(200) - async createResponse(@Body() reqBody) { + async createResponse(@Body() reqBody, @Request() req) { // 检查签名 checkSign(reqBody); // 校验参数 - const validationResult = await Joi.object({ + const { value, error } = Joi.object({ surveyPath: Joi.string().required(), data: Joi.any().required(), encryptType: Joi.string(), sessionId: Joi.string(), clientTime: Joi.number().required(), difTime: Joi.number(), - }).validateAsync(reqBody, { allowUnknown: true }); + }).validate(reqBody, { allowUnknown: true }); + + if (error) { + this.logger.error(`updateMeta_parameter error: ${error.message}`, { + req, + }); + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } const { surveyPath, encryptType, data, sessionId, clientTime, difTime } = - validationResult; + value; // 查询schema const responseSchema = @@ -153,8 +162,8 @@ export class SurveyResponseController { // 对用户提交的数据进行遍历处理 for (const field in decryptedData) { - const value = decryptedData[field]; - const values = Array.isArray(value) ? value : [value]; + const val = decryptedData[field]; + const vals = Array.isArray(val) ? val : [val]; if (field in optionTextAndId) { // 记录选项的提交数量,用于投票题回显、或者拓展上限限制功能 const optionCountData: Record = @@ -164,7 +173,7 @@ export class SurveyResponseController { type: 'option', })) || { total: 0 }; optionCountData.total++; - for (const val of values) { + for (const val of vals) { if (!optionCountData[val]) { optionCountData[val] = 1; } else { @@ -183,7 +192,7 @@ export class SurveyResponseController { // 入库 const surveyResponse = await this.surveyResponseService.createSurveyResponse({ - surveyPath: validationResult.surveyPath, + surveyPath: value.surveyPath, data: decryptedData, clientTime, difTime, diff --git a/server/src/modules/surveyResponse/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts index 357644c6..6f9b6fc9 100644 --- a/server/src/modules/surveyResponse/surveyResponse.module.ts +++ b/server/src/modules/surveyResponse/surveyResponse.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; import { MessageModule } from '../message/message.module'; @@ -11,6 +13,7 @@ import { ResponseSchema } from 'src/models/responseSchema.entity'; import { Counter } from 'src/models/counter.entity'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; +import { Logger } from 'src/logger'; import { ClientEncryptController } from './controllers/clientEncrpt.controller'; import { CounterController } from './controllers/counter.controller'; @@ -18,9 +21,6 @@ import { ResponseSchemaController } from './controllers/responseSchema.controlle import { SurveyResponseController } from './controllers/surveyResponse.controller'; import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule } from '@nestjs/config'; - @Module({ imports: [ TypeOrmModule.forFeature([ @@ -44,6 +44,7 @@ import { ConfigModule } from '@nestjs/config'; SurveyResponseService, CounterService, ClientEncryptService, + Logger, ], exports: [ ResponseSchemaService, diff --git a/server/src/modules/workspace/_test/splitMember.spec.ts b/server/src/modules/workspace/_test/splitMember.spec.ts new file mode 100644 index 00000000..1498d5e6 --- /dev/null +++ b/server/src/modules/workspace/_test/splitMember.spec.ts @@ -0,0 +1,50 @@ +import { splitMembers, Member } from '../utils/splitMember'; +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; + +describe('splitMembers', () => { + it('should split members into newMembers, adminMembers, and userMembers', () => { + const members = [ + { userId: 'user1', role: WORKSPACE_ROLE.ADMIN, _id: '1' }, + { userId: 'user2', role: WORKSPACE_ROLE.USER, _id: '2' }, + { userId: 'user3', role: WORKSPACE_ROLE.ADMIN, _id: '3' }, + { userId: 'user4', role: WORKSPACE_ROLE.USER, _id: '4' }, + { userId: 'user5', role: WORKSPACE_ROLE.USER }, + ]; + + const result = splitMembers(members); + + expect(result).toEqual({ + newMembers: [{ userId: 'user5', role: WORKSPACE_ROLE.USER }], + adminMembers: ['1', '3'], + userMembers: ['2', '4'], + }); + }); + + it('should handle an empty members array', () => { + const members: Array = []; + + const result = splitMembers(members); + + expect(result).toEqual({ + newMembers: [], + adminMembers: [], + userMembers: [], + }); + }); + + it('should handle members with no role', () => { + const members = [ + { userId: 'user1', role: WORKSPACE_ROLE.ADMIN, _id: '1' }, + { userId: 'user2', role: '', _id: '2' }, + { userId: 'user3', role: '', _id: '3' }, + ]; + + const result = splitMembers(members); + + expect(result).toEqual({ + newMembers: [], + adminMembers: ['1'], + userMembers: ['2', '3'], + }); + }); +}); diff --git a/server/src/modules/workspace/_test/workspace.controller.spec.ts b/server/src/modules/workspace/_test/workspace.controller.spec.ts new file mode 100644 index 00000000..3a5b2733 --- /dev/null +++ b/server/src/modules/workspace/_test/workspace.controller.spec.ts @@ -0,0 +1,229 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ObjectId } from 'mongodb'; +import { WorkspaceController } from '../controllers/workspace.controller'; +import { WorkspaceService } from '../services/workspace.service'; +import { WorkspaceMemberService } from '../services/workspaceMember.service'; +import { CreateWorkspaceDto } from '../dto/createWorkspace.dto'; +import { HttpException } from 'src/exceptions/httpException'; +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; +import { Workspace } from 'src/models/workspace.entity'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; +import { Logger } from 'src/logger'; +import { User } from 'src/models/user.entity'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); + +describe('WorkspaceController', () => { + let controller: WorkspaceController; + let workspaceService: WorkspaceService; + let workspaceMemberService: WorkspaceMemberService; + let userService: UserService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspaceController], + providers: [ + { + provide: WorkspaceService, + useValue: { + create: jest.fn(), + findAllById: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: WorkspaceMemberService, + useValue: { + create: jest.fn(), + batchCreate: jest.fn(), + findAllByUserId: jest.fn(), + batchUpdate: jest.fn(), + batchDelete: jest.fn(), + countByWorkspaceId: jest.fn(), + }, + }, + { + provide: UserService, + useValue: { + getUserListByIds: jest.fn(), + }, + }, + { + provide: SurveyMetaService, + useValue: { + countSurveyMetaByWorkspaceId: jest.fn().mockImplementation(() => { + return Math.floor(Math.random() * 10); + }), + }, + }, + { + provide: Logger, + useValue: { + info: jest.fn(), + error: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(WorkspaceController); + workspaceService = module.get(WorkspaceService); + workspaceMemberService = module.get( + WorkspaceMemberService, + ); + userService = module.get(UserService); + }); + + describe('create', () => { + it('should create a workspace and return workspaceId', async () => { + const createWorkspaceDto: CreateWorkspaceDto = { + name: 'Test Workspace', + description: 'Test Description', + members: [{ userId: 'userId1', role: WORKSPACE_ROLE.USER }], + }; + const req = { user: { _id: new ObjectId() } }; + const createdWorkspace = { _id: new ObjectId() }; + + jest.spyOn(userService, 'getUserListByIds').mockResolvedValue([ + { + _id: 'userId1', + }, + ] as unknown as Array); + + jest + .spyOn(workspaceService, 'create') + .mockResolvedValue(createdWorkspace as Workspace); + jest.spyOn(workspaceMemberService, 'create').mockResolvedValue(null); + jest.spyOn(workspaceMemberService, 'batchCreate').mockResolvedValue(null); + + const result = await controller.create(createWorkspaceDto, req); + + expect(result).toEqual({ + code: 200, + data: { workspaceId: createdWorkspace._id.toString() }, + }); + expect(workspaceService.create).toHaveBeenCalledWith({ + name: createWorkspaceDto.name, + description: createWorkspaceDto.description, + ownerId: req.user._id.toString(), + }); + expect(workspaceMemberService.create).toHaveBeenCalledWith({ + userId: req.user._id.toString(), + workspaceId: createdWorkspace._id.toString(), + role: WORKSPACE_ROLE.ADMIN, + }); + expect(workspaceMemberService.batchCreate).toHaveBeenCalledWith({ + workspaceId: createdWorkspace._id.toString(), + members: createWorkspaceDto.members, + }); + }); + + it('should throw an exception if validation fails', async () => { + const createWorkspaceDto: CreateWorkspaceDto = { + name: '', + members: [], + }; + const req = { user: { _id: new ObjectId() } }; + + await expect(controller.create(createWorkspaceDto, req)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('findAll', () => { + it('should return a list of workspaces for the user', async () => { + const req = { user: { _id: new ObjectId() } }; + const memberList = [{ workspaceId: new ObjectId().toString() }]; + const workspaces = [{ _id: new ObjectId(), name: 'Test Workspace' }]; + + jest + .spyOn(workspaceMemberService, 'findAllByUserId') + .mockResolvedValue(memberList as unknown as Array); + jest + .spyOn(workspaceService, 'findAllById') + .mockResolvedValue(workspaces as Array); + + jest.spyOn(userService, 'getUserListByIds').mockResolvedValue([]); + + const result = await controller.findAll(req); + + expect(result.code).toEqual(200); + expect(workspaceMemberService.findAllByUserId).toHaveBeenCalledWith({ + userId: req.user._id.toString(), + }); + expect(workspaceService.findAllById).toHaveBeenCalledWith({ + workspaceIdList: memberList.map((item) => item.workspaceId), + }); + }); + }); + + describe('update', () => { + it('should update a workspace and its members', async () => { + const id = new ObjectId().toString(); + const userId = new ObjectId(); + const members = { + newMembers: [{ userId: userId.toString(), role: WORKSPACE_ROLE.ADMIN }], + adminMembers: [], + userMembers: [], + }; + jest.spyOn(userService, 'getUserListByIds').mockResolvedValue([ + { + _id: userId, + }, + ] as Array); + const updateDto = { + name: 'Updated Workspace', + members: [ + ...members.newMembers, + ...members.adminMembers, + ...members.userMembers, + ], + }; + const updateResult = { affected: 1, raw: '', generatedMaps: [] }; + + jest.spyOn(workspaceService, 'update').mockResolvedValue(updateResult); + jest.spyOn(workspaceMemberService, 'batchCreate').mockResolvedValue(null); + jest.spyOn(workspaceMemberService, 'batchUpdate').mockResolvedValue(null); + + const result = await controller.update(id, updateDto); + + expect(result).toEqual({ + code: 200, + }); + expect(workspaceService.update).toHaveBeenCalledWith(id, { + name: updateDto.name, + }); + expect(workspaceMemberService.batchCreate).toHaveBeenCalledWith({ + workspaceId: id, + members: members.newMembers, + }); + expect(workspaceMemberService.batchUpdate).toHaveBeenCalledWith({ + idList: members.adminMembers, + role: WORKSPACE_ROLE.ADMIN, + }); + expect(workspaceMemberService.batchUpdate).toHaveBeenCalledWith({ + idList: members.userMembers, + role: WORKSPACE_ROLE.USER, + }); + }); + }); + + describe('delete', () => { + it('should delete a workspace', async () => { + const id = 'workspaceId'; + + jest.spyOn(workspaceService, 'delete').mockResolvedValue(null); + + const result = await controller.delete(id); + + expect(result).toEqual({ code: 200 }); + expect(workspaceService.delete).toHaveBeenCalledWith(id); + }); + }); +}); diff --git a/server/src/modules/workspace/_test/workspace.service.spec.ts b/server/src/modules/workspace/_test/workspace.service.spec.ts new file mode 100644 index 00000000..1eb12233 --- /dev/null +++ b/server/src/modules/workspace/_test/workspace.service.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { ObjectId } from 'mongodb'; + +import { WorkspaceService } from '../services/workspace.service'; +import { Workspace } from 'src/models/workspace.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); + +describe('WorkspaceService', () => { + let service: WorkspaceService; + let workspaceRepository: MongoRepository; + let surveyMetaRepository: MongoRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceService, + { + provide: getRepositoryToken(Workspace), + useClass: MongoRepository, + }, + { + provide: getRepositoryToken(SurveyMeta), + useClass: MongoRepository, + }, + ], + }).compile(); + + service = module.get(WorkspaceService); + workspaceRepository = module.get>( + getRepositoryToken(Workspace), + ); + surveyMetaRepository = module.get>( + getRepositoryToken(SurveyMeta), + ); + }); + + describe('create', () => { + it('should create a new workspace', async () => { + const workspace = { + name: 'Test Workspace', + description: 'Description', + ownerId: 'ownerId', + }; + const createdWorkspace = { ...workspace, _id: new ObjectId() }; + + jest + .spyOn(workspaceRepository, 'create') + .mockReturnValue(createdWorkspace as any); + jest + .spyOn(workspaceRepository, 'save') + .mockResolvedValue(createdWorkspace as any); + + const result = await service.create(workspace); + + expect(result).toEqual(createdWorkspace); + expect(workspaceRepository.create).toHaveBeenCalledWith(workspace); + expect(workspaceRepository.save).toHaveBeenCalledWith(createdWorkspace); + }); + }); + + describe('findAllById', () => { + it('should return a list of workspaces', async () => { + const workspaceIdList = [ + new ObjectId().toString(), + new ObjectId().toString(), + ]; + const workspaces = [ + { _id: workspaceIdList[0], name: 'Workspace 1' }, + { _id: workspaceIdList[1], name: 'Workspace 2' }, + ]; + + jest + .spyOn(workspaceRepository, 'find') + .mockResolvedValue(workspaces as any); + + const result = await service.findAllById({ workspaceIdList }); + + expect(result).toEqual(workspaces); + expect(workspaceRepository.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('update', () => { + it('should update a workspace', async () => { + const workspaceId = 'workspaceId'; + const updateData = { name: 'Updated Workspace' }; + + jest + .spyOn(workspaceRepository, 'update') + .mockResolvedValue({ affected: 1 } as any); + + const result = await service.update(workspaceId, updateData); + + expect(result).toEqual({ affected: 1 }); + expect(workspaceRepository.update).toHaveBeenCalledWith( + workspaceId, + updateData, + ); + }); + }); + + describe('delete', () => { + it('should delete a workspace and update related surveyMeta', async () => { + const workspaceId = new ObjectId().toString(); + + jest + .spyOn(workspaceRepository, 'updateOne') + .mockResolvedValue({ modifiedCount: 1 } as any); + jest + .spyOn(surveyMetaRepository, 'updateMany') + .mockResolvedValue({ modifiedCount: 1 } as any); + + await service.delete(workspaceId); + + expect(workspaceRepository.updateOne).toHaveBeenCalledTimes(1); + + expect(surveyMetaRepository.updateMany).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/server/src/modules/workspace/_test/workspaceMember.controller.spec.ts b/server/src/modules/workspace/_test/workspaceMember.controller.spec.ts new file mode 100644 index 00000000..32e765c7 --- /dev/null +++ b/server/src/modules/workspace/_test/workspaceMember.controller.spec.ts @@ -0,0 +1,183 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkspaceMemberController } from '../controllers/workspaceMember.controller'; +import { WorkspaceMemberService } from '../services/workspaceMember.service'; +import { CreateWorkspaceMemberDto } from '../dto/createWorkspaceMember.dto'; +import { UpdateWorkspaceMemberDto } from '../dto/updateWorkspaceMember.dto'; +import { DeleteWorkspaceMemberDto } from '../dto/deleteWorkspaceMember.dto'; +import { HttpException } from 'src/exceptions/httpException'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); + +describe('WorkspaceMemberController', () => { + let controller: WorkspaceMemberController; + let workspaceMemberService: WorkspaceMemberService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspaceMemberController], + providers: [ + { + provide: WorkspaceMemberService, + useValue: { + create: jest.fn(), + findAllByWorkspaceId: jest.fn(), + updateRole: jest.fn(), + deleteMember: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get( + WorkspaceMemberController, + ); + workspaceMemberService = module.get( + WorkspaceMemberService, + ); + }); + + describe('create', () => { + it('should create a workspace member and return memberId', async () => { + const createDto: CreateWorkspaceMemberDto = { + workspaceId: 'workspaceId', + userId: 'userId', + role: WORKSPACE_ROLE.ADMIN, + }; + const createdMember = { _id: 'memberId' }; + + jest + .spyOn(workspaceMemberService, 'create') + .mockResolvedValue(createdMember as unknown as WorkspaceMember); + + const result = await controller.create(createDto); + + expect(result).toEqual({ + code: 200, + data: { + memberId: createdMember._id, + }, + }); + expect(workspaceMemberService.create).toHaveBeenCalledWith({ + userId: createDto.userId, + workspaceId: createDto.workspaceId, + role: createDto.role, + }); + }); + + it('should throw an exception if validation fails', async () => { + const createDto: CreateWorkspaceMemberDto = { + workspaceId: '', + userId: '', + role: '', + }; + + await expect(controller.create(createDto)).rejects.toThrow(HttpException); + }); + }); + + describe('findAll', () => { + it('should return a list of workspace members', async () => { + const req = { query: { workspaceId: 'workspaceId' } }; + const members = [{ userId: 'userId1', role: 'USER' }]; + + jest + .spyOn(workspaceMemberService, 'findAllByWorkspaceId') + .mockResolvedValue(members as unknown as Array); + + const result = await controller.findAll(req); + + expect(result).toEqual({ + code: 200, + data: members, + }); + expect(workspaceMemberService.findAllByWorkspaceId).toHaveBeenCalledWith({ + workspaceId: 'workspaceId', + }); + }); + + it('should throw an exception if workspaceId is not provided', async () => { + const req = { query: {} }; + + await expect(controller.findAll(req)).rejects.toThrow(HttpException); + }); + }); + + describe('updateRole', () => { + it('should update the role of a workspace member and return modifiedCount', async () => { + const updateDto: UpdateWorkspaceMemberDto = { + workspaceId: 'workspaceId', + userId: 'userId', + role: WORKSPACE_ROLE.ADMIN, + }; + const updateResult = { modifiedCount: 1 }; + + jest + .spyOn(workspaceMemberService, 'updateRole') + .mockResolvedValue(updateResult); + + const result = await controller.updateRole(updateDto); + + expect(result).toEqual({ + code: 200, + data: { + modifiedCount: updateResult.modifiedCount, + }, + }); + expect(workspaceMemberService.updateRole).toHaveBeenCalledWith({ + workspaceId: updateDto.workspaceId, + userId: updateDto.userId, + role: updateDto.role, + }); + }); + + it('should throw an exception if validation fails', async () => { + const updateDto: UpdateWorkspaceMemberDto = { + workspaceId: '', + userId: '', + role: '', + }; + + await expect(controller.updateRole(updateDto)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('delete', () => { + it('should delete a workspace member and return deletedCount', async () => { + const deleteDto: DeleteWorkspaceMemberDto = { + workspaceId: 'workspaceId', + userId: 'userId', + }; + const deleteResult = { acknowledged: true, deletedCount: 1 }; + + jest + .spyOn(workspaceMemberService, 'deleteMember') + .mockResolvedValue(deleteResult); + + const result = await controller.delete(deleteDto); + + expect(result).toEqual({ + code: 200, + data: { + deletedCount: deleteResult.deletedCount, + }, + }); + expect(workspaceMemberService.deleteMember).toHaveBeenCalledWith( + deleteDto, + ); + }); + + it('should throw an exception if validation fails', async () => { + const deleteDto: DeleteWorkspaceMemberDto = { + userId: '', + workspaceId: '', + }; + await expect(controller.delete(deleteDto)).rejects.toThrow(HttpException); + }); + }); +}); diff --git a/server/src/modules/workspace/_test/workspaceMember.service.spec.ts b/server/src/modules/workspace/_test/workspaceMember.service.spec.ts new file mode 100644 index 00000000..d115c3fe --- /dev/null +++ b/server/src/modules/workspace/_test/workspaceMember.service.spec.ts @@ -0,0 +1,196 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { ObjectId } from 'mongodb'; + +import { WorkspaceMemberService } from '../services/workspaceMember.service'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; + +jest.mock('src/guards/authentication.guard'); +jest.mock('src/guards/survey.guard'); +jest.mock('src/guards/workspace.guard'); + +describe('WorkspaceMemberService', () => { + let service: WorkspaceMemberService; + let repository: MongoRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkspaceMemberService, + { + provide: getRepositoryToken(WorkspaceMember), + useClass: MongoRepository, + }, + ], + }).compile(); + + service = module.get(WorkspaceMemberService); + repository = module.get>( + getRepositoryToken(WorkspaceMember), + ); + }); + + describe('create', () => { + it('should create a new workspace member', async () => { + const member = { + role: 'admin', + userId: 'userId', + workspaceId: 'workspaceId', + }; + const createdMember = { ...member, _id: new ObjectId() }; + + jest.spyOn(repository, 'create').mockReturnValue(createdMember as any); + jest.spyOn(repository, 'save').mockResolvedValue(createdMember as any); + + const result = await service.create(member); + + expect(result).toEqual(createdMember); + expect(repository.create).toHaveBeenCalledWith(member); + expect(repository.save).toHaveBeenCalledWith(createdMember); + }); + }); + + describe('batchCreate', () => { + it('should batch create workspace members', async () => { + const workspaceId = 'workspaceId'; + const members = [ + { userId: 'userId1', role: 'admin' }, + { userId: 'userId2', role: 'user' }, + ]; + const dataToInsert = members.map((item) => ({ ...item, workspaceId })); + + jest + .spyOn(repository, 'insertMany') + .mockResolvedValueOnce({ insertedCount: members.length } as any); + + const result = await service.batchCreate({ workspaceId, members }); + + expect(result).toEqual({ insertedCount: members.length }); + expect(repository.insertMany).toHaveBeenCalledWith(dataToInsert); + }); + + it('should return insertedCount 0 if no members to insert', async () => { + const workspaceId = new ObjectId().toString(); + const members = []; + + const result = await service.batchCreate({ workspaceId, members }); + + expect(result).toEqual({ insertedCount: 0 }); + }); + }); + + describe('batchUpdate', () => { + it('should batch update workspace members roles', async () => { + const idList = [new ObjectId().toString(), new ObjectId().toString()]; + const role = 'user'; + + jest + .spyOn(repository, 'updateMany') + .mockResolvedValue({ modifiedCount: idList.length } as any); + + const result = await service.batchUpdate({ idList, role }); + + expect(result).toEqual({ modifiedCount: idList.length }); + }); + + it('should return modifiedCount 0 if no ids to update', async () => { + const idList = []; + const role = 'user'; + + const result = await service.batchUpdate({ idList, role }); + + expect(result).toEqual({ modifiedCount: 0 }); + }); + }); + + describe('findAllByUserId', () => { + it('should return all workspace members by userId', async () => { + const userId = 'userId'; + const members = [ + { userId, workspaceId: 'workspaceId1' }, + { userId, workspaceId: 'workspaceId2' }, + ]; + + jest.spyOn(repository, 'find').mockResolvedValue(members as any); + + const result = await service.findAllByUserId({ userId }); + + expect(result).toEqual(members); + expect(repository.find).toHaveBeenCalledWith({ where: { userId } }); + }); + }); + + describe('findAllByWorkspaceId', () => { + it('should return all workspace members by workspaceId', async () => { + const workspaceId = 'workspaceId'; + const members = [ + { userId: 'userId1', workspaceId }, + { userId: 'userId2', workspaceId }, + ]; + + jest.spyOn(repository, 'find').mockResolvedValue(members as any); + + const result = await service.findAllByWorkspaceId({ workspaceId }); + + expect(result).toEqual(members); + expect(repository.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('findOne', () => { + it('should return a single workspace member', async () => { + const workspaceId = 'workspaceId'; + const userId = 'userId'; + const member = { userId, workspaceId }; + + jest.spyOn(repository, 'findOne').mockResolvedValue(member as any); + + const result = await service.findOne({ workspaceId, userId }); + + expect(result).toEqual(member); + expect(repository.findOne).toHaveBeenCalledWith({ + where: { workspaceId, userId }, + }); + }); + }); + + describe('updateRole', () => { + it('should update the role of a workspace member', async () => { + const workspaceId = 'workspaceId'; + const userId = 'userId'; + const role = 'admin'; + + jest + .spyOn(repository, 'updateOne') + .mockResolvedValue({ modifiedCount: 1 } as any); + + const result = await service.updateRole({ workspaceId, userId, role }); + + expect(result).toEqual({ modifiedCount: 1 }); + expect(repository.updateOne).toHaveBeenCalledWith( + { workspaceId, userId }, + { $set: { role } }, + ); + }); + }); + + describe('deleteMember', () => { + it('should delete a workspace member', async () => { + const workspaceId = 'workspaceId'; + const userId = 'userId'; + + jest + .spyOn(repository, 'deleteOne') + .mockResolvedValue({ deletedCount: 1 } as any); + + const result = await service.deleteMember({ workspaceId, userId }); + + expect(result).toEqual({ deletedCount: 1 }); + expect(repository.deleteOne).toHaveBeenCalledWith({ + workspaceId, + userId, + }); + }); + }); +}); diff --git a/server/src/modules/workspace/controllers/workspace.controller.ts b/server/src/modules/workspace/controllers/workspace.controller.ts new file mode 100644 index 00000000..81196830 --- /dev/null +++ b/server/src/modules/workspace/controllers/workspace.controller.ts @@ -0,0 +1,329 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, + Request, + SetMetadata, + HttpCode, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import moment from 'moment'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { WorkspaceGuard } from 'src/guards/workspace.guard'; + +import { WorkspaceService } from '../services/workspace.service'; +import { WorkspaceMemberService } from '../services/workspaceMember.service'; + +import { CreateWorkspaceDto } from '../dto/createWorkspace.dto'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { + ROLE as WORKSPACE_ROLE, + PERMISSION as WORKSPACE_PERMISSION, + ROLE_PERMISSION as WORKSPACE_ROLE_PERMISSION, +} from 'src/enums/workspace'; +import { splitMembers } from '../utils/splitMember'; +import { UserService } from 'src/modules/auth/services/user.service'; +import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; +import { Logger } from 'src/logger'; + +@ApiTags('workspace') +@ApiBearerAuth() +@UseGuards(Authentication) +@Controller('/api/workspace') +export class WorkspaceController { + constructor( + private readonly workspaceService: WorkspaceService, + private readonly workspaceMemberService: WorkspaceMemberService, + private readonly userService: UserService, + private readonly surveyMetaService: SurveyMetaService, + private readonly logger: Logger, + ) {} + + @Get('getRoleList') + @HttpCode(200) + async getRoleList() { + const rolePermissions = Object.values(WORKSPACE_ROLE_PERMISSION); + return { + code: 200, + data: rolePermissions, + }; + } + + @Post() + @HttpCode(200) + async create(@Body() workspace: CreateWorkspaceDto, @Request() req) { + const { value, error } = CreateWorkspaceDto.validate(workspace); + if (error) { + this.logger.error( + `CreateWorkspaceDto validate failed: ${error.message}`, + { req }, + ); + throw new HttpException( + `参数错误: 请联系管理员`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + + if (Array.isArray(value.members) && value.members.length > 0) { + // 校验用户是否真实存在 + const userIdList = value.members.map((item) => item.userId); + // 不能有重复的userId + const userIdSet = new Set(userIdList); + if (userIdList.length !== Array.from(userIdSet).length) { + throw new HttpException( + '不能重复添加用户', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const userList = await this.userService.getUserListByIds({ + idList: userIdList, + }); + const userInfoMap = userList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + for (const member of value.members) { + if (!userInfoMap[member.userId]) { + throw new HttpException( + `用户id: {${member.userId}} 不存在`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + } + } + const userId = req.user._id.toString(); + // 插入空间表 + const retWorkspace = await this.workspaceService.create({ + name: value.name, + description: value.description, + ownerId: userId, + }); + const workspaceId = retWorkspace._id.toString(); + // 空间的成员表要新增一条管理员数据 + await this.workspaceMemberService.create({ + userId, + workspaceId, + role: WORKSPACE_ROLE.ADMIN, + }); + if (Array.isArray(value.members) && value.members.length > 0) { + await this.workspaceMemberService.batchCreate({ + workspaceId, + members: value.members, + }); + } + return { + code: 200, + data: { + workspaceId, + }, + }; + } + + @Get() + @HttpCode(200) + async findAll(@Request() req) { + const userId = req.user._id.toString(); + // 查询当前用户参与的空间 + const workspaceInfoList = await this.workspaceMemberService.findAllByUserId( + { userId }, + ); + const workspaceIdList = workspaceInfoList.map((item) => item.workspaceId); + const workspaceInfoMap = workspaceInfoList.reduce((pre, cur) => { + pre[cur.workspaceId] = cur; + return pre; + }, {}); + // 查询当前用户的空间列表 + const list = await this.workspaceService.findAllById({ workspaceIdList }); + const ownerIdList = list.map((item) => item.ownerId); + const userList = await this.userService.getUserListByIds({ + idList: ownerIdList, + }); + const userInfoMap = userList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + const surveyTotalList = await Promise.all( + workspaceIdList.map((item) => { + return this.surveyMetaService.countSurveyMetaByWorkspaceId({ + workspaceId: item, + }); + }), + ); + const surveyTotalMap = workspaceIdList.reduce((pre, cur, index) => { + const total = surveyTotalList[index]; + pre[cur] = total; + return pre; + }, {}); + + const memberTotalList = await Promise.all( + workspaceIdList.map((item) => { + return this.workspaceMemberService.countByWorkspaceId({ + workspaceId: item, + }); + }), + ); + const memberTotalMap = workspaceIdList.reduce((pre, cur, index) => { + const total = memberTotalList[index]; + pre[cur] = total; + return pre; + }, {}); + + return { + code: 200, + data: { + list: list.map((item) => { + const workspaceId = item._id.toString(); + const curWorkspaceInfo = workspaceInfoMap?.[workspaceId] || {}; + const ownerInfo = userInfoMap?.[item.ownerId] || {}; + return { + ...item, + createDate: moment(item.createDate).format('YYYY-MM-DD HH:mm:ss'), + owner: ownerInfo.username, + currentUserId: curWorkspaceInfo.userId, + currentUserRole: curWorkspaceInfo.role, + surveyTotal: surveyTotalMap[workspaceId] || 0, + memberTotal: memberTotalMap[workspaceId] || 0, + }; + }), + }, + }; + } + + @Get(':id') + @HttpCode(200) + @UseGuards(WorkspaceGuard) + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.READ_WORKSPACE]) + @SetMetadata('workspaceId', 'params.id') + async getWorkspaceInfo(@Param('id') workspaceId: string, @Request() req) { + const workspaceInfo = await this.workspaceService.findOneById(workspaceId); + const members = await this.workspaceMemberService.findAllByWorkspaceId({ + workspaceId, + }); + const memberInfoMap = members.reduce((pre, cur) => { + cur[cur.userId] = cur; + return pre; + }, {}); + const userIdList = members.map((item) => item.userId); + const userList = await this.userService.getUserListByIds({ + idList: userIdList, + }); + const userInfoMap = userList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + const currentUserId = req.user._id.toString(); + return { + code: 200, + data: { + _id: workspaceInfo._id, + name: workspaceInfo.name, + description: workspaceInfo.description, + currentUserId, + currentUserRole: memberInfoMap?.[currentUserId]?.role, + members: members.map((item) => { + return { + _id: item._id, + userId: item.userId, + role: item.role, + username: userInfoMap[item.userId].username, + }; + }), + }, + }; + } + + @Post(':id') + @HttpCode(200) + @UseGuards(WorkspaceGuard) + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_WORKSPACE]) + @SetMetadata('workspaceId', 'params.id') + async update(@Param('id') id: string, @Body() workspace: CreateWorkspaceDto) { + const members = workspace.members; + if (!Array.isArray(members) || members.length === 0) { + throw new HttpException('成员不能为空', EXCEPTION_CODE.PARAMETER_ERROR); + } + delete workspace.members; + const updateRes = await this.workspaceService.update(id, workspace); + this.logger.info(`updateRes: ${JSON.stringify(updateRes)}`); + const { newMembers, adminMembers, userMembers } = splitMembers(members); + if ( + adminMembers.length === 0 && + !newMembers.some((item) => item.role === WORKSPACE_ROLE.ADMIN) + ) { + throw new HttpException( + '空间不能没有管理员', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const allUserIdList = members.map((item) => item.userId); + // 不能有重复的userId + const allUserIdSet = new Set(allUserIdList); + if (allUserIdList.length !== Array.from(allUserIdSet).length) { + throw new HttpException( + '不能重复添加用户', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + // 检查所有成员是否真实存在 + const allUserList = await this.userService.getUserListByIds({ + idList: allUserIdList, + }); + const allUserInfoMap = allUserList.reduce((pre, cur) => { + const id = cur._id.toString(); + pre[id] = cur; + return pre; + }, {}); + for (const member of members) { + if (!allUserInfoMap[member.userId]) { + throw new HttpException( + `用户id: {${member.userId}} 不存在`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + } + + const allIds = [...adminMembers, ...userMembers]; + // 新增和更新成员,把数据库里已删除的成员删掉 + const res = await Promise.all([ + this.workspaceMemberService.batchDelete({ idList: [], neIdList: allIds }), + this.workspaceMemberService.batchCreate({ + workspaceId: id, + members: newMembers, + }), + this.workspaceMemberService.batchUpdate({ + idList: adminMembers, + role: WORKSPACE_ROLE.ADMIN, + }), + this.workspaceMemberService.batchUpdate({ + idList: userMembers, + role: WORKSPACE_ROLE.USER, + }), + ]); + this.logger.info(`updateRes: ${JSON.stringify(res)}`); + return { + code: 200, + }; + } + + @Delete(':id') + @HttpCode(200) + @UseGuards(WorkspaceGuard) + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_WORKSPACE]) + @SetMetadata('workspaceId', 'params.id') + async delete(@Param('id') id: string) { + const res = await this.workspaceService.delete(id); + this.logger.info(`res: ${JSON.stringify(res)}`); + return { + code: 200, + }; + } +} diff --git a/server/src/modules/workspace/controllers/workspaceMember.controller.ts b/server/src/modules/workspace/controllers/workspaceMember.controller.ts new file mode 100644 index 00000000..5dab6caa --- /dev/null +++ b/server/src/modules/workspace/controllers/workspaceMember.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Get, + Post, + Body, + UseGuards, + Request, + SetMetadata, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { WorkspaceGuard } from 'src/guards/workspace.guard'; + +import { WorkspaceMemberService } from '../services/workspaceMember.service'; + +import { CreateWorkspaceMemberDto } from '../dto/createWorkspaceMember.dto'; +import { UpdateWorkspaceMemberDto } from '../dto/updateWorkspaceMember.dto'; +import { DeleteWorkspaceMemberDto } from '../dto/deleteWorkspaceMember.dto'; + +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; + +@ApiTags('workspaceMember') +@ApiBearerAuth() +@UseGuards(WorkspaceGuard) +@UseGuards(Authentication) +@Controller('/api/workspaceMember') +export class WorkspaceMemberController { + constructor( + private readonly workspaceMemberService: WorkspaceMemberService, + ) {} + + @Post() + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_MEMBER]) + @SetMetadata('workspaceId', 'body.workspaceId') + async create(@Body() member: CreateWorkspaceMemberDto) { + const { error, value } = CreateWorkspaceMemberDto.validate(member); + if (error) { + throw new HttpException( + `参数错误: ${error.message}`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const { workspaceId, role, userId } = value; + const res = await this.workspaceMemberService.create({ + userId, + workspaceId, + role, + }); + return { + code: 200, + data: { + memberId: res._id.toString(), + }, + }; + } + + @Get() + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.READ_MEMBER]) + @SetMetadata('workspaceId', 'query.workspaceId') + async findAll(@Request() req) { + const workspaceId = req.query.workspaceId; + if (!workspaceId) { + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const list = await this.workspaceMemberService.findAllByWorkspaceId({ + workspaceId, + }); + return { + code: 200, + data: list, + }; + } + + @Post('updateRole') + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_MEMBER]) + @SetMetadata('workspaceId', 'body.workspaceId') + async updateRole(@Body() updateDto: UpdateWorkspaceMemberDto) { + const { error, value } = UpdateWorkspaceMemberDto.validate(updateDto); + if (error) { + throw new HttpException( + `参数错误: ${error.message}`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const updateRes = await this.workspaceMemberService.updateRole({ + role: value.role, + workspaceId: value.workspaceId, + userId: value.userId, + }); + return { + code: 200, + data: { + modifiedCount: updateRes.modifiedCount, + }, + }; + } + + @Post('deleteMember') + @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_MEMBER]) + @SetMetadata('workspaceId', 'body.id') + async delete(@Body() deleteDto: DeleteWorkspaceMemberDto) { + const { value, error } = DeleteWorkspaceMemberDto.validate(deleteDto); + if (error) { + throw new HttpException( + `参数错误: ${error.message}`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const res = await this.workspaceMemberService.deleteMember({ ...value }); + return { + code: 200, + data: { + deletedCount: res.deletedCount, + }, + }; + } +} diff --git a/server/src/modules/workspace/dto/createWorkspace.dto.ts b/server/src/modules/workspace/dto/createWorkspace.dto.ts new file mode 100644 index 00000000..799ca5e2 --- /dev/null +++ b/server/src/modules/workspace/dto/createWorkspace.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; + +export class CreateWorkspaceDto { + @ApiProperty({ description: '空间名称', required: true }) + name: string; + + @ApiProperty({ description: '空间描述', required: false }) + description?: string; + + @ApiProperty({ description: '空间成员', required: true }) + members: Array<{ userId: string; role: WORKSPACE_ROLE; _id?: string }>; + + static validate(data) { + return Joi.object({ + name: Joi.string().required(), + description: Joi.string().allow(null, ''), + members: Joi.array() + .allow(null) + .items( + Joi.object({ + userId: Joi.string().required(), + role: Joi.string().valid(WORKSPACE_ROLE.ADMIN, WORKSPACE_ROLE.USER), + }), + ), + }).validate(data, { allowUnknown: true }); + } +} diff --git a/server/src/modules/workspace/dto/createWorkspaceMember.dto.ts b/server/src/modules/workspace/dto/createWorkspaceMember.dto.ts new file mode 100644 index 00000000..f21eedf6 --- /dev/null +++ b/server/src/modules/workspace/dto/createWorkspaceMember.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; + +export class CreateWorkspaceMemberDto { + @ApiProperty({ description: '空间角色', required: true }) + role: string; + + @ApiProperty({ description: '空间id', required: false }) + workspaceId: string; + + @ApiProperty({ description: '用户id', required: true }) + userId: string; + + static validate(data) { + return Joi.object({ + role: Joi.string() + .valid(WORKSPACE_ROLE.ADMIN, WORKSPACE_ROLE.USER) + .required(), + workspaceId: Joi.string().required(), + userId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/workspace/dto/deleteWorkspaceMember.dto.ts b/server/src/modules/workspace/dto/deleteWorkspaceMember.dto.ts new file mode 100644 index 00000000..95ef0354 --- /dev/null +++ b/server/src/modules/workspace/dto/deleteWorkspaceMember.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class DeleteWorkspaceMemberDto { + @ApiProperty({ description: '空间id', required: false }) + workspaceId: string; + + @ApiProperty({ description: '用户id', required: false }) + userId: string; + + static validate(data) { + return Joi.object({ + workspaceId: Joi.string().required(), + userId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/workspace/dto/updateWorkspaceMember.dto.ts b/server/src/modules/workspace/dto/updateWorkspaceMember.dto.ts new file mode 100644 index 00000000..275ffa09 --- /dev/null +++ b/server/src/modules/workspace/dto/updateWorkspaceMember.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; + +export class UpdateWorkspaceMemberDto { + @ApiProperty({ description: '空间角色', required: true }) + role: string; + + @ApiProperty({ description: '空间id', required: false }) + workspaceId: string; + + @ApiProperty({ description: '用户id', required: false }) + userId: string; + + static validate(data) { + return Joi.object({ + role: Joi.string() + .valid(WORKSPACE_ROLE.ADMIN, WORKSPACE_ROLE.USER) + .required(), + workspaceId: Joi.string().required(), + userId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/workspace/services/workspace.service.ts b/server/src/modules/workspace/services/workspace.service.ts new file mode 100644 index 00000000..aaf37f69 --- /dev/null +++ b/server/src/modules/workspace/services/workspace.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; + +import { Workspace } from 'src/models/workspace.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; + +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from 'src/enums'; + +@Injectable() +export class WorkspaceService { + constructor( + @InjectRepository(Workspace) + private workspaceRepository: MongoRepository, + @InjectRepository(SurveyMeta) + private surveyMetaRepository: MongoRepository, + ) {} + + async create(workspace: { + name: string; + description: string; + ownerId: string; + }): Promise { + const newWorkspace = this.workspaceRepository.create({ + ...workspace, + }); + return this.workspaceRepository.save(newWorkspace); + } + + async findOneById(id) { + return this.workspaceRepository.findOne({ + where: { + _id: new ObjectId(id), + }, + }); + } + + async findAllById({ + workspaceIdList, + }: { + workspaceIdList: string[]; + }): Promise { + return this.workspaceRepository.find({ + where: { + _id: { + $in: workspaceIdList.map((item) => new ObjectId(item)), + }, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + order: { + _id: -1, + }, + select: [ + '_id', + 'curStatus', + 'name', + 'description', + 'ownerId', + 'createDate', + ], + }); + } + + update(id: string, workspace: Partial) { + return this.workspaceRepository.update(id, workspace); + } + + async delete(id: string) { + const newStatus = { + status: RECORD_STATUS.REMOVED, + date: Date.now(), + }; + const workspaceRes = await this.workspaceRepository.updateOne( + { + _id: new ObjectId(id), + }, + { + $set: { + curStatus: newStatus, + }, + $push: { + statusList: newStatus as never, + }, + }, + ); + const surveyRes = await this.surveyMetaRepository.updateMany( + { + workspaceId: id, + }, + { + $set: { + curStatus: newStatus, + }, + $push: { + statusList: newStatus as never, + }, + }, + ); + return { + workspaceRes, + surveyRes, + }; + } +} diff --git a/server/src/modules/workspace/services/workspaceMember.service.ts b/server/src/modules/workspace/services/workspaceMember.service.ts new file mode 100644 index 00000000..f4535f75 --- /dev/null +++ b/server/src/modules/workspace/services/workspaceMember.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from 'src/enums'; + +@Injectable() +export class WorkspaceMemberService { + constructor( + @InjectRepository(WorkspaceMember) + private workspaceMemberRepository: MongoRepository, + ) {} + + async create(member: { + role: string; + userId: string; + workspaceId: string; + }): Promise { + const newMember = this.workspaceMemberRepository.create(member); + return this.workspaceMemberRepository.save(newMember); + } + + async batchCreate({ + workspaceId, + members, + }: { + workspaceId: string; + members: Array<{ userId: string; role: string }>; + }) { + if (members.length === 0) { + return { + insertedCount: 0, + }; + } + const dataToInsert = members.map((item) => { + return { + ...item, + workspaceId, + }; + }); + return this.workspaceMemberRepository.insertMany(dataToInsert); + } + + async batchUpdate({ idList, role }: { idList: Array; role: string }) { + if (idList.length === 0) { + return { + modifiedCount: 0, + }; + } + return this.workspaceMemberRepository.updateMany( + { + _id: { + $in: idList.map((item) => new ObjectId(item)), + }, + }, + { + $set: { + role, + }, + }, + ); + } + + async batchDelete({ + idList, + neIdList, + }: { + idList: Array; + neIdList: Array; + }) { + if (idList.length === 0 || neIdList.length === 0) { + return { + modifiedCount: 0, + }; + } + return this.workspaceMemberRepository.deleteMany({ + _id: { + $in: idList.map((item) => new ObjectId(item)), + $nin: neIdList.map((item) => new ObjectId(item)), + }, + }); + } + + async findAllByUserId({ userId }): Promise { + return this.workspaceMemberRepository.find({ + where: { + userId, + }, + }); + } + + async findAllByWorkspaceId({ workspaceId }): Promise { + return this.workspaceMemberRepository.find({ + where: { + workspaceId, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + select: ['_id', 'createDate', 'curStatus', 'role', 'userId'], + }); + } + + async findOne({ workspaceId, userId }): Promise { + return this.workspaceMemberRepository.findOne({ + where: { + workspaceId, + userId, + }, + }); + } + + async updateRole({ workspaceId, userId, role }) { + return this.workspaceMemberRepository.updateOne( + { + workspaceId, + userId, + }, + { + $set: { + role, + }, + }, + ); + } + + async deleteMember({ workspaceId, userId }) { + return this.workspaceMemberRepository.deleteOne({ + workspaceId, + userId, + }); + } + + async countByWorkspaceId({ workspaceId }) { + return this.workspaceMemberRepository.count({ + workspaceId, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }); + } +} diff --git a/server/src/modules/workspace/utils/splitMember.ts b/server/src/modules/workspace/utils/splitMember.ts new file mode 100644 index 00000000..4f3fe615 --- /dev/null +++ b/server/src/modules/workspace/utils/splitMember.ts @@ -0,0 +1,26 @@ +import { ROLE as WORKSPACE_ROLE } from 'src/enums/workspace'; +export type Member = { + userId: string; + role: string; + _id?: string; +}; + +export const splitMembers = (members: Array) => { + const newMembers = [], + adminMembers = [], + userMembers = []; + for (const member of members) { + if (!member._id) { + newMembers.push(member); + } else if (member.role === WORKSPACE_ROLE.ADMIN) { + adminMembers.push(member._id); + } else { + userMembers.push(member._id); + } + } + return { + newMembers, + adminMembers, + userMembers, + }; +}; diff --git a/server/src/modules/workspace/workspace.module.ts b/server/src/modules/workspace/workspace.module.ts new file mode 100644 index 00000000..5807836c --- /dev/null +++ b/server/src/modules/workspace/workspace.module.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; + +import { WorkspaceService } from './services/workspace.service'; +import { WorkspaceMemberService } from './services/workspaceMember.service'; +import { SurveyMetaService } from '../survey/services/surveyMeta.service'; + +import { WorkspaceController } from './controllers/workspace.controller'; + +import { Workspace } from 'src/models/workspace.entity'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; + +import { AuthModule } from '../auth/auth.module'; + +import { LoggerProvider } from 'src/logger/logger.provider'; +import { WorkspaceGuard } from 'src/guards/workspace.guard'; +import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace, WorkspaceMember, SurveyMeta]), + ConfigModule, + AuthModule, + ], + controllers: [WorkspaceController], + providers: [ + WorkspaceService, + WorkspaceMemberService, + LoggerProvider, + WorkspaceGuard, + SurveyMetaService, + PluginManagerProvider, + ], + exports: [WorkspaceMemberService], +}) +export class WorkspaceModule {}