feat(workspace): add inviting to workspace

This commit is contained in:
Karol Sójko 2022-10-10 11:38:53 +02:00
parent 8a72a1a559
commit e06cc3ba80
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
20 changed files with 325 additions and 5 deletions

1
.pnp.cjs generated
View file

@ -3218,6 +3218,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
["@standardnotes/security", "workspace:packages/security"],\
["@standardnotes/time", "workspace:packages/time"],\
["@types/cors", "npm:2.8.12"],\
["@types/express", "npm:4.17.13"],\
["@types/ioredis", "npm:4.28.10"],\

View file

@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class addInvites1665394559520 implements MigrationInterface {
name = 'addInvites1665394559520'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE TABLE `workspace_invites` (`uuid` varchar(36) NOT NULL, `inviter_uuid` varchar(36) NOT NULL, `invitee_email` varchar(255) NOT NULL, `status` varchar(64) NOT NULL, `accepting_user_uuid` varchar(36) NULL, `workspace_uuid` varchar(36) NOT NULL, `created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, PRIMARY KEY (`uuid`)) ENGINE=InnoDB',
)
await queryRunner.query('ALTER TABLE `workspaces` ADD `created_at` bigint NOT NULL')
await queryRunner.query('ALTER TABLE `workspaces` ADD `updated_at` bigint NOT NULL')
await queryRunner.query('ALTER TABLE `workspace_users` ADD `created_at` bigint NOT NULL')
await queryRunner.query('ALTER TABLE `workspace_users` ADD `updated_at` bigint NOT NULL')
await queryRunner.query(
'ALTER TABLE `workspace_invites` ADD CONSTRAINT `FK_782df40d03151dd3998acd0a6ba` FOREIGN KEY (`workspace_uuid`) REFERENCES `workspaces`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION',
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `workspace_invites` DROP FOREIGN KEY `FK_782df40d03151dd3998acd0a6ba`')
await queryRunner.query('ALTER TABLE `workspace_users` DROP COLUMN `updated_at`')
await queryRunner.query('ALTER TABLE `workspace_users` DROP COLUMN `created_at`')
await queryRunner.query('ALTER TABLE `workspaces` DROP COLUMN `updated_at`')
await queryRunner.query('ALTER TABLE `workspaces` DROP COLUMN `created_at`')
await queryRunner.query('DROP TABLE `workspace_invites`')
}
}

View file

@ -30,6 +30,7 @@
"@standardnotes/domain-events": "workspace:^",
"@standardnotes/domain-events-infra": "workspace:^",
"@standardnotes/security": "workspace:*",
"@standardnotes/time": "workspace:^",
"aws-sdk": "^2.1159.0",
"cors": "2.8.5",
"dotenv": "^16.0.1",

View file

@ -7,6 +7,7 @@ import {
DomainEventMessageHandlerInterface,
DomainEventSubscriberFactoryInterface,
} from '@standardnotes/domain-events'
import { TimerInterface, Timer } from '@standardnotes/time'
import { Env } from './Env'
import TYPES from './Types'
import { AppDataSource } from './DataSource'
@ -31,6 +32,10 @@ import { WorkspaceUser } from '../Domain/Workspace/WorkspaceUser'
import { CreateWorkspace } from '../Domain/UseCase/CreateWorkspace/CreateWorkspace'
import { WorkspacesController } from '../Controller/WorkspacesController'
import { UserRegisteredEventHandler } from '../Domain/Handler/UserRegisteredEventHandler'
import { WorkspaceInviteRepositoryInterface } from '../Domain/Invite/WorkspaceInviteRepositoryInterface'
import { MySQLWorkspaceInviteRepository } from '../Infra/MySQL/MySQLWorkspaceInviteRepository'
import { WorkspaceInvite } from '../Domain/Invite/WorkspaceInvite'
import { InviteToWorkspace } from '../Domain/UseCase/InviteToWorkspace/InviteToWorkspace'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@ -96,6 +101,9 @@ export class ContainerConfigLoader {
// Repositories
container.bind<WorkspaceRepositoryInterface>(TYPES.WorkspaceRepository).to(MySQLWorkspaceRepository)
container.bind<WorkspaceUserRepositoryInterface>(TYPES.WorkspaceUserRepository).to(MySQLWorkspaceUserRepository)
container
.bind<WorkspaceInviteRepositoryInterface>(TYPES.WorkspaceInviteRepository)
.to(MySQLWorkspaceInviteRepository)
// ORM
container
.bind<Repository<Workspace>>(TYPES.ORMWorkspaceRepository)
@ -103,6 +111,9 @@ export class ContainerConfigLoader {
container
.bind<Repository<WorkspaceUser>>(TYPES.ORMWorkspaceUserRepository)
.toConstantValue(AppDataSource.getRepository(WorkspaceUser))
container
.bind<Repository<WorkspaceInvite>>(TYPES.ORMWorkspaceInviteRepository)
.toConstantValue(AppDataSource.getRepository(WorkspaceInvite))
// Middleware
container.bind<ApiGatewayAuthMiddleware>(TYPES.ApiGatewayAuthMiddleware).to(ApiGatewayAuthMiddleware)
// env vars
@ -117,9 +128,11 @@ export class ContainerConfigLoader {
// use cases
container.bind<CreateWorkspace>(TYPES.CreateWorkspace).to(CreateWorkspace)
container.bind<InviteToWorkspace>(TYPES.InviteToWorkspace).to(InviteToWorkspace)
// Handlers
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
// Services
container.bind<TimerInterface>(TYPES.Timer).toConstantValue(new Timer())
container
.bind<TokenDecoderInterface<CrossServiceTokenData>>(TYPES.CrossServiceTokenDecoder)
.toConstantValue(new TokenDecoder<CrossServiceTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
@ -136,7 +149,9 @@ export class ContainerConfigLoader {
)
}
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([])
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['USER_REGISTERED', container.get(TYPES.UserRegisteredEventHandler)],
])
if (env.get('SQS_QUEUE_URL', true)) {
container

View file

@ -1,4 +1,5 @@
import { DataSource, LoggerOptions } from 'typeorm'
import { WorkspaceInvite } from '../Domain/Invite/WorkspaceInvite'
import { Workspace } from '../Domain/Workspace/Workspace'
import { WorkspaceUser } from '../Domain/Workspace/WorkspaceUser'
import { Env } from './Env'
@ -34,7 +35,7 @@ export const AppDataSource = new DataSource({
],
removeNodeErrorCount: 10,
},
entities: [Workspace, WorkspaceUser],
entities: [Workspace, WorkspaceUser, WorkspaceInvite],
migrations: [env.get('DB_MIGRATIONS_PATH', true) ?? 'dist/migrations/*.js'],
migrationsRun: true,
logging: <LoggerOptions>env.get('DB_DEBUG_LEVEL'),

View file

@ -8,9 +8,11 @@ const TYPES = {
// Repositories
WorkspaceRepository: Symbol.for('WorkspaceRepository'),
WorkspaceUserRepository: Symbol.for('WorkspaceUserRepository'),
WorkspaceInviteRepository: Symbol.for('WorkspaceInviteRepository'),
// ORM
ORMWorkspaceRepository: Symbol.for('ORMWorkspaceRepository'),
ORMWorkspaceUserRepository: Symbol.for('ORMWorkspaceUserRepository'),
ORMWorkspaceInviteRepository: Symbol.for('ORMWorkspaceInviteRepository'),
// Middleware
ApiGatewayAuthMiddleware: Symbol.for('ApiGatewayAuthMiddleware'),
// env vars
@ -25,9 +27,11 @@ const TYPES = {
VERSION: Symbol.for('VERSION'),
// use cases
CreateWorkspace: Symbol.for('CreateWorkspace'),
InviteToWorkspace: Symbol.for('InviteToWorkspace'),
// Handlers
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
// Services
Timer: Symbol.for('Timer'),
CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'),
DomainEventPublisher: Symbol.for('DomainEventPublisher'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),

View file

@ -0,0 +1,68 @@
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
import { Workspace } from '../Workspace/Workspace'
import { WorkspaceInviteStatus } from './WorkspaceInviteStatus'
@Entity({ name: 'workspace_invites' })
export class WorkspaceInvite {
@PrimaryGeneratedColumn('uuid')
declare uuid: string
@Column({
name: 'inviter_uuid',
length: 36,
})
declare inviterUuid: string
@Column({
name: 'invitee_email',
length: 255,
})
declare inviteeEmail: string
@Column({
name: 'status',
type: 'varchar',
length: 64,
})
declare status: WorkspaceInviteStatus
@Column({
name: 'accepting_user_uuid',
type: 'varchar',
length: 36,
nullable: true,
})
declare acceptingUserUuid: string | null
@Column({
name: 'workspace_uuid',
length: 36,
})
declare workspaceUuid: string
@Column({
name: 'created_at',
type: 'bigint',
})
declare createdAt: number
@Column({
name: 'updated_at',
type: 'bigint',
})
declare updatedAt: number
@ManyToOne(
/* istanbul ignore next */
() => Workspace,
/* istanbul ignore next */
(workspace) => workspace.invites,
/* istanbul ignore next */
{ onDelete: 'CASCADE' },
)
@JoinColumn(
/* istanbul ignore next */
{ name: 'workspace_uuid' },
)
declare workspace: Promise<Workspace>
}

View file

@ -0,0 +1,5 @@
import { WorkspaceInvite } from './WorkspaceInvite'
export interface WorkspaceInviteRepositoryInterface {
save(workspace: WorkspaceInvite): Promise<WorkspaceInvite>
}

View file

@ -0,0 +1,4 @@
export enum WorkspaceInviteStatus {
Created = 'created',
Accepted = 'accepted',
}

View file

@ -6,14 +6,19 @@ import { WorkspaceRepositoryInterface } from '../../Workspace/WorkspaceRepositor
import { WorkspaceUserRepositoryInterface } from '../../Workspace/WorkspaceUserRepositoryInterface'
import { CreateWorkspace } from './CreateWorkspace'
import { TimerInterface } from '@standardnotes/time'
describe('CreateWorkspace', () => {
let workspaceRepository: WorkspaceRepositoryInterface
let workspaceUserRepository: WorkspaceUserRepositoryInterface
let timer: TimerInterface
const createUseCase = () => new CreateWorkspace(workspaceRepository, workspaceUserRepository)
const createUseCase = () => new CreateWorkspace(workspaceRepository, workspaceUserRepository, timer)
beforeEach(() => {
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
workspaceRepository = {} as jest.Mocked<WorkspaceRepositoryInterface>
workspaceRepository.save = jest.fn().mockImplementation((workspace) => {
return {
@ -39,6 +44,8 @@ describe('CreateWorkspace', () => {
expect(workspaceRepository.save).toHaveBeenCalledWith({
name: 'A Team',
type: 'root',
createdAt: 1,
updatedAt: 1,
})
expect(workspaceUserRepository.save).toHaveBeenCalledWith({
accessLevel: 'owner',
@ -48,6 +55,8 @@ describe('CreateWorkspace', () => {
status: 'active',
userUuid: '1-2-3',
workspaceUuid: 'w-1-2-3',
createdAt: 1,
updatedAt: 1,
})
})
@ -59,12 +68,16 @@ describe('CreateWorkspace', () => {
expect(workspaceRepository.save).toHaveBeenCalledWith({
type: 'private',
createdAt: 1,
updatedAt: 1,
})
expect(workspaceUserRepository.save).toHaveBeenCalledWith({
accessLevel: 'owner',
status: 'active',
userUuid: '1-2-3',
workspaceUuid: 'w-1-2-3',
createdAt: 1,
updatedAt: 1,
})
})
})

View file

@ -1,3 +1,4 @@
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
@ -17,6 +18,7 @@ export class CreateWorkspace implements UseCaseInterface {
constructor(
@inject(TYPES.WorkspaceRepository) private workspaceRepository: WorkspaceRepositoryInterface,
@inject(TYPES.WorkspaceUserRepository) private workspaceUserRepository: WorkspaceUserRepositoryInterface,
@inject(TYPES.Timer) private timer: TimerInterface,
) {}
async execute(dto: CreateWorkspaceDTO): Promise<CreateWorkspaceResponse> {
@ -25,6 +27,9 @@ export class CreateWorkspace implements UseCaseInterface {
workspace.name = dto.name
}
workspace.type = dto.type
const timestamp = this.timer.getTimestampInMicroseconds()
workspace.createdAt = timestamp
workspace.updatedAt = timestamp
workspace = await this.workspaceRepository.save(workspace)
@ -42,6 +47,8 @@ export class CreateWorkspace implements UseCaseInterface {
ownerAssociation.status = WorkspaceUserStatus.Active
ownerAssociation.userUuid = dto.ownerUuid
ownerAssociation.workspaceUuid = workspace.uuid
ownerAssociation.createdAt = timestamp
ownerAssociation.updatedAt = timestamp
await this.workspaceUserRepository.save(ownerAssociation)

View file

@ -0,0 +1,45 @@
import 'reflect-metadata'
import { TimerInterface } from '@standardnotes/time'
import { WorkspaceInviteRepositoryInterface } from '../../Invite/WorkspaceInviteRepositoryInterface'
import { InviteToWorkspace } from './InviteToWorkspace'
describe('InviteToWorkspace', () => {
let workspaceInviteRepository: WorkspaceInviteRepositoryInterface
let timer: TimerInterface
const createUseCase = () => new InviteToWorkspace(workspaceInviteRepository, timer)
beforeEach(() => {
workspaceInviteRepository = {} as jest.Mocked<WorkspaceInviteRepositoryInterface>
workspaceInviteRepository.save = jest.fn().mockImplementation((invite) => {
return {
...invite,
uuid: 'i-1-2-3',
}
})
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1)
})
it('should create an invite', async () => {
const result = await createUseCase().execute({
inviteeEmail: 'test@test.te',
inviterUuid: 'u-1-2-3',
workspaceUuid: 'w-1-2-3',
})
expect(result).toEqual({ uuid: 'i-1-2-3' })
expect(workspaceInviteRepository.save).toHaveBeenCalledWith({
inviterUuid: 'u-1-2-3',
inviteeEmail: 'test@test.te',
workspaceUuid: 'w-1-2-3',
status: 'created',
createdAt: 1,
updatedAt: 1,
})
})
})

View file

@ -0,0 +1,37 @@
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { WorkspaceInvite } from '../../Invite/WorkspaceInvite'
import { WorkspaceInviteRepositoryInterface } from '../../Invite/WorkspaceInviteRepositoryInterface'
import { WorkspaceInviteStatus } from '../../Invite/WorkspaceInviteStatus'
import { UseCaseInterface } from '../UseCaseInterface'
import { InviteToWorkspaceDTO } from './InviteToWorkspaceDTO'
import { InviteToWorkspaceResponse } from './InviteToWorkspaceResponse'
@injectable()
export class InviteToWorkspace implements UseCaseInterface {
constructor(
@inject(TYPES.WorkspaceInviteRepository) private workspaceInviteRepository: WorkspaceInviteRepositoryInterface,
@inject(TYPES.Timer) private timer: TimerInterface,
) {}
async execute(dto: InviteToWorkspaceDTO): Promise<InviteToWorkspaceResponse> {
let invite = new WorkspaceInvite()
invite.inviterUuid = dto.inviterUuid
invite.inviteeEmail = dto.inviteeEmail
invite.workspaceUuid = dto.workspaceUuid
invite.status = WorkspaceInviteStatus.Created
const timestamp = this.timer.getTimestampInMicroseconds()
invite.createdAt = timestamp
invite.updatedAt = timestamp
invite = await this.workspaceInviteRepository.save(invite)
return {
uuid: invite.uuid,
}
}
}

View file

@ -0,0 +1,7 @@
import { Uuid } from '@standardnotes/common'
export type InviteToWorkspaceDTO = {
workspaceUuid: Uuid
inviterUuid: Uuid
inviteeEmail: string
}

View file

@ -0,0 +1,3 @@
export type InviteToWorkspaceResponse = {
uuid: string
}

View file

@ -1,5 +1,6 @@
import { WorkspaceType } from '@standardnotes/common'
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'
import { WorkspaceInvite } from '../Invite/WorkspaceInvite'
@Entity({ name: 'workspaces' })
export class Workspace {
@ -23,4 +24,24 @@ export class Workspace {
default: 0,
})
declare keyRotationIndex: number
@Column({
name: 'created_at',
type: 'bigint',
})
declare createdAt: number
@Column({
name: 'updated_at',
type: 'bigint',
})
declare updatedAt: number
@OneToMany(
/* istanbul ignore next */
() => WorkspaceInvite,
/* istanbul ignore next */
(workspaceInvite) => workspaceInvite.workspace,
)
declare invites: Promise<WorkspaceInvite[]>
}

View file

@ -61,4 +61,16 @@ export class WorkspaceUser {
default: 0,
})
declare keyRotationIndex: number
@Column({
name: 'created_at',
type: 'bigint',
})
declare createdAt: number
@Column({
name: 'updated_at',
type: 'bigint',
})
declare updatedAt: number
}

View file

@ -0,0 +1,30 @@
import 'reflect-metadata'
import { Repository, SelectQueryBuilder } from 'typeorm'
import { WorkspaceInvite } from '../../Domain/Invite/WorkspaceInvite'
import { MySQLWorkspaceInviteRepository } from './MySQLWorkspaceInviteRepository'
describe('MySQLWorkspaceInviteRepository', () => {
let ormRepository: Repository<WorkspaceInvite>
let invite: WorkspaceInvite
let queryBuilder: SelectQueryBuilder<WorkspaceInvite>
const createRepository = () => new MySQLWorkspaceInviteRepository(ormRepository)
beforeEach(() => {
invite = {} as jest.Mocked<WorkspaceInvite>
queryBuilder = {} as jest.Mocked<SelectQueryBuilder<WorkspaceInvite>>
ormRepository = {} as jest.Mocked<Repository<WorkspaceInvite>>
ormRepository.save = jest.fn()
ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder)
})
it('should save', async () => {
await createRepository().save(invite)
expect(ormRepository.save).toHaveBeenCalledWith(invite)
})
})

View file

@ -0,0 +1,18 @@
import { inject, injectable } from 'inversify'
import { Repository } from 'typeorm'
import TYPES from '../../Bootstrap/Types'
import { WorkspaceInvite } from '../../Domain/Invite/WorkspaceInvite'
import { WorkspaceInviteRepositoryInterface } from '../../Domain/Invite/WorkspaceInviteRepositoryInterface'
@injectable()
export class MySQLWorkspaceInviteRepository implements WorkspaceInviteRepositoryInterface {
constructor(
@inject(TYPES.ORMWorkspaceInviteRepository)
private ormRepository: Repository<WorkspaceInvite>,
) {}
async save(workspaceInvite: WorkspaceInvite): Promise<WorkspaceInvite> {
return this.ormRepository.save(workspaceInvite)
}
}

View file

@ -2397,7 +2397,7 @@ __metadata:
languageName: unknown
linkType: soft
"@standardnotes/time@workspace:*, @standardnotes/time@workspace:packages/time":
"@standardnotes/time@workspace:*, @standardnotes/time@workspace:^, @standardnotes/time@workspace:packages/time":
version: 0.0.0-use.local
resolution: "@standardnotes/time@workspace:packages/time"
dependencies:
@ -2447,6 +2447,7 @@ __metadata:
"@standardnotes/domain-events": "workspace:^"
"@standardnotes/domain-events-infra": "workspace:^"
"@standardnotes/security": "workspace:*"
"@standardnotes/time": "workspace:^"
"@types/cors": ^2.8.9
"@types/express": ^4.17.11
"@types/ioredis": ^4.28.10