From 66f56e310070e21046ecdf44c34328d9aee1782b Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Fri, 12 Jun 2026 13:56:24 +0530 Subject: [PATCH 1/4] identity hexagonal refactor --- apps/api/src/app/app.module.ts | 12 + .../auth/auth.controller.coverage.spec.ts | 6 +- .../identity/auth/auth.controller.spec.ts | 6 +- .../modules/identity/auth/auth.controller.ts | 11 +- .../identity/auth/platform.guard.spec.ts | 4 +- .../identity/roles/roles.controller.spec.ts | 4 +- .../identity/roles/roles.controller.ts | 6 +- .../roles/roles.controller.visibility.spec.ts | 4 +- .../system-admin.controller.spec.ts | 12 +- .../system-admin/system-admin.controller.ts | 19 +- .../tenants/tenants.controller.spec.ts | 4 +- .../identity/tenants/tenants.controller.ts | 7 +- .../identity/users/users.controller.spec.ts | 258 ++++++++---------- .../identity/users/users.controller.ts | 159 ++--------- apps/worker/package.json | 1 + apps/worker/src/app.module.ts | 11 + packages/auth/src/services/auth.service.ts | 20 +- packages/credentials/src/index.ts | 5 +- .../src/oauth/token-manager.service.ts | 3 +- .../src/oauth/token-refresh.service.ts | 3 +- .../{ => outbound}/better-auth.abac.spec.ts | 10 +- .../better-auth.adapter.spec.ts | 8 +- .../{ => outbound}/better-auth.adapter.ts | 29 +- .../drizzle-permission.repository.spec.ts} | 30 +- .../drizzle-permission.repository.ts} | 10 +- .../drizzle-role.repository.spec.ts} | 10 +- .../drizzle-role.repository.ts} | 10 +- .../drizzle-tenant.repository.spec.ts} | 34 +-- .../drizzle-tenant.repository.ts} | 18 +- .../drizzle-user.repository.spec.ts} | 40 +-- .../drizzle-user.repository.ts} | 22 +- packages/identity/src/better-auth.config.ts | 10 +- packages/identity/src/constants.ts | 8 +- .../ports/outbound/auth-provider.port.ts} | 2 +- .../outbound/better-auth-config.port.ts} | 0 .../ports/outbound/email-provider.port.ts} | 0 .../ports/outbound}/errors.ts | 0 .../ports/outbound/event-publisher.port.ts} | 4 +- .../identity/src/core/ports/outbound/index.ts | 9 + .../outbound/permission-repository.port.ts} | 2 +- .../ports/outbound/role-repository.port.ts} | 2 +- .../ports/outbound/tenant-repository.port.ts} | 2 +- .../ports/outbound}/types.ts | 20 ++ .../ports/outbound/user-repository.port.ts} | 2 +- .../users/create-user.use-case.spec.ts | 27 ++ .../use-cases/users/create-user.use-case.ts | 17 ++ .../users/get-user-by-id.use-case.spec.ts | 50 ++++ .../users/get-user-by-id.use-case.ts | 39 +++ .../users/get-user-profile.use-case.spec.ts | 25 ++ .../users/get-user-profile.use-case.ts | 18 ++ ...st-users-with-invitations.use-case.spec.ts | 57 ++++ .../list-users-with-invitations.use-case.ts | 57 ++++ .../users/remove-user.use-case.spec.ts | 56 ++++ .../use-cases/users/remove-user.use-case.ts | 48 ++++ packages/identity/src/identity.module.spec.ts | 29 +- packages/identity/src/identity.module.ts | 120 ++++---- packages/identity/src/index.spec.ts | 26 +- packages/identity/src/index.ts | 17 +- packages/identity/src/interfaces/index.ts | 9 - .../identity-event-publisher.service.ts | 2 +- pnpm-lock.yaml | 3 + 61 files changed, 887 insertions(+), 550 deletions(-) rename packages/identity/src/adapters/{ => outbound}/better-auth.abac.spec.ts (93%) rename packages/identity/src/adapters/{ => outbound}/better-auth.adapter.spec.ts (98%) rename packages/identity/src/adapters/{ => outbound}/better-auth.adapter.ts (96%) rename packages/identity/src/adapters/{drizzle-permission.adapter.spec.ts => outbound/drizzle-permission.repository.spec.ts} (88%) rename packages/identity/src/adapters/{drizzle-permission.adapter.ts => outbound/drizzle-permission.repository.ts} (93%) rename packages/identity/src/adapters/{drizzle-role.adapter.spec.ts => outbound/drizzle-role.repository.spec.ts} (96%) rename packages/identity/src/adapters/{drizzle-role.adapter.ts => outbound/drizzle-role.repository.ts} (90%) rename packages/identity/src/adapters/{drizzle-tenant.adapter.spec.ts => outbound/drizzle-tenant.repository.spec.ts} (91%) rename packages/identity/src/adapters/{drizzle-tenant.adapter.ts => outbound/drizzle-tenant.repository.ts} (95%) rename packages/identity/src/adapters/{drizzle-user.adapter.spec.ts => outbound/drizzle-user.repository.spec.ts} (91%) rename packages/identity/src/adapters/{drizzle-user.adapter.ts => outbound/drizzle-user.repository.ts} (95%) rename packages/identity/src/{interfaces/auth-provider.interface.ts => core/ports/outbound/auth-provider.port.ts} (95%) rename packages/identity/src/{interfaces/better-auth-config.interface.ts => core/ports/outbound/better-auth-config.port.ts} (100%) rename packages/identity/src/{interfaces/email-provider.interface.ts => core/ports/outbound/email-provider.port.ts} (100%) rename packages/identity/src/{interfaces => core/ports/outbound}/errors.ts (100%) rename packages/identity/src/{interfaces/event-publisher.interface.ts => core/ports/outbound/event-publisher.port.ts} (51%) create mode 100644 packages/identity/src/core/ports/outbound/index.ts rename packages/identity/src/{interfaces/permission-provider.interface.ts => core/ports/outbound/permission-repository.port.ts} (95%) rename packages/identity/src/{interfaces/role-provider.interface.ts => core/ports/outbound/role-repository.port.ts} (96%) rename packages/identity/src/{interfaces/tenant-provider.interface.ts => core/ports/outbound/tenant-repository.port.ts} (97%) rename packages/identity/src/{interfaces => core/ports/outbound}/types.ts (73%) rename packages/identity/src/{interfaces/user-provider.interface.ts => core/ports/outbound/user-repository.port.ts} (97%) create mode 100644 packages/identity/src/core/use-cases/users/create-user.use-case.spec.ts create mode 100644 packages/identity/src/core/use-cases/users/create-user.use-case.ts create mode 100644 packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts create mode 100644 packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts create mode 100644 packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts create mode 100644 packages/identity/src/core/use-cases/users/get-user-profile.use-case.ts create mode 100644 packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.spec.ts create mode 100644 packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts create mode 100644 packages/identity/src/core/use-cases/users/remove-user.use-case.spec.ts create mode 100644 packages/identity/src/core/use-cases/users/remove-user.use-case.ts delete mode 100644 packages/identity/src/interfaces/index.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 6f06c9d6..d2795e8a 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -5,6 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; +import { EncryptionModule } from '@soopa/security'; import { UsersModule } from '../modules/identity/users/users.module.js'; import { TenantsModule } from '../modules/identity/tenants/tenants.module.js'; import { AuthModule } from '@soopa/auth'; @@ -47,6 +48,17 @@ import { PluginsModule } from '../modules/plugins/plugins.module.js'; // ObservabilityModule must be first so pino is active before all other modules // bootstrap and emit their own startup logs. ObservabilityModule, + // Global encryption module + EncryptionModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + mode: cfg.get('INFRA_MODE') === 'local' ? 'local' : 'kms', + encryptionKey: cfg.get('ENCRYPTION_KEY'), + kmsKeyId: cfg.get('KMS_KEY_ID'), + kmsEndpoint: cfg.get('KMS_ENDPOINT'), + }), + }), // Global Redis client — available to all modules via REDIS_CLIENT token CacheModule, // Global SQS queue — available to all modules via QUEUE_SERVICE / QueueService token diff --git a/apps/api/src/modules/identity/auth/auth.controller.coverage.spec.ts b/apps/api/src/modules/identity/auth/auth.controller.coverage.spec.ts index 08760353..44d7cbf2 100644 --- a/apps/api/src/modules/identity/auth/auth.controller.coverage.spec.ts +++ b/apps/api/src/modules/identity/auth/auth.controller.coverage.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller.js'; import { AuthService, AuthGuard } from '@soopa/auth'; -import { USER_PROVIDER, TENANT_PROVIDER } from '@soopa/identity'; +import { USER_REPOSITORY, TENANT_REPOSITORY } from '@soopa/identity'; import { InvitationsService } from '../invitations/invitations.service.js'; import { BadRequestException } from '@nestjs/common'; import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; @@ -75,8 +75,8 @@ describe('AuthController Coverage', () => { controllers: [AuthController], providers: [ { provide: AuthService, useValue: authService }, - { provide: USER_PROVIDER, useValue: userProvider }, - { provide: TENANT_PROVIDER, useValue: tenantProvider }, + { provide: USER_REPOSITORY, useValue: userProvider }, + { provide: TENANT_REPOSITORY, useValue: tenantProvider }, { provide: InvitationsService, useValue: invitationsService }, ], }) diff --git a/apps/api/src/modules/identity/auth/auth.controller.spec.ts b/apps/api/src/modules/identity/auth/auth.controller.spec.ts index 12b5f97a..2e3b81f4 100644 --- a/apps/api/src/modules/identity/auth/auth.controller.spec.ts +++ b/apps/api/src/modules/identity/auth/auth.controller.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller.js'; import { AuthService, type RequestAuthContext } from '@soopa/auth'; -import { USER_PROVIDER, TENANT_PROVIDER } from '@soopa/identity'; +import { USER_REPOSITORY, TENANT_REPOSITORY } from '@soopa/identity'; import type { User, Session } from '@soopa/identity'; import { InvitationsService } from '../invitations/invitations.service.js'; import { Request, Response } from 'express'; @@ -54,11 +54,11 @@ describe('AuthController', () => { useValue: mockAuthService, }, { - provide: USER_PROVIDER, + provide: USER_REPOSITORY, useValue: mockUserProvider, }, { - provide: TENANT_PROVIDER, + provide: TENANT_REPOSITORY, useValue: mockTenantProvider, }, { diff --git a/apps/api/src/modules/identity/auth/auth.controller.ts b/apps/api/src/modules/identity/auth/auth.controller.ts index 5346ad6e..8eb72773 100644 --- a/apps/api/src/modules/identity/auth/auth.controller.ts +++ b/apps/api/src/modules/identity/auth/auth.controller.ts @@ -16,10 +16,10 @@ import { AuthContext, type RequestAuthContext, } from '@soopa/auth'; -import { USER_PROVIDER, TENANT_PROVIDER } from '@soopa/identity'; +import { USER_REPOSITORY, TENANT_REPOSITORY } from '@soopa/identity'; import type { - IUserProvider, - ITenantProvider, + IUserRepository, + ITenantRepository, Session, User, } from '@soopa/identity'; @@ -53,8 +53,9 @@ export class ResendVerificationDto extends createZodDto( export class AuthController { constructor( private readonly authService: AuthService, - @Inject(USER_PROVIDER) private readonly userProvider: IUserProvider, - @Inject(TENANT_PROVIDER) private readonly tenantProvider: ITenantProvider, + @Inject(USER_REPOSITORY) private readonly userProvider: IUserRepository, + @Inject(TENANT_REPOSITORY) + private readonly tenantProvider: ITenantRepository, private readonly invitationsService: InvitationsService, ) {} diff --git a/apps/api/src/modules/identity/auth/platform.guard.spec.ts b/apps/api/src/modules/identity/auth/platform.guard.spec.ts index d07ba253..8458e28b 100644 --- a/apps/api/src/modules/identity/auth/platform.guard.spec.ts +++ b/apps/api/src/modules/identity/auth/platform.guard.spec.ts @@ -6,7 +6,7 @@ import { UnauthorizedException, ExecutionContext, } from '@nestjs/common'; -import { PERMISSION_PROVIDER } from '@soopa/identity'; +import { PERMISSION_REPOSITORY } from '@soopa/identity'; import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; describe('PlatformGuard', () => { @@ -31,7 +31,7 @@ describe('PlatformGuard', () => { }, }, { - provide: PERMISSION_PROVIDER, + provide: PERMISSION_REPOSITORY, useValue: { hasRole: vi.fn(), }, diff --git a/apps/api/src/modules/identity/roles/roles.controller.spec.ts b/apps/api/src/modules/identity/roles/roles.controller.spec.ts index b2afda4c..a9ac515e 100644 --- a/apps/api/src/modules/identity/roles/roles.controller.spec.ts +++ b/apps/api/src/modules/identity/roles/roles.controller.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RolesController } from './roles.controller.js'; -import { ROLE_PROVIDER, RoleScope } from '@soopa/identity'; +import { ROLE_REPOSITORY, RoleScope } from '@soopa/identity'; import { AuthGuard, PermissionsGuard, @@ -38,7 +38,7 @@ describe('RolesController', () => { controllers: [RolesController], providers: [ { - provide: ROLE_PROVIDER, + provide: ROLE_REPOSITORY, useValue: roleProvider, }, ], diff --git a/apps/api/src/modules/identity/roles/roles.controller.ts b/apps/api/src/modules/identity/roles/roles.controller.ts index b2413af9..43344134 100644 --- a/apps/api/src/modules/identity/roles/roles.controller.ts +++ b/apps/api/src/modules/identity/roles/roles.controller.ts @@ -19,9 +19,9 @@ import { AuthContext, type RequestAuthContext, } from '@soopa/auth'; -import { ROLE_PROVIDER, RoleScope } from '@soopa/identity'; +import { ROLE_REPOSITORY, RoleScope } from '@soopa/identity'; import type { - IRoleProvider, + IRoleRepository, CreateRoleInput, UpdateRoleInput, } from '@soopa/identity'; @@ -31,7 +31,7 @@ import { filterRolesForRequester } from '@soopa/identity/utils/role-visibility'; @UseGuards(AuthGuard, PermissionsGuard) export class RolesController { constructor( - @Inject(ROLE_PROVIDER) private readonly roleProvider: IRoleProvider, + @Inject(ROLE_REPOSITORY) private readonly roleProvider: IRoleRepository, ) {} @Get() diff --git a/apps/api/src/modules/identity/roles/roles.controller.visibility.spec.ts b/apps/api/src/modules/identity/roles/roles.controller.visibility.spec.ts index 14e5d63a..1fe16723 100644 --- a/apps/api/src/modules/identity/roles/roles.controller.visibility.spec.ts +++ b/apps/api/src/modules/identity/roles/roles.controller.visibility.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RolesController } from './roles.controller.js'; -import { ROLE_PROVIDER } from '@soopa/identity'; +import { ROLE_REPOSITORY } from '@soopa/identity'; import { AuthGuard, PermissionsGuard, @@ -38,7 +38,7 @@ describe('RolesController - Visibility Logic', () => { controllers: [RolesController], providers: [ { - provide: ROLE_PROVIDER, + provide: ROLE_REPOSITORY, useValue: mockRoleProvider, }, ], diff --git a/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts b/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts index e8d5536b..7c3e8dbe 100644 --- a/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts +++ b/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts @@ -8,9 +8,9 @@ import { } from '@nestjs/common'; import { AUTH_PROVIDER, - USER_PROVIDER, - TENANT_PROVIDER, - ROLE_PROVIDER, + USER_REPOSITORY, + TENANT_REPOSITORY, + ROLE_REPOSITORY, } from '@soopa/identity'; import { getRequiredAdminRoleId, @@ -77,9 +77,9 @@ describe('SystemAdminController', () => { controllers: [SystemAdminController], providers: [ { provide: AUTH_PROVIDER, useValue: mockAuthProvider }, - { provide: USER_PROVIDER, useValue: mockUserProvider }, - { provide: TENANT_PROVIDER, useValue: mockTenantProvider }, - { provide: ROLE_PROVIDER, useValue: mockRoleProvider }, + { provide: USER_REPOSITORY, useValue: mockUserProvider }, + { provide: TENANT_REPOSITORY, useValue: mockTenantProvider }, + { provide: ROLE_REPOSITORY, useValue: mockRoleProvider }, ], }) .overrideGuard(SystemAdminGuard) diff --git a/apps/api/src/modules/identity/system-admin/system-admin.controller.ts b/apps/api/src/modules/identity/system-admin/system-admin.controller.ts index 09f1bbc8..c3865915 100644 --- a/apps/api/src/modules/identity/system-admin/system-admin.controller.ts +++ b/apps/api/src/modules/identity/system-admin/system-admin.controller.ts @@ -15,15 +15,15 @@ import { } from '@nestjs/common'; import { AUTH_PROVIDER, - USER_PROVIDER, - TENANT_PROVIDER, - ROLE_PROVIDER, + USER_REPOSITORY, + TENANT_REPOSITORY, + ROLE_REPOSITORY, } from '@soopa/identity'; import type { IAuthProvider, - IUserProvider, - ITenantProvider, - IRoleProvider, + IUserRepository, + ITenantRepository, + IRoleRepository, } from '@soopa/identity'; import { AuthContext, type RequestAuthContext } from '@soopa/auth'; import { @@ -47,9 +47,10 @@ import { RequirePermission, PermissionsGuard, AuthGuard } from '@soopa/auth'; export class SystemAdminController { constructor( @Inject(AUTH_PROVIDER) private readonly authProvider: IAuthProvider, - @Inject(USER_PROVIDER) private readonly userProvider: IUserProvider, - @Inject(TENANT_PROVIDER) private readonly tenantProvider: ITenantProvider, - @Inject(ROLE_PROVIDER) private readonly roleProvider: IRoleProvider, + @Inject(USER_REPOSITORY) private readonly userProvider: IUserRepository, + @Inject(TENANT_REPOSITORY) + private readonly tenantProvider: ITenantRepository, + @Inject(ROLE_REPOSITORY) private readonly roleProvider: IRoleRepository, ) {} @Post('users/:id/invite') diff --git a/apps/api/src/modules/identity/tenants/tenants.controller.spec.ts b/apps/api/src/modules/identity/tenants/tenants.controller.spec.ts index 61ac5f92..00106644 100644 --- a/apps/api/src/modules/identity/tenants/tenants.controller.spec.ts +++ b/apps/api/src/modules/identity/tenants/tenants.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Request } from 'express'; import { TenantsController } from './tenants.controller.js'; -import { TENANT_PROVIDER, Tenant } from '@soopa/identity'; +import { TENANT_REPOSITORY, Tenant } from '@soopa/identity'; import { AuthGuard } from '@soopa/auth'; import { UpdateTenantStatus } from './tenants.validation.js'; @@ -24,7 +24,7 @@ describe('TenantsController', () => { controllers: [TenantsController], providers: [ { - provide: TENANT_PROVIDER, + provide: TENANT_REPOSITORY, useValue: mockTenantProvider, }, ], diff --git a/apps/api/src/modules/identity/tenants/tenants.controller.ts b/apps/api/src/modules/identity/tenants/tenants.controller.ts index b2e7ed6a..9930e3de 100644 --- a/apps/api/src/modules/identity/tenants/tenants.controller.ts +++ b/apps/api/src/modules/identity/tenants/tenants.controller.ts @@ -12,14 +12,15 @@ import { import { Request } from 'express'; import { AuthGuard, PermissionsGuard, RequirePermission } from '@soopa/auth'; import { UpdateTenantStatus, UpdateTenantDto } from './tenants.validation.js'; -import { TENANT_PROVIDER } from '@soopa/identity'; -import type { ITenantProvider } from '@soopa/identity'; +import { TENANT_REPOSITORY } from '@soopa/identity'; +import type { ITenantRepository } from '@soopa/identity'; @Controller('tenants') @UseGuards(AuthGuard, PermissionsGuard) export class TenantsController { constructor( - @Inject(TENANT_PROVIDER) private readonly tenantProvider: ITenantProvider, + @Inject(TENANT_REPOSITORY) + private readonly tenantProvider: ITenantRepository, ) {} @Get() diff --git a/apps/api/src/modules/identity/users/users.controller.spec.ts b/apps/api/src/modules/identity/users/users.controller.spec.ts index b477b2d8..e471ed98 100644 --- a/apps/api/src/modules/identity/users/users.controller.spec.ts +++ b/apps/api/src/modules/identity/users/users.controller.spec.ts @@ -1,67 +1,59 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller.js'; -import { USER_PROVIDER, TENANT_PROVIDER } from '@soopa/identity'; -import { InvitationsService } from '../invitations/invitations.service.js'; +import { + ListUsersWithInvitationsUseCase, + RemoveUserUseCase, + GetUserProfileUseCase, + CreateUserUseCase, + GetUserByIdUseCase, +} from '@soopa/identity'; import { Request } from 'express'; import { AuthGuard, PermissionsGuard } from '@soopa/auth'; import { CreateUser } from './users.validation.js'; import { NotFoundException, InternalServerErrorException, + BadRequestException, } from '@nestjs/common'; import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; describe('UsersController', () => { let controller: UsersController; - let userProvider: { - create: Mock; - findAll: Mock; - findById: Mock; - findByEmail: Mock; - update: Mock; - delete: Mock; - forceVerifyEmail: Mock; - deleteIfNotLastAdmin: Mock; - }; - let tenantProvider: { - findAllForUser: Mock; - }; + let listUsersWithInvitationsUseCase: { execute: Mock }; + let removeUserUseCase: { execute: Mock }; + let getUserProfileUseCase: { execute: Mock }; + let createUserUseCase: { execute: Mock }; + let getUserByIdUseCase: { execute: Mock }; beforeEach(async () => { - // vi.clearAllMocks() is redundant here as we create fresh mocks below - - userProvider = { - create: vi.fn(), - findAll: vi.fn(), - findById: vi.fn(), - findByEmail: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - forceVerifyEmail: vi.fn(), - deleteIfNotLastAdmin: vi.fn(), - }; - - tenantProvider = { - findAllForUser: vi.fn(), - }; + listUsersWithInvitationsUseCase = { execute: vi.fn() }; + removeUserUseCase = { execute: vi.fn() }; + getUserProfileUseCase = { execute: vi.fn() }; + createUserUseCase = { execute: vi.fn() }; + getUserByIdUseCase = { execute: vi.fn() }; const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], providers: [ { - provide: USER_PROVIDER, - useValue: userProvider, + provide: ListUsersWithInvitationsUseCase, + useValue: listUsersWithInvitationsUseCase, + }, + { + provide: RemoveUserUseCase, + useValue: removeUserUseCase, + }, + { + provide: GetUserProfileUseCase, + useValue: getUserProfileUseCase, }, { - provide: TENANT_PROVIDER, - useValue: tenantProvider, + provide: CreateUserUseCase, + useValue: createUserUseCase, }, { - provide: InvitationsService, - useValue: { - create: vi.fn(), - list: vi.fn().mockResolvedValue([]), - }, + provide: GetUserByIdUseCase, + useValue: getUserByIdUseCase, }, ], }) @@ -78,31 +70,47 @@ describe('UsersController', () => { expect(controller).toBeDefined(); }); + describe('getMe', () => { + it('should call getUserProfileUseCase with the correct user ID', async () => { + const req = { user: { id: 'user-1' } } as unknown as Request & { + user: { id: string }; + }; + const userProfile = { id: 'user-1', email: 'test@example.com' }; + getUserProfileUseCase.execute.mockResolvedValue(userProfile); + + const result = await controller.getMe(req); + + expect(result).toEqual(userProfile); + expect(getUserProfileUseCase.execute).toHaveBeenCalledWith('user-1'); + }); + }); + describe('create', () => { - it('should call userProvider.create with correct parameters', async () => { - const createUser: CreateUser = { + it('should call createUserUseCase with the correct payload', async () => { + const createUserDto: CreateUser = { email: 'test@example.com', role: 'member', }; - const result = { id: '1', ...createUser }; - userProvider.create.mockResolvedValue(result); + const createdUser = { id: 'new-user', ...createUserDto }; + createUserUseCase.execute.mockResolvedValue(createdUser); - expect(await controller.create(createUser)).toEqual(result); + const result = await controller.create(createUserDto); - expect(userProvider.create).toHaveBeenCalledWith(createUser); + expect(result).toEqual(createdUser); + expect(createUserUseCase.execute).toHaveBeenCalledWith(createUserDto); }); }); describe('findAll', () => { - it('should return empty list if no organizationId in request', async () => { - const req = { - user: {}, - } as unknown as Request & { user: { organizationId?: string } }; + it('should return empty list if no organizationId is present', async () => { + const req = { user: {} } as unknown as Request & { + user: { organizationId?: string }; + }; const result = await controller.findAll(req); - expect(result).toEqual({ data: [], total: 0 }); - expect(userProvider.findAll).not.toHaveBeenCalled(); + expect(result).toEqual({ data: [], total: 0 }); + expect(listUsersWithInvitationsUseCase.execute).not.toHaveBeenCalled(); }); it('should return empty list if user is undefined', async () => { @@ -111,79 +119,55 @@ describe('UsersController', () => { }; const result = await controller.findAll(req); - expect(result).toEqual({ data: [], total: 0 }); - expect(userProvider.findAll).not.toHaveBeenCalled(); + expect(result).toEqual({ data: [], total: 0 }); + expect(listUsersWithInvitationsUseCase.execute).not.toHaveBeenCalled(); }); - it('should call userProvider.findAll with tenantId if present', async () => { + it('should call listUsersWithInvitationsUseCase if organizationId is present', async () => { const tenantId = 'org-123'; const req = { user: { organizationId: tenantId }, } as unknown as Request & { user: { organizationId?: string } }; - const users = [{ id: '1', email: 'test@example.com', permissions: [] }]; - - userProvider.findAll.mockResolvedValue({ data: users, total: 1 }); + const response = { data: [{ id: 'user-1', type: 'user' }], total: 1 }; + listUsersWithInvitationsUseCase.execute.mockResolvedValue(response); const result = await controller.findAll(req); - expect(result).toEqual({ data: users, total: 1 }); - expect(userProvider.findAll).toHaveBeenCalledWith({ tenantId }); + expect(result).toEqual(response); + expect(listUsersWithInvitationsUseCase.execute).toHaveBeenCalledWith( + tenantId, + ); }); }); describe('findOne', () => { - it('should call userProvider.findById and check tenant membership', async () => { + it('should call getUserByIdUseCase and return the user', async () => { const id = '1'; const tenantId = 'org-123'; - const user = { id: '1', email: 'test@example.com', permissions: [] }; const req = { user: { organizationId: tenantId }, } as unknown as Request & { user: { organizationId?: string } }; + const user = { id: '1', email: 'test@example.com' }; - userProvider.findById.mockResolvedValue(user); - tenantProvider.findAllForUser.mockResolvedValue([{ id: tenantId }]); // Is Member + getUserByIdUseCase.execute.mockResolvedValue(user); const result = await controller.findOne(id, req); - expect(result).toEqual(user); - - expect(userProvider.findById).toHaveBeenCalledWith(id); - expect(tenantProvider.findAllForUser).toHaveBeenCalledWith(id); - }); - - it('should throw NotFoundException if no tenantId (no context)', async () => { - const id = '1'; - const req = { - user: {}, - } as unknown as Request & { user: { organizationId?: string } }; - await expect(controller.findOne(id, req)).rejects.toThrow( - NotFoundException, - ); + expect(result).toEqual(user); + expect(getUserByIdUseCase.execute).toHaveBeenCalledWith(id, tenantId); }); - it('should throw NotFoundException if user not member of tenant', async () => { + it('should propagate errors from the use case (e.g. NotFoundException)', async () => { const id = '1'; const tenantId = 'org-123'; - const user = { id: '1', email: 'test@example.com', permissions: [] }; const req = { user: { organizationId: tenantId }, } as unknown as Request & { user: { organizationId?: string } }; - userProvider.findById.mockResolvedValue(user); - tenantProvider.findAllForUser.mockResolvedValue([{ id: 'other-org' }]); // Not Member - - await expect(controller.findOne(id, req)).rejects.toThrow( - NotFoundException, + getUserByIdUseCase.execute.mockRejectedValue( + new NotFoundException('User not found'), ); - }); - it('should throw NotFoundException if user does not exist', async () => { - const id = '1'; - const req = { - user: { organizationId: 'org-123' }, - } as unknown as Request & { user: { organizationId?: string } }; - - userProvider.findById.mockResolvedValue(null); await expect(controller.findOne(id, req)).rejects.toThrow( NotFoundException, @@ -192,109 +176,87 @@ describe('UsersController', () => { }); describe('remove', () => { - // ... existing tests ... - - it('should throw InternalServerErrorException on unexpected error', async () => { + it('should throw BadRequestException if no organizationId in context', async () => { const id = 'user-123'; - const tenantId = 'org-123'; - const req = { - user: { id: 'current-user', organizationId: tenantId }, - } as unknown as Request & { + const req = { user: { id: 'admin' } } as unknown as Request & { user: { id: string; organizationId?: string }; }; - userProvider.deleteIfNotLastAdmin.mockRejectedValue( - new Error('Unexpected DB error'), - ); - await expect(controller.remove(id, req)).rejects.toThrow( - InternalServerErrorException, + BadRequestException, ); + expect(removeUserUseCase.execute).not.toHaveBeenCalled(); }); - it('should throw BadRequestException if no organizationId (no context)', async () => { + + it('should successfully remove a user', async () => { const id = 'user-123'; const req = { - user: { id: 'current-user' }, + user: { id: 'admin', organizationId: 'org-123' }, } as unknown as Request & { user: { id: string; organizationId?: string }; }; + const response = { success: true, hardDeleted: true }; - await expect(controller.remove(id, req)).rejects.toThrow( - 'Organization context required', - ); - }); + removeUserUseCase.execute.mockResolvedValue(response); - it('should throw BadRequestException if trying to delete self', async () => { - const id = 'current-user'; - const tenantId = 'org-123'; - const req = { - user: { id: 'current-user', organizationId: tenantId }, - } as unknown as Request & { - user: { id: string; organizationId?: string }; - }; + const result = await controller.remove(id, req); - await expect(controller.remove(id, req)).rejects.toThrow( - 'You cannot delete your own account.', + expect(result).toEqual(response); + expect(removeUserUseCase.execute).toHaveBeenCalledWith( + id, + 'admin', + 'org-123', ); }); - it('should throw error if user not member of tenant', async () => { + it('should propagate BadRequestException from use case', async () => { const id = 'user-123'; - const tenantId = 'org-123'; const req = { - user: { id: 'current-user', organizationId: tenantId }, + user: { id: 'admin', organizationId: 'org-123' }, } as unknown as Request & { user: { id: string; organizationId?: string }; }; - // Atomic operation throws error for non-member - userProvider.deleteIfNotLastAdmin.mockRejectedValue( - new Error('User is not a member of this organization'), + removeUserUseCase.execute.mockRejectedValue( + new BadRequestException('Cannot delete self'), ); await expect(controller.remove(id, req)).rejects.toThrow( - 'User not found in this organization', + BadRequestException, ); }); - it('should throw BadRequestException if trying to delete the last admin', async () => { + it('should propagate NotFoundException from use case', async () => { const id = 'user-123'; - const tenantId = 'org-123'; const req = { - user: { id: 'current-user', organizationId: tenantId }, + user: { id: 'admin', organizationId: 'org-123' }, } as unknown as Request & { user: { id: string; organizationId?: string }; }; - // Atomic operation returns false when last admin - userProvider.deleteIfNotLastAdmin.mockResolvedValue(false); + removeUserUseCase.execute.mockRejectedValue( + new NotFoundException('User not found'), + ); await expect(controller.remove(id, req)).rejects.toThrow( - 'Cannot delete the last admin of the organization', + NotFoundException, ); }); - it('should successfully delete user if all checks pass', async () => { + it('should wrap unknown errors in InternalServerErrorException', async () => { const id = 'user-123'; - const tenantId = 'org-123'; const req = { - user: { id: 'current-user', organizationId: tenantId }, + user: { id: 'admin', organizationId: 'org-123' }, } as unknown as Request & { user: { id: string; organizationId?: string }; }; - // Atomic operation succeeds - userProvider.deleteIfNotLastAdmin.mockResolvedValue({ - success: true, - hardDeleted: true, - }); - - const result = await controller.remove(id, req); + removeUserUseCase.execute.mockRejectedValue( + new Error('Unexpected DB error'), + ); - expect(result).toEqual({ success: true, hardDeleted: true }); - expect(userProvider.deleteIfNotLastAdmin).toHaveBeenCalledWith( - id, - tenantId, + await expect(controller.remove(id, req)).rejects.toThrow( + InternalServerErrorException, ); }); }); diff --git a/apps/api/src/modules/identity/users/users.controller.ts b/apps/api/src/modules/identity/users/users.controller.ts index 0594cbb0..586fd78d 100644 --- a/apps/api/src/modules/identity/users/users.controller.ts +++ b/apps/api/src/modules/identity/users/users.controller.ts @@ -5,7 +5,6 @@ import { Body, Param, Req, - Inject, UseGuards, NotFoundException, BadRequestException, @@ -13,35 +12,18 @@ import { Delete, Logger, } from '@nestjs/common'; -import { USER_PROVIDER, TENANT_PROVIDER } from '@soopa/identity'; -import type { ITenantProvider, IUserProvider, User } from '@soopa/identity'; -import { InvitationsService } from '../invitations/invitations.service.js'; +import { + ListUsersWithInvitationsUseCase, + RemoveUserUseCase, + GetUserProfileUseCase, + CreateUserUseCase, + GetUserByIdUseCase, +} from '@soopa/identity'; +import type { UserListItem } from '@soopa/identity'; import { CreateUser } from './users.validation.js'; import { Request } from 'express'; import { AuthGuard, PermissionsGuard, RequirePermission } from '@soopa/auth'; -// Union type to support both real Users and Pending Invitations in the same list -export type UserListItem = - | (User & { kind?: 'user' }) // Optional discriminator for backwards compat if needed, or strict: { kind: 'user' } - | { - kind: 'invitation'; - id: string; - email: string; - name: string; - role: string; - status: 'pending'; - emailVerified: boolean; - createdAt: Date; - updatedAt: Date; - isInvitation: true; - // Optional fields from User to satisfy strict typing if needed by consumers - permissions?: string[]; - image?: string; - banned?: boolean; - banReason?: string | null; - banExpires?: Date | null; - }; - export interface UserListResponse { data: UserListItem[]; total: number; @@ -58,9 +40,11 @@ export class UsersController { private readonly logger = new Logger(UsersController.name); constructor( - @Inject(USER_PROVIDER) private readonly userProvider: IUserProvider, - @Inject(TENANT_PROVIDER) private readonly tenantProvider: ITenantProvider, - private readonly invitationsService: InvitationsService, + private readonly listUsersWithInvitationsUseCase: ListUsersWithInvitationsUseCase, + private readonly removeUserUseCase: RemoveUserUseCase, + private readonly getUserProfileUseCase: GetUserProfileUseCase, + private readonly createUserUseCase: CreateUserUseCase, + private readonly getUserByIdUseCase: GetUserByIdUseCase, ) {} /** @@ -71,13 +55,7 @@ export class UsersController { async getMe(@Req() req: Request & { user: { id: string } }) { this.logger.debug(`getMe called for user: ${req.user?.id}`); // AuthGuard ensures req.user is populated - const user = await this.userProvider.findById(req.user.id); - if (!user) { - this.logger.warn(`User not found for ID: ${req.user.id}`); - throw new NotFoundException('User not found'); - } - this.logger.debug(`Returning user profile for: ${req.user.id}`); - return user; + return this.getUserProfileUseCase.execute(req.user.id); } /** @@ -86,7 +64,7 @@ export class UsersController { @Post() @RequirePermission('users', 'manage') create(@Body() createUser: CreateUser) { - return this.userProvider.create(createUser); + return this.createUserUseCase.execute(createUser); } /** @@ -99,60 +77,11 @@ export class UsersController { async findAll( @Req() req: Request & { user: { organizationId?: string } }, ): Promise { - // AuthGuard guarantees session is valid and populates user info - // We use 'organizationId' (mapped in getSessionWithOrg) const tenantId = req.user?.organizationId; - if (!tenantId) { - // STRICT ISOLATION: Admin users must belong to an organization to see users. - // Returning empty list is safer than throwing error for UI handling, - // but for security transparency, let's return empty. return { data: [], total: 0 }; } - - const { data: users, total: userTotal } = await this.userProvider.findAll({ - tenantId, - }); - const invitations = await this.invitationsService.list(tenantId); - - // Filter out invitations for users that already exist - const existingEmails = new Set(users.map((u) => u.email.toLowerCase())); - const pendingInvitations = invitations.filter( - (inv: { email: string }) => !existingEmails.has(inv.email.toLowerCase()), - ); - - // Map invitations to User structure for unified UI list - const invitedUsers: UserListItem[] = pendingInvitations.map( - (inv: { - id: string; - email: string; - role: string | null; - createdAt: Date; - }) => ({ - id: inv.id, // Use invitation ID temporarily - email: inv.email, - name: '', // Name might not be known yet - role: inv.role ?? 'member', - status: 'pending', // Explicit status for UI (vs 'active') - emailVerified: false, - createdAt: inv.createdAt, - updatedAt: inv.createdAt, - isInvitation: true, - // Ensure other fields are undefined or compatible - permissions: undefined, - image: undefined, - banned: false, - }), - ); - - // Merge: Users first, then Pending Invites (or sort by date) - const combinedData = [...users, ...invitedUsers]; - - // Return Refine-compatible pagination structure - return { - data: combinedData, - total: userTotal + invitations.length, - }; + return this.listUsersWithInvitationsUseCase.execute(tenantId); } /** @@ -168,28 +97,7 @@ export class UsersController { @Req() req: Request & { user: { organizationId?: string } }, ) { const tenantId = req.user?.organizationId; - - // Strict isolation: Admin users must belong to a tenant to view details - if (!tenantId) { - throw new NotFoundException('User not found'); // Mask existence - } - - const user = await this.userProvider.findById(id); - - if (!user) { - throw new NotFoundException('User not found'); - } - - // Tenant Scoping check - // Ensure the target user is a member of the requester's tenant - const userTenants = await this.tenantProvider.findAllForUser(user.id); - const isMember = userTenants.some((t) => t.id === tenantId); - - if (!isMember) { - throw new NotFoundException('User not found'); // Mask existence for security - } - - return user; + return this.getUserByIdUseCase.execute(id, tenantId); } /** @@ -207,44 +115,17 @@ export class UsersController { throw new BadRequestException('Organization context required'); } - // SECURITY: Prevent self-deletion - // This is a critical enterprise-grade guard. - if (id === req.user.id) { - throw new BadRequestException('You cannot delete your own account.'); - } - try { - // Use atomic operation to prevent TOCTOU race condition - // This combines membership verification, last-admin check, and deletion in a single transaction - const result = await this.userProvider.deleteIfNotLastAdmin(id, tenantId); - - if (!result.success) { - throw new BadRequestException( - 'Cannot delete the last admin of the organization', - ); - } - - return { success: true, hardDeleted: result.hardDeleted }; + return await this.removeUserUseCase.execute(id, req.user.id, tenantId); } catch (error) { - // Translate provider-specific errors to HTTP exceptions if ( - error instanceof Error && - error.message.includes('not a member of this organization') + error instanceof BadRequestException || + error instanceof NotFoundException ) { - throw new NotFoundException('User not found in this organization'); - } - - // Re-throw BadRequestException (last admin case) - if (error instanceof BadRequestException) { throw error; } - - // Handle unexpected errors - // Log full details for debugging (in real app utilize a logger) const err = error as Error; this.logger.error(`User deletion failed: ${err.message}`, err.stack); - - // Return generic message to client to avoid leaking internals throw new InternalServerErrorException('Failed to delete user'); } } diff --git a/apps/worker/package.json b/apps/worker/package.json index fd195f63..579d342c 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -48,6 +48,7 @@ "@soopa/piece-framework": "workspace:*", "@soopa/piece-registry": "workspace:*", "@soopa/queue": "workspace:*", + "@soopa/security": "workspace:*", "ai": "^6.0.146", "bcryptjs": "^3.0.3", "better-auth": "^1.4.10", diff --git a/apps/worker/src/app.module.ts b/apps/worker/src/app.module.ts index dcc919ae..194c1caf 100644 --- a/apps/worker/src/app.module.ts +++ b/apps/worker/src/app.module.ts @@ -9,6 +9,7 @@ import { ObservabilityModule } from "./bootstrap/observability/observability.mod import { DatabaseModule } from "@soopa/database"; import { DbManagerModule } from "./bootstrap/dbmanager/dbmanager.module.js"; import { EventEmitterModule } from "@nestjs/event-emitter"; +import { EncryptionModule } from "@soopa/security"; // Consumers import { CopilotWorker } from "./consumers/copilot.worker.js"; @@ -50,6 +51,16 @@ import { NestCacheInvalidatorAdapter } from "./adapters/outbound/nest-cache-inva DatabaseModule, DbManagerModule, ObservabilityModule, + EncryptionModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + mode: cfg.get("INFRA_MODE") === "local" ? "local" : "kms", + encryptionKey: cfg.get("ENCRYPTION_KEY"), + kmsKeyId: cfg.get("KMS_KEY_ID"), + kmsEndpoint: cfg.get("KMS_ENDPOINT"), + }), + }), PiecesModule.forRoot(), ...(process.env.ENABLE_PLUGIN_MIGRATIONS === "true" ? [PiecesModule.withMigrations()] diff --git a/packages/auth/src/services/auth.service.ts b/packages/auth/src/services/auth.service.ts index eeb90886..1fb26f74 100644 --- a/packages/auth/src/services/auth.service.ts +++ b/packages/auth/src/services/auth.service.ts @@ -7,10 +7,10 @@ import { Session, User, CreateUserInput, - TENANT_PROVIDER, - ITenantProvider, - PERMISSION_PROVIDER, - IPermissionProvider, + TENANT_REPOSITORY, + ITenantRepository, + PERMISSION_REPOSITORY, + IPermissionRepository, getSystemTenantId, } from "@soopa/identity"; @@ -20,9 +20,10 @@ export class AuthService { constructor( @Inject(AUTH_PROVIDER) private readonly authProvider: IAuthProvider, - @Inject(TENANT_PROVIDER) private readonly tenantProvider: ITenantProvider, - @Inject(PERMISSION_PROVIDER) - private readonly permissionProvider: IPermissionProvider, + @Inject(TENANT_REPOSITORY) + private readonly tenantProvider: ITenantRepository, + @Inject(PERMISSION_REPOSITORY) + private readonly permissionProvider: IPermissionRepository, ) {} async login(credentials: LoginCredentials): Promise { @@ -87,7 +88,10 @@ export class AuthService { // using slice() to avoid mutating the original array const sortedTenants = tenants .slice() - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + .sort( + (a: { createdAt: Date }, b: { createdAt: Date }) => + b.createdAt.getTime() - a.createdAt.getTime(), + ); const hasTenant = sortedTenants.length > 0; const organizationId = hasTenant ? sortedTenants[0].id : undefined; const organizationName = hasTenant diff --git a/packages/credentials/src/index.ts b/packages/credentials/src/index.ts index 3df3a0e6..e00670b8 100644 --- a/packages/credentials/src/index.ts +++ b/packages/credentials/src/index.ts @@ -2,12 +2,11 @@ * @soopa/credentials — public API * * Entrypoints: - * - `@soopa/credentials` — runtime services (encryption, token manager) + * - `@soopa/credentials` — runtime services (e.g., token manager) + * - `@soopa/security` — crypto utilities (encryption) * - `@soopa/piece-framework` — piece/action/trigger/auth/property definitions */ -// Crypto utilities moved to @soopa/security - // Token management (OAuth refresh, credential storage) export * from './oauth/token-manager.service.js'; export * from './oauth/token-refresh.service.js'; diff --git a/packages/credentials/src/oauth/token-manager.service.ts b/packages/credentials/src/oauth/token-manager.service.ts index d9d67935..d833c9db 100644 --- a/packages/credentials/src/oauth/token-manager.service.ts +++ b/packages/credentials/src/oauth/token-manager.service.ts @@ -3,7 +3,8 @@ import { dataSources, credentials, DATABASE_CONNECTION } from '@soopa/database'; import { eq } from 'drizzle-orm'; import type { DrizzleDb } from '@soopa/database'; -import { IEncryptionService, ENCRYPTION_SERVICE } from '@soopa/security'; +import type { IEncryptionService } from '@soopa/security'; +import { ENCRYPTION_SERVICE } from '@soopa/security'; import type { IDistributedLock } from './distributed-lock.interface.js'; /** diff --git a/packages/credentials/src/oauth/token-refresh.service.ts b/packages/credentials/src/oauth/token-refresh.service.ts index 203d7c97..5acb8e37 100644 --- a/packages/credentials/src/oauth/token-refresh.service.ts +++ b/packages/credentials/src/oauth/token-refresh.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { OAuthRefreshClient, OAuthRefreshError } from './token-manager.service.js'; -import { IEncryptionService, ENCRYPTION_SERVICE } from '@soopa/security'; +import type { IEncryptionService } from '@soopa/security'; +import { ENCRYPTION_SERVICE } from '@soopa/security'; import type { ICredentialsEventPublisher } from '../interfaces/event-publisher.interface.js'; import { CredentialInvalidatedEvent } from '../events/credential-invalidated.event.js'; import { resolveOAuth2Url } from '@soopa/piece-framework'; diff --git a/packages/identity/src/adapters/better-auth.abac.spec.ts b/packages/identity/src/adapters/outbound/better-auth.abac.spec.ts similarity index 93% rename from packages/identity/src/adapters/better-auth.abac.spec.ts rename to packages/identity/src/adapters/outbound/better-auth.abac.spec.ts index be39b5e0..49952a39 100644 --- a/packages/identity/src/adapters/better-auth.abac.spec.ts +++ b/packages/identity/src/adapters/outbound/better-auth.abac.spec.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, type Mock } from "vitest"; import { BetterAuthAdapter } from "./better-auth.adapter.js"; -import type { IdentityModuleOptions } from "../identity.module.js"; +import type { IdentityModuleOptions } from "../../identity.module.js"; import type { IEmailProvider, - ITenantProvider, + ITenantRepository, IIdentityEventPublisher, -} from "../interfaces/index.js"; +} from "../../core/ports/outbound/index.js"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import * as schema from "../schema.js"; +import * as schema from "../../schema.js"; // Mocks vi.mock("better-auth", () => ({ @@ -59,7 +59,7 @@ describe("BetterAuthAdapter - ABAC Condition Mapping", () => { it("should serialize permissions with conditions as JSON strings", async () => { const db = mkDb(); const mockEmail = { sendEmail: vi.fn() } as unknown as IEmailProvider; - const mockTenantProvider = {} as unknown as ITenantProvider; + const mockTenantProvider = {} as unknown as ITenantRepository; const options = mkOptions(); const mockEventPublisher: IIdentityEventPublisher = { diff --git a/packages/identity/src/adapters/better-auth.adapter.spec.ts b/packages/identity/src/adapters/outbound/better-auth.adapter.spec.ts similarity index 98% rename from packages/identity/src/adapters/better-auth.adapter.spec.ts rename to packages/identity/src/adapters/outbound/better-auth.adapter.spec.ts index b77ef886..dd9612b4 100644 --- a/packages/identity/src/adapters/better-auth.adapter.spec.ts +++ b/packages/identity/src/adapters/outbound/better-auth.adapter.spec.ts @@ -3,11 +3,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { BetterAuthAdapter, } from "./better-auth.adapter.js"; -import type { BetterAuthAdapterConfig } from "../interfaces/better-auth-config.interface.js"; -import type { IIdentityEventPublisher } from "../interfaces/index.js"; -import * as schema from "../schema.js"; +import type { BetterAuthAdapterConfig } from '../../core/ports/outbound/better-auth-config.port.js'; +import type { IIdentityEventPublisher } from '../../core/ports/outbound/index.js'; +import * as schema from '../../schema.js'; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { getBetterAuthPlugins } from "../better-auth.config.js"; +import { getBetterAuthPlugins } from '../../better-auth.config.js'; vi.mock("better-auth", () => ({ betterAuth: vi.fn((opts: any) => ({ diff --git a/packages/identity/src/adapters/better-auth.adapter.ts b/packages/identity/src/adapters/outbound/better-auth.adapter.ts similarity index 96% rename from packages/identity/src/adapters/better-auth.adapter.ts rename to packages/identity/src/adapters/outbound/better-auth.adapter.ts index 9bf5afe7..bab44ec5 100644 --- a/packages/identity/src/adapters/better-auth.adapter.ts +++ b/packages/identity/src/adapters/outbound/better-auth.adapter.ts @@ -5,23 +5,23 @@ import { v4 as uuidv4 } from "uuid"; import { eq, and } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { fromNodeHeaders } from "better-auth/node"; -import { normalizeRole } from "../utils/role-normalization.js"; -import { getBetterAuthPlugins } from "../better-auth.config.js"; -import { validateFrontendUrl } from "../utils/url.util.js"; -import type { ITenantProvider } from "../interfaces/tenant-provider.interface.js"; +import { normalizeRole } from "../../utils/role-normalization.js"; +import { getBetterAuthPlugins } from "../../better-auth.config.js"; +import { validateFrontendUrl } from "../../utils/url.util.js"; +import type { ITenantRepository } from "../../core/ports/outbound/tenant-repository.port.js"; import { Inject, Injectable } from "@nestjs/common"; import type { IncomingHttpHeaders } from "node:http"; -import * as schema from "../schema.js"; -import type { BetterAuthAdapterConfig } from "../interfaces/better-auth-config.interface.js"; -import type { IEmailProvider } from "../interfaces/email-provider.interface.js"; +import * as schema from "../../schema.js"; +import type { BetterAuthAdapterConfig } from "../../core/ports/outbound/better-auth-config.port.js"; +import type { IEmailProvider } from "../../core/ports/outbound/email-provider.port.js"; import { EMAIL_PROVIDER, IDENTITY_OPTIONS, IDENTITY_DB, BETTER_AUTH_CONFIG, - TENANT_PROVIDER, + TENANT_REPOSITORY, IDENTITY_EVENT_PUBLISHER, -} from "../constants.js"; +} from "../../constants.js"; import type { IAuthProvider, CreateInvitationInput, @@ -31,10 +31,10 @@ import type { Session, User as UserInterface, IIdentityEventPublisher, -} from "../interfaces/index.js"; -import { UserInvitedEvent } from "../events/index.js"; -import type { CreateUserInput } from "../interfaces/user-provider.interface.js"; -import type { IdentityModuleOptions } from "../identity.module.js"; +} from "../../core/ports/outbound/index.js"; +import { UserInvitedEvent } from "../../events/index.js"; +import type { CreateUserInput } from "../../core/ports/outbound/user-repository.port.js"; +import type { IdentityModuleOptions } from "../../identity.module.js"; export const PERMISSION_FALLBACK_DASHBOARD_READ = "dashboard:read"; @@ -95,7 +95,8 @@ export class BetterAuthAdapter implements IAuthProvider { @Inject(EMAIL_PROVIDER) private readonly emailService: IEmailProvider, @Inject(BETTER_AUTH_CONFIG) private readonly config: BetterAuthAdapterConfig, - @Inject(TENANT_PROVIDER) private readonly tenantProvider: ITenantProvider, // Injected Dependency + @Inject(TENANT_REPOSITORY) + private readonly tenantProvider: ITenantRepository, // Injected Dependency @Inject(IDENTITY_OPTIONS) private readonly options: IdentityModuleOptions, @Inject(IDENTITY_EVENT_PUBLISHER) private readonly eventPublisher: IIdentityEventPublisher, diff --git a/packages/identity/src/adapters/drizzle-permission.adapter.spec.ts b/packages/identity/src/adapters/outbound/drizzle-permission.repository.spec.ts similarity index 88% rename from packages/identity/src/adapters/drizzle-permission.adapter.spec.ts rename to packages/identity/src/adapters/outbound/drizzle-permission.repository.spec.ts index c63e846e..80120550 100644 --- a/packages/identity/src/adapters/drizzle-permission.adapter.spec.ts +++ b/packages/identity/src/adapters/outbound/drizzle-permission.repository.spec.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; -import { DrizzlePermissionAdapter } from "./drizzle-permission.adapter.js"; -import * as schema from "../schema.js"; +import { DrizzlePermissionRepositoryAdapter } from "./drizzle-permission.repository.js"; +import * as schema from "../../schema.js"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; -import type { User } from "../interfaces/index.js"; -import { getSystemTenantId } from "../constants.js"; +import type { User } from "../../core/ports/outbound/index.js"; +import { getSystemTenantId } from "../../constants.js"; -vi.mock("../constants", async () => { +vi.mock("../../constants.js", async () => { return { - ...(await vi.importActual("../constants")), + ...(await vi.importActual("../../constants.js")), getSystemTenantId: () => "sys-tenant-id", getOwnerRoleId: () => "owner-role-id", getAdminRoleId: () => "admin-role-id", @@ -80,12 +80,12 @@ const mkUser = (over?: Partial): User => ({ ...over, }); -describe("DrizzlePermissionAdapter", () => { +describe("DrizzlePermissionRepositoryAdapter", () => { beforeEach(() => vi.restoreAllMocks()); it("can: tenant checks - Owner true, others based on permissions", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); const user = mkUser(); // no membership @@ -140,7 +140,7 @@ describe("DrizzlePermissionAdapter", () => { it("can: respects global wildcard access", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); const user = mkUser(); db.select.mockReturnValue( @@ -160,7 +160,7 @@ describe("DrizzlePermissionAdapter", () => { it("can: respects resource-level wildcard access", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); const user = mkUser(); db.select.mockReturnValue( @@ -181,7 +181,7 @@ describe("DrizzlePermissionAdapter", () => { it("hasRole: checks role id", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); const user = mkUser(); db.select.mockReturnValue( @@ -211,7 +211,7 @@ describe("DrizzlePermissionAdapter", () => { it("getPermissions: aggregates wildcard and role-based perms", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); // tenant member: user with permissions db.select.mockReturnValue( @@ -237,7 +237,7 @@ describe("DrizzlePermissionAdapter", () => { it("getPermissions: returns * for system admin if DB returns it", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); // Mock DB to return '*' permission for system tenant query db.select.mockReturnValue( @@ -258,7 +258,7 @@ describe("DrizzlePermissionAdapter", () => { it("getPermissions: returns resource wildcard (e.g. organization:*)", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); db.select.mockReturnValue( mockChainedQuery([ @@ -278,7 +278,7 @@ describe("DrizzlePermissionAdapter", () => { it("can: denies permission if scope mismatch", async () => { const db = mkDb(); - const adapter = new DrizzlePermissionAdapter(db); + const adapter = new DrizzlePermissionRepositoryAdapter(db); const user = mkUser(); // User has 'admin:read' but scoped to system tenant, not 'o1' diff --git a/packages/identity/src/adapters/drizzle-permission.adapter.ts b/packages/identity/src/adapters/outbound/drizzle-permission.repository.ts similarity index 93% rename from packages/identity/src/adapters/drizzle-permission.adapter.ts rename to packages/identity/src/adapters/outbound/drizzle-permission.repository.ts index c94e646c..73b237eb 100644 --- a/packages/identity/src/adapters/drizzle-permission.adapter.ts +++ b/packages/identity/src/adapters/outbound/drizzle-permission.repository.ts @@ -1,17 +1,17 @@ import { Inject, Injectable } from "@nestjs/common"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { IDENTITY_DB } from "../constants.js"; +import { IDENTITY_DB } from "../../constants.js"; import { eq, and } from "drizzle-orm"; import type { - IPermissionProvider, + IPermissionRepository, PermissionAction, PermissionResource, User, -} from "../interfaces/index.js"; -import * as schema from "../schema.js"; +} from "../../core/ports/outbound/index.js"; +import * as schema from "../../schema.js"; @Injectable() -export class DrizzlePermissionAdapter implements IPermissionProvider { +export class DrizzlePermissionRepositoryAdapter implements IPermissionRepository { constructor( @Inject(IDENTITY_DB) private readonly db: NodePgDatabase, ) {} diff --git a/packages/identity/src/adapters/drizzle-role.adapter.spec.ts b/packages/identity/src/adapters/outbound/drizzle-role.repository.spec.ts similarity index 96% rename from packages/identity/src/adapters/drizzle-role.adapter.spec.ts rename to packages/identity/src/adapters/outbound/drizzle-role.repository.spec.ts index af5f2104..40be2ca4 100644 --- a/packages/identity/src/adapters/drizzle-role.adapter.spec.ts +++ b/packages/identity/src/adapters/outbound/drizzle-role.repository.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest"; -import { DrizzleRoleAdapter } from "./drizzle-role.adapter.js"; +import { DrizzleRoleRepositoryAdapter } from "./drizzle-role.repository.js"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import * as schema from "../schema.js"; +import * as schema from "../../schema.js"; import { eq, desc } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; @@ -46,13 +46,13 @@ const mkDb = () => { return db; }; -describe("DrizzleRoleAdapter", () => { +describe("DrizzleRoleRepositoryAdapter", () => { let db: NodePgDatabase & MockDb; - let adapter: DrizzleRoleAdapter; + let adapter: DrizzleRoleRepositoryAdapter; beforeEach(() => { db = mkDb(); - adapter = new DrizzleRoleAdapter(db); + adapter = new DrizzleRoleRepositoryAdapter(db); vi.mocked(uuidv4).mockReturnValue( "test-uuid" as unknown as ReturnType, ); diff --git a/packages/identity/src/adapters/drizzle-role.adapter.ts b/packages/identity/src/adapters/outbound/drizzle-role.repository.ts similarity index 90% rename from packages/identity/src/adapters/drizzle-role.adapter.ts rename to packages/identity/src/adapters/outbound/drizzle-role.repository.ts index 83244367..90b4faa1 100644 --- a/packages/identity/src/adapters/drizzle-role.adapter.ts +++ b/packages/identity/src/adapters/outbound/drizzle-role.repository.ts @@ -2,18 +2,18 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { eq, desc } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; -import * as schema from "../schema.js"; -import { IDENTITY_DB } from "../constants.js"; +import * as schema from "../../schema.js"; +import { IDENTITY_DB } from "../../constants.js"; import type { - IRoleProvider, + IRoleRepository, RoleEntity, FindRolesOptions, CreateRoleInput, UpdateRoleInput, -} from "../interfaces/index.js"; +} from "../../core/ports/outbound/index.js"; @Injectable() -export class DrizzleRoleAdapter implements IRoleProvider { +export class DrizzleRoleRepositoryAdapter implements IRoleRepository { constructor( @Inject(IDENTITY_DB) private readonly db: NodePgDatabase, ) {} diff --git a/packages/identity/src/adapters/drizzle-tenant.adapter.spec.ts b/packages/identity/src/adapters/outbound/drizzle-tenant.repository.spec.ts similarity index 91% rename from packages/identity/src/adapters/drizzle-tenant.adapter.spec.ts rename to packages/identity/src/adapters/outbound/drizzle-tenant.repository.spec.ts index 0d9c19da..86ff7fa6 100644 --- a/packages/identity/src/adapters/drizzle-tenant.adapter.spec.ts +++ b/packages/identity/src/adapters/outbound/drizzle-tenant.repository.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-assignment */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { Tenant as TenantInterface } from "../interfaces/index.js"; -import type { IIdentityEventPublisher } from "../interfaces/index.js"; -import { DrizzleTenantAdapter } from "./drizzle-tenant.adapter.js"; -import * as schema from "../schema.js"; +import { Tenant as TenantInterface } from "../../core/ports/outbound/index.js"; +import type { IIdentityEventPublisher } from "../../core/ports/outbound/index.js"; +import { DrizzleTenantRepositoryAdapter } from "./drizzle-tenant.repository.js"; +import * as schema from "../../schema.js"; type MockFunc = ReturnType; @@ -46,7 +46,7 @@ interface MockDb { }; } -describe("DrizzleTenantAdapter", () => { +describe("DrizzleTenantRepositoryAdapter", () => { const now = new Date("2024-01-01T00:00:00.000Z"); const mkOrg = ( @@ -128,7 +128,7 @@ describe("DrizzleTenantAdapter", () => { it("create creates organization and admin member, handles slug collision retries", async () => { const { db, tx } = mkDb(); const publisher = mkPublisher(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, publisher, ); @@ -171,7 +171,7 @@ describe("DrizzleTenantAdapter", () => { it("createTenant inserts organization and maps result; duplicate slug throws", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -192,7 +192,7 @@ describe("DrizzleTenantAdapter", () => { it("createTenant validates empty slug", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -204,7 +204,7 @@ describe("DrizzleTenantAdapter", () => { it("update trims slug, serializes metadata, handles unique violation", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -235,7 +235,7 @@ describe("DrizzleTenantAdapter", () => { it("update rejects empty slug", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -247,7 +247,7 @@ describe("DrizzleTenantAdapter", () => { it("delete removes related rows and organization; not found throws", async () => { const { db, tx } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -267,7 +267,7 @@ describe("DrizzleTenantAdapter", () => { it("findAllForUser joins member and organization and maps role", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -292,7 +292,7 @@ describe("DrizzleTenantAdapter", () => { it("findAll supports pagination and search; returns total", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -324,7 +324,7 @@ describe("DrizzleTenantAdapter", () => { it("findById returns mapped tenant or null", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -342,7 +342,7 @@ describe("DrizzleTenantAdapter", () => { it("findBySlug returns mapped tenant or null", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -360,7 +360,7 @@ describe("DrizzleTenantAdapter", () => { it("updateStatus updates and returns mapped tenant; not found throws", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); @@ -384,7 +384,7 @@ describe("DrizzleTenantAdapter", () => { it("provisionTenantForUser delegates to create", async () => { const { db } = mkDb(); - const adapter = new DrizzleTenantAdapter( + const adapter = new DrizzleTenantRepositoryAdapter( db as unknown as NodePgDatabase, mkPublisher(), ); diff --git a/packages/identity/src/adapters/drizzle-tenant.adapter.ts b/packages/identity/src/adapters/outbound/drizzle-tenant.repository.ts similarity index 95% rename from packages/identity/src/adapters/drizzle-tenant.adapter.ts rename to packages/identity/src/adapters/outbound/drizzle-tenant.repository.ts index 277c9c6c..b6ffc866 100644 --- a/packages/identity/src/adapters/drizzle-tenant.adapter.ts +++ b/packages/identity/src/adapters/outbound/drizzle-tenant.repository.ts @@ -1,17 +1,21 @@ import { Inject, Injectable } from "@nestjs/common"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; -import { IDENTITY_DB, IDENTITY_EVENT_PUBLISHER, Role } from "../constants.js"; +import { + IDENTITY_DB, + IDENTITY_EVENT_PUBLISHER, + Role, +} from "../../constants.js"; import { eq, count, ilike, desc, and } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import type { - ITenantProvider, + ITenantRepository, Tenant as TenantInterface, UpdateTenantInput, IIdentityEventPublisher, -} from "../interfaces/index.js"; -import { TenantProvisionedEvent } from "../events/index.js"; -import * as schema from "../schema.js"; -import { generateFancyTenantName } from "../utils/name-generator.js"; +} from "../../core/ports/outbound/index.js"; +import { TenantProvisionedEvent } from "../../events/index.js"; +import * as schema from "../../schema.js"; +import { generateFancyTenantName } from "../../utils/name-generator.js"; interface PgError extends Error { code: string; @@ -19,7 +23,7 @@ interface PgError extends Error { } @Injectable() -export class DrizzleTenantAdapter implements ITenantProvider { +export class DrizzleTenantRepositoryAdapter implements ITenantRepository { constructor( @Inject(IDENTITY_DB) private readonly db: NodePgDatabase, @Inject(IDENTITY_EVENT_PUBLISHER) diff --git a/packages/identity/src/adapters/drizzle-user.adapter.spec.ts b/packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts similarity index 91% rename from packages/identity/src/adapters/drizzle-user.adapter.spec.ts rename to packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts index e9b7a0d9..efe7730b 100644 --- a/packages/identity/src/adapters/drizzle-user.adapter.spec.ts +++ b/packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { DrizzleUserAdapter } from "./drizzle-user.adapter.js"; -import * as schema from "../schema.js"; -import { UserNotFoundError } from "../interfaces/index.js"; -import type { IdentityModuleOptions } from "../identity.module.js"; +import { DrizzleUserRepositoryAdapter } from "./drizzle-user.repository.js"; +import * as schema from "../../schema.js"; +import { UserNotFoundError } from "../../core/ports/outbound/index.js"; +import type { IdentityModuleOptions } from "../../identity.module.js"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { IAuthProvider, CreateUserInput, UpdateUserInput, -} from "../interfaces/index.js"; +} from "../../core/ports/outbound/index.js"; type MockFunc = ReturnType; @@ -118,7 +118,7 @@ const mkOptions = (): IdentityModuleOptions => ({ adminRoleId: "admin-role-id", memberRoleId: "member-role-id", }, - // Minimal stub — DrizzleUserAdapter does not use betterAuthConfig directly; + // Minimal stub — DrizzleUserRepositoryAdapter does not use betterAuthConfig directly; // it is required by IdentityModuleOptions but unused in this adapter's logic. betterAuthConfig: { allowedOrigins: [], @@ -126,7 +126,7 @@ const mkOptions = (): IdentityModuleOptions => ({ }, }); -describe("DrizzleUserAdapter", () => { +describe("DrizzleUserRepositoryAdapter", () => { beforeEach(() => { vi.restoreAllMocks(); }); @@ -134,7 +134,7 @@ describe("DrizzleUserAdapter", () => { it("create delegates to auth provider", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); const user = await adapter.create({ email: "a@b.com", @@ -146,7 +146,7 @@ describe("DrizzleUserAdapter", () => { it("update handles password via auth provider; updates fields; throws if missing user after update", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); // password path db.query.user.findFirst.mockResolvedValueOnce(mkUser({ id: "u1" })); @@ -164,7 +164,7 @@ describe("DrizzleUserAdapter", () => { expect(res.name).toBe("A"); // missing setPassword support - const adapter2 = new DrizzleUserAdapter(db, mkOptions(), { + const adapter2 = new DrizzleUserRepositoryAdapter(db, mkOptions(), { createUser: (input: CreateUserInput) => auth.createUser(input), } as unknown as IAuthProvider); await expect( @@ -182,7 +182,7 @@ describe("DrizzleUserAdapter", () => { it("delete cascades and related tables in a transaction", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); // Access the transaction mock to verify cascade behavior const txCalls: any[] = []; @@ -212,7 +212,7 @@ describe("DrizzleUserAdapter", () => { it("findById and findByEmail return mapped or null", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); auth.findById = vi.fn().mockResolvedValue(mkUser({ id: "u1" })); const byId = await adapter.findById("u1"); @@ -225,7 +225,11 @@ describe("DrizzleUserAdapter", () => { const authNoFind = mkAuth(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access delete (authNoFind as any).findById; - const adapterFallback = new DrizzleUserAdapter(db, mkOptions(), authNoFind); + const adapterFallback = new DrizzleUserRepositoryAdapter( + db, + mkOptions(), + authNoFind, + ); db.query.user.findFirst.mockResolvedValueOnce(mkUser({ id: "u2" })); const byIdFallback = await adapterFallback.findById("u2"); @@ -255,7 +259,7 @@ describe("DrizzleUserAdapter", () => { it("deleteIfNotLastAdmin handles various scenarios", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); const mockSelect = vi.fn(); const mockTx = { @@ -382,7 +386,7 @@ describe("DrizzleUserAdapter", () => { it("forceVerifyEmail updates user", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); await adapter.forceVerifyEmail("u1"); @@ -398,7 +402,7 @@ describe("DrizzleUserAdapter", () => { it("count returns total users with optional filtering", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); // Mock count result const mockCountResult = [{ count: 5 }]; @@ -424,7 +428,7 @@ describe("DrizzleUserAdapter", () => { it("findAll builds search filters correctly", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); const dataChain = { from: vi.fn().mockReturnThis(), @@ -456,7 +460,7 @@ describe("DrizzleUserAdapter", () => { it("findAll handles tenant scoping", async () => { const db = mkDb(); const auth = mkAuth(); - const adapter = new DrizzleUserAdapter(db, mkOptions(), auth); + const adapter = new DrizzleUserRepositoryAdapter(db, mkOptions(), auth); const dataChain = { from: vi.fn().mockReturnThis(), diff --git a/packages/identity/src/adapters/drizzle-user.adapter.ts b/packages/identity/src/adapters/outbound/drizzle-user.repository.ts similarity index 95% rename from packages/identity/src/adapters/drizzle-user.adapter.ts rename to packages/identity/src/adapters/outbound/drizzle-user.repository.ts index a244f04a..fb18723e 100644 --- a/packages/identity/src/adapters/drizzle-user.adapter.ts +++ b/packages/identity/src/adapters/outbound/drizzle-user.repository.ts @@ -2,20 +2,24 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { eq, and, ilike, count, desc } from "drizzle-orm"; import { - IUserProvider, + IUserRepository, UpdateUserInput, User as UserInterface, IAuthProvider, UserNotFoundError, -} from "../interfaces/index.js"; -import type { CreateUserInput } from "../interfaces/index.js"; -import * as schema from "../schema.js"; -import { AUTH_PROVIDER, IDENTITY_OPTIONS, IDENTITY_DB } from "../constants.js"; -import type { IdentityModuleOptions } from "../identity.module.js"; +} from "../../core/ports/outbound/index.js"; +import type { CreateUserInput } from "../../core/ports/outbound/index.js"; +import * as schema from "../../schema.js"; +import { + AUTH_PROVIDER, + IDENTITY_OPTIONS, + IDENTITY_DB, +} from "../../constants.js"; +import type { IdentityModuleOptions } from "../../identity.module.js"; @Injectable() -export class DrizzleUserAdapter implements IUserProvider { - private readonly logger = new Logger(DrizzleUserAdapter.name); +export class DrizzleUserRepositoryAdapter implements IUserRepository { + private readonly logger = new Logger(DrizzleUserRepositoryAdapter.name); constructor( @Inject(IDENTITY_DB) private readonly db: NodePgDatabase, @@ -120,7 +124,7 @@ export class DrizzleUserAdapter implements IUserProvider { this.logger.error( `findById failed for user ${id}: ${errorMessage}`, error instanceof Error ? error.stack : undefined, - "DrizzleUserAdapter.findById", + "DrizzleUserRepositoryAdapter.findById", ); throw error; } diff --git a/packages/identity/src/better-auth.config.ts b/packages/identity/src/better-auth.config.ts index 20b5c493..e3c5e739 100644 --- a/packages/identity/src/better-auth.config.ts +++ b/packages/identity/src/better-auth.config.ts @@ -2,10 +2,10 @@ import { validateFrontendUrl } from "./utils/url.util.js"; import { organization, admin } from "better-auth/plugins"; import { createAccessControl } from "better-auth/plugins/access"; import { createAuthMiddleware } from "better-auth/api"; -import type { ITenantProvider } from "./interfaces/tenant-provider.interface.js"; -import type { IEmailProvider } from "./interfaces/email-provider.interface.js"; -import type { BetterAuthAdapterConfig } from "./interfaces/better-auth-config.interface.js"; -import type { User as UserInterface } from "./interfaces/index.js"; +import type { ITenantRepository } from "./core/ports/outbound/tenant-repository.port.js"; +import type { IEmailProvider } from "./core/ports/outbound/email-provider.port.js"; +import type { BetterAuthAdapterConfig } from "./core/ports/outbound/better-auth-config.port.js"; +import type { User as UserInterface } from "./core/ports/outbound/index.js"; // ... (existing imports) import type { HookEndpointContext } from "better-auth"; @@ -58,7 +58,7 @@ const getApiFromContext = ( export const getBetterAuthPlugins = ( emailService: IEmailProvider, config: BetterAuthAdapterConfig, - tenantProvider: ITenantProvider, + tenantProvider: ITenantRepository, ) => { // Define statements matching Better Auth's organization plugin expectations const statement = { diff --git a/packages/identity/src/constants.ts b/packages/identity/src/constants.ts index e066283d..5b4e91dc 100644 --- a/packages/identity/src/constants.ts +++ b/packages/identity/src/constants.ts @@ -1,12 +1,12 @@ export const IDENTITY_OPTIONS = "IDENTITY_OPTIONS"; export const AUTH_PROVIDER = "AUTH_PROVIDER"; -export const USER_PROVIDER = "USER_PROVIDER"; -export const TENANT_PROVIDER = "TENANT_PROVIDER"; -export const PERMISSION_PROVIDER = "PERMISSION_PROVIDER"; +export const USER_REPOSITORY = "USER_REPOSITORY"; +export const TENANT_REPOSITORY = "TENANT_REPOSITORY"; +export const PERMISSION_REPOSITORY = "PERMISSION_REPOSITORY"; export const EMAIL_PROVIDER = "EMAIL_PROVIDER"; export const IDENTITY_DB = "IDENTITY_DB"; export const BETTER_AUTH_CONFIG = "BETTER_AUTH_CONFIG"; -export const ROLE_PROVIDER = "ROLE_PROVIDER"; +export const ROLE_REPOSITORY = "ROLE_REPOSITORY"; export const IDENTITY_EVENT_PUBLISHER = "IDENTITY_EVENT_PUBLISHER"; export const ALL_PERMISSIONS = [ diff --git a/packages/identity/src/interfaces/auth-provider.interface.ts b/packages/identity/src/core/ports/outbound/auth-provider.port.ts similarity index 95% rename from packages/identity/src/interfaces/auth-provider.interface.ts rename to packages/identity/src/core/ports/outbound/auth-provider.port.ts index db2b54ca..613b0cbf 100644 --- a/packages/identity/src/interfaces/auth-provider.interface.ts +++ b/packages/identity/src/core/ports/outbound/auth-provider.port.ts @@ -1,5 +1,5 @@ import type { AuthResult, Invitation, Session, User } from "./types.js"; -import type { CreateUserInput } from "./user-provider.interface.js"; +import type { CreateUserInput } from "./user-repository.port.js"; export interface LoginCredentials { email: string; diff --git a/packages/identity/src/interfaces/better-auth-config.interface.ts b/packages/identity/src/core/ports/outbound/better-auth-config.port.ts similarity index 100% rename from packages/identity/src/interfaces/better-auth-config.interface.ts rename to packages/identity/src/core/ports/outbound/better-auth-config.port.ts diff --git a/packages/identity/src/interfaces/email-provider.interface.ts b/packages/identity/src/core/ports/outbound/email-provider.port.ts similarity index 100% rename from packages/identity/src/interfaces/email-provider.interface.ts rename to packages/identity/src/core/ports/outbound/email-provider.port.ts diff --git a/packages/identity/src/interfaces/errors.ts b/packages/identity/src/core/ports/outbound/errors.ts similarity index 100% rename from packages/identity/src/interfaces/errors.ts rename to packages/identity/src/core/ports/outbound/errors.ts diff --git a/packages/identity/src/interfaces/event-publisher.interface.ts b/packages/identity/src/core/ports/outbound/event-publisher.port.ts similarity index 51% rename from packages/identity/src/interfaces/event-publisher.interface.ts rename to packages/identity/src/core/ports/outbound/event-publisher.port.ts index c8ed2ac5..2b4a1469 100644 --- a/packages/identity/src/interfaces/event-publisher.interface.ts +++ b/packages/identity/src/core/ports/outbound/event-publisher.port.ts @@ -1,5 +1,5 @@ -import type { TenantProvisionedEvent } from "../events/tenant-provisioned.event.js"; -import type { UserInvitedEvent } from "../events/user-invited.event.js"; +import type { TenantProvisionedEvent } from "../../../events/tenant-provisioned.event.js"; +import type { UserInvitedEvent } from "../../../events/user-invited.event.js"; export interface IIdentityEventPublisher { publishTenantProvisioned(event: TenantProvisionedEvent): Promise; diff --git a/packages/identity/src/core/ports/outbound/index.ts b/packages/identity/src/core/ports/outbound/index.ts new file mode 100644 index 00000000..3658708a --- /dev/null +++ b/packages/identity/src/core/ports/outbound/index.ts @@ -0,0 +1,9 @@ +export * from "./types.js"; +export * from "./auth-provider.port.js"; +export * from "./user-repository.port.js"; +export * from "./tenant-repository.port.js"; +export * from "./permission-repository.port.js"; +export * from "./email-provider.port.js"; +export * from "./role-repository.port.js"; +export * from "./event-publisher.port.js"; +export * from "./errors.js"; diff --git a/packages/identity/src/interfaces/permission-provider.interface.ts b/packages/identity/src/core/ports/outbound/permission-repository.port.ts similarity index 95% rename from packages/identity/src/interfaces/permission-provider.interface.ts rename to packages/identity/src/core/ports/outbound/permission-repository.port.ts index 262a2471..441d8805 100644 --- a/packages/identity/src/interfaces/permission-provider.interface.ts +++ b/packages/identity/src/core/ports/outbound/permission-repository.port.ts @@ -9,7 +9,7 @@ export type PermissionAction = | "invite"; export type PermissionResource = string; // e.g. 'user', 'tenant', 'settings', 'billing', 'api_key' -export interface IPermissionProvider { +export interface IPermissionRepository { /** * Check if a user has permission to perform an action on a resource within a specific context (tenant). */ diff --git a/packages/identity/src/interfaces/role-provider.interface.ts b/packages/identity/src/core/ports/outbound/role-repository.port.ts similarity index 96% rename from packages/identity/src/interfaces/role-provider.interface.ts rename to packages/identity/src/core/ports/outbound/role-repository.port.ts index 0403c5b0..b18757ed 100644 --- a/packages/identity/src/interfaces/role-provider.interface.ts +++ b/packages/identity/src/core/ports/outbound/role-repository.port.ts @@ -21,7 +21,7 @@ export interface UpdateRoleInput { description?: string; } -export interface IRoleProvider { +export interface IRoleRepository { findAll(options?: FindRolesOptions): Promise; /** * Finds a role by ID. diff --git a/packages/identity/src/interfaces/tenant-provider.interface.ts b/packages/identity/src/core/ports/outbound/tenant-repository.port.ts similarity index 97% rename from packages/identity/src/interfaces/tenant-provider.interface.ts rename to packages/identity/src/core/ports/outbound/tenant-repository.port.ts index ef9dd6e3..526cf701 100644 --- a/packages/identity/src/interfaces/tenant-provider.interface.ts +++ b/packages/identity/src/core/ports/outbound/tenant-repository.port.ts @@ -8,7 +8,7 @@ export interface UpdateTenantInput { metadata?: Record; } -export interface ITenantProvider { +export interface ITenantRepository { // User-scoped creation (auto-adds member) create(userId: string, name: string): Promise; diff --git a/packages/identity/src/interfaces/types.ts b/packages/identity/src/core/ports/outbound/types.ts similarity index 73% rename from packages/identity/src/interfaces/types.ts rename to packages/identity/src/core/ports/outbound/types.ts index 830c0599..c433002e 100644 --- a/packages/identity/src/interfaces/types.ts +++ b/packages/identity/src/core/ports/outbound/types.ts @@ -56,3 +56,23 @@ export interface AuthResult { user: User; cookie?: string | string[]; } + +export type UserListItem = + | (User & { kind?: "user" }) + | { + kind: "invitation"; + id: string; + email: string; + name: string; + role: string; + status: "pending"; + emailVerified: boolean; + createdAt: Date; + updatedAt: Date; + isInvitation: true; + permissions?: string[]; + image?: string; + banned?: boolean; + banReason?: string | null; + banExpires?: Date | null; + }; diff --git a/packages/identity/src/interfaces/user-provider.interface.ts b/packages/identity/src/core/ports/outbound/user-repository.port.ts similarity index 97% rename from packages/identity/src/interfaces/user-provider.interface.ts rename to packages/identity/src/core/ports/outbound/user-repository.port.ts index d05754ff..e616b1dd 100644 --- a/packages/identity/src/interfaces/user-provider.interface.ts +++ b/packages/identity/src/core/ports/outbound/user-repository.port.ts @@ -13,7 +13,7 @@ export interface UpdateUserInput extends Partial { password?: string; } -export interface IUserProvider { +export interface IUserRepository { create(input: CreateUserInput): Promise; update(id: string, input: UpdateUserInput): Promise; diff --git a/packages/identity/src/core/use-cases/users/create-user.use-case.spec.ts b/packages/identity/src/core/use-cases/users/create-user.use-case.spec.ts new file mode 100644 index 00000000..5ab4b894 --- /dev/null +++ b/packages/identity/src/core/use-cases/users/create-user.use-case.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CreateUserUseCase } from "./create-user.use-case.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; + +describe("CreateUserUseCase", () => { + let useCase: CreateUserUseCase; + let userRepository: { create: ReturnType }; + + beforeEach(() => { + userRepository = { create: vi.fn() }; + useCase = new CreateUserUseCase( + userRepository as unknown as IUserRepository, + ); + }); + + it("should call userRepository.create and return the result", async () => { + const payload = { email: "test@example.com", role: "member" as const }; + const expectedUser = { id: "user1", email: "test@example.com" }; + + userRepository.create.mockResolvedValue(expectedUser); + + const result = await useCase.execute(payload); + + expect(result).toEqual(expectedUser); + expect(userRepository.create).toHaveBeenCalledWith(payload); + }); +}); diff --git a/packages/identity/src/core/use-cases/users/create-user.use-case.ts b/packages/identity/src/core/use-cases/users/create-user.use-case.ts new file mode 100644 index 00000000..18cb071a --- /dev/null +++ b/packages/identity/src/core/use-cases/users/create-user.use-case.ts @@ -0,0 +1,17 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { USER_REPOSITORY } from "../../../constants.js"; +import type { + IUserRepository, + CreateUserInput, +} from "../../ports/outbound/user-repository.port.js"; + +@Injectable() +export class CreateUserUseCase { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, + ) {} + + async execute(input: CreateUserInput) { + return this.userRepository.create(input); + } +} diff --git a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts new file mode 100644 index 00000000..60f7ec8a --- /dev/null +++ b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GetUserByIdUseCase } from "./get-user-by-id.use-case.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; +import type { ITenantRepository } from "../../ports/outbound/tenant-repository.port.js"; +import { NotFoundException } from "@nestjs/common"; + +describe("GetUserByIdUseCase", () => { + let useCase: GetUserByIdUseCase; + let userRepository: { findById: ReturnType }; + let tenantRepository: { findAllForUser: ReturnType }; + + beforeEach(() => { + userRepository = { findById: vi.fn() }; + tenantRepository = { findAllForUser: vi.fn() }; + useCase = new GetUserByIdUseCase( + userRepository as unknown as IUserRepository, + tenantRepository as unknown as ITenantRepository, + ); + }); + + it("should throw NotFoundException if no tenantId provided", async () => { + await expect(useCase.execute("user1")).rejects.toThrow(NotFoundException); + }); + + it("should throw NotFoundException if user not found", async () => { + userRepository.findById.mockResolvedValue(null); + await expect(useCase.execute("user1", "tenant1")).rejects.toThrow( + NotFoundException, + ); + }); + + it("should throw NotFoundException if user is not member of tenant", async () => { + const user = { id: "user1" }; + userRepository.findById.mockResolvedValue(user); + tenantRepository.findAllForUser.mockResolvedValue([{ id: "other-tenant" }]); + + await expect(useCase.execute("user1", "tenant1")).rejects.toThrow( + NotFoundException, + ); + }); + + it("should return user if member of tenant", async () => { + const user = { id: "user1" }; + userRepository.findById.mockResolvedValue(user); + tenantRepository.findAllForUser.mockResolvedValue([{ id: "tenant1" }]); + + const result = await useCase.execute("user1", "tenant1"); + expect(result).toEqual(user); + }); +}); diff --git a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts new file mode 100644 index 00000000..13bafbb2 --- /dev/null +++ b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { USER_REPOSITORY, TENANT_REPOSITORY } from "../../../constants.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; +import type { ITenantRepository } from "../../ports/outbound/tenant-repository.port.js"; + +@Injectable() +export class GetUserByIdUseCase { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, + @Inject(TENANT_REPOSITORY) + private readonly tenantRepository: ITenantRepository, + ) {} + + async execute(id: string, tenantId?: string) { + // If no tenantId is provided (e.g., system admin or pure identity scope), + // they don't have access to this endpoint by default unless we allow it, + // but the controller ensures tenantId is passed for normal requests. + if (!tenantId) { + throw new NotFoundException("User not found"); + } + + const user = await this.userRepository.findById(id); + + if (!user) { + throw new NotFoundException("User not found"); + } + + // Tenant Scoping check + // Ensure the target user is a member of the requester's tenant + const userTenants = await this.tenantRepository.findAllForUser(user.id); + const isMember = userTenants.some((t) => t.id === tenantId); + + if (!isMember) { + throw new NotFoundException("User not found"); // Mask existence for security + } + + return user; + } +} diff --git a/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts new file mode 100644 index 00000000..d16e061e --- /dev/null +++ b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GetUserProfileUseCase } from "./get-user-profile.use-case.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; + +describe("GetUserProfileUseCase", () => { + let useCase: GetUserProfileUseCase; + let userRepository: { findById: ReturnType }; + + beforeEach(() => { + userRepository = { findById: vi.fn() }; + useCase = new GetUserProfileUseCase( + userRepository as unknown as IUserRepository, + ); + }); + + it("should call userRepository.findById and return the user", async () => { + const expectedUser = { id: "user1", email: "test@example.com" }; + userRepository.findById.mockResolvedValue(expectedUser); + + const result = await useCase.execute("user1"); + + expect(result).toEqual(expectedUser); + expect(userRepository.findById).toHaveBeenCalledWith("user1"); + }); +}); diff --git a/packages/identity/src/core/use-cases/users/get-user-profile.use-case.ts b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.ts new file mode 100644 index 00000000..9bae189f --- /dev/null +++ b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.ts @@ -0,0 +1,18 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { USER_REPOSITORY } from "../../../constants.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; + +@Injectable() +export class GetUserProfileUseCase { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, + ) {} + + async execute(userId: string) { + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundException("User not found"); + } + return user; + } +} diff --git a/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.spec.ts b/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.spec.ts new file mode 100644 index 00000000..7bd4498f --- /dev/null +++ b/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ListUsersWithInvitationsUseCase } from "./list-users-with-invitations.use-case.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; +import type { IAuthProvider } from "../../ports/outbound/auth-provider.port.js"; + +describe("ListUsersWithInvitationsUseCase", () => { + let useCase: ListUsersWithInvitationsUseCase; + let userRepository: { findAll: ReturnType }; + let authProvider: { listInvitations: ReturnType }; + + beforeEach(() => { + userRepository = { findAll: vi.fn() }; + authProvider = { listInvitations: vi.fn() }; + useCase = new ListUsersWithInvitationsUseCase( + userRepository as unknown as IUserRepository, + authProvider as unknown as IAuthProvider, + ); + }); + + it("should return empty if tenantId is missing", async () => { + const result = await useCase.execute(""); + expect(result).toEqual({ data: [], total: 0 }); + }); + + it("should combine users and pending invitations", async () => { + const users = [{ id: "u1", email: "user@example.com" }]; + userRepository.findAll.mockResolvedValue({ data: users, total: 1 }); + + const invitations = [ + { + id: "i1", + email: "user@example.com", + role: "member", + createdAt: new Date(), + }, // duplicate + { + id: "i2", + email: "new@example.com", + role: "admin", + createdAt: new Date(), + }, // new + ]; + authProvider.listInvitations.mockResolvedValue(invitations); + + const result = await useCase.execute("org-1"); + expect(result.data).toHaveLength(2); // 1 user + 1 new invitation + expect(result.total).toBe(2); + const invitation = result.data[1] as unknown as { + email: string; + isInvitation: boolean; + status: string; + }; + expect(invitation.email).toBe("new@example.com"); + expect(invitation.isInvitation).toBe(true); + expect(invitation.status).toBe("pending"); + }); +}); diff --git a/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts b/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts new file mode 100644 index 00000000..74dfdc8a --- /dev/null +++ b/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts @@ -0,0 +1,57 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { IUserRepository } from "../../ports/outbound/user-repository.port.js"; +import { USER_REPOSITORY, AUTH_PROVIDER } from "../../../constants.js"; +import type { IAuthProvider } from "../../ports/outbound/auth-provider.port.js"; +import type { UserListItem, Invitation } from "../../ports/outbound/types.js"; + +@Injectable() +export class ListUsersWithInvitationsUseCase { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, + @Inject(AUTH_PROVIDER) private readonly authProvider: IAuthProvider, + ) {} + + async execute( + tenantId: string, + ): Promise<{ data: UserListItem[]; total: number }> { + if (!tenantId) { + return { data: [], total: 0 }; + } + + const { data: users, total: userTotal } = await this.userRepository.findAll( + { tenantId }, + ); + const invitations = await this.authProvider.listInvitations(tenantId); + + const existingEmails = new Set( + users.map((u: { email: string }) => u.email.toLowerCase()), + ); + const pendingInvitations = invitations.filter( + (inv: { email: string }) => !existingEmails.has(inv.email.toLowerCase()), + ); + + const invitedUsers: UserListItem[] = pendingInvitations.map( + (inv: Invitation) => ({ + id: inv.id, + email: inv.email, + name: "", + role: inv.role ?? "member", + status: "pending", + emailVerified: false, + createdAt: inv.createdAt, + updatedAt: inv.createdAt, + isInvitation: true, + permissions: undefined, + image: undefined, + banned: false, + }), + ); + + const combinedData = [...users, ...invitedUsers]; + + return { + data: combinedData, + total: userTotal + pendingInvitations.length, + }; + } +} diff --git a/packages/identity/src/core/use-cases/users/remove-user.use-case.spec.ts b/packages/identity/src/core/use-cases/users/remove-user.use-case.spec.ts new file mode 100644 index 00000000..ad3e2fee --- /dev/null +++ b/packages/identity/src/core/use-cases/users/remove-user.use-case.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { RemoveUserUseCase } from "./remove-user.use-case.js"; +import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; + +describe("RemoveUserUseCase", () => { + let useCase: RemoveUserUseCase; + let userRepository: { deleteIfNotLastAdmin: ReturnType }; + + beforeEach(() => { + userRepository = { deleteIfNotLastAdmin: vi.fn() }; + useCase = new RemoveUserUseCase( + userRepository as unknown as IUserRepository, + ); + }); + + it("should throw BadRequestException if deleting self", async () => { + await expect(useCase.execute("u1", "u1", "tenant1")).rejects.toThrow( + BadRequestException, + ); + }); + + it("should throw BadRequestException if last admin", async () => { + userRepository.deleteIfNotLastAdmin.mockResolvedValue({ success: false }); + await expect(useCase.execute("u2", "u1", "tenant1")).rejects.toThrow( + BadRequestException, + ); + }); + + it("should throw NotFoundException if not a member", async () => { + userRepository.deleteIfNotLastAdmin.mockRejectedValue( + new Error("User is not a member of this organization"), + ); + await expect(useCase.execute("u2", "u1", "tenant1")).rejects.toThrow( + NotFoundException, + ); + }); + + it("should propagate generic errors", async () => { + userRepository.deleteIfNotLastAdmin.mockRejectedValue( + new Error("Generic db error"), + ); + await expect(useCase.execute("u2", "u1", "tenant1")).rejects.toThrow( + "Generic db error", + ); + }); + + it("should return success and hardDeleted status on success", async () => { + userRepository.deleteIfNotLastAdmin.mockResolvedValue({ + success: true, + hardDeleted: true, + }); + const result = await useCase.execute("u2", "u1", "tenant1"); + expect(result).toEqual({ success: true, hardDeleted: true }); + }); +}); diff --git a/packages/identity/src/core/use-cases/users/remove-user.use-case.ts b/packages/identity/src/core/use-cases/users/remove-user.use-case.ts new file mode 100644 index 00000000..0a030a36 --- /dev/null +++ b/packages/identity/src/core/use-cases/users/remove-user.use-case.ts @@ -0,0 +1,48 @@ +import { + Injectable, + Inject, + BadRequestException, + NotFoundException, +} from "@nestjs/common"; +import { IUserRepository } from "../../ports/outbound/user-repository.port.js"; +import { USER_REPOSITORY } from "../../../constants.js"; + +@Injectable() +export class RemoveUserUseCase { + constructor( + @Inject(USER_REPOSITORY) private readonly userRepository: IUserRepository, + ) {} + + async execute( + userId: string, + requestingUserId: string, + tenantId: string, + ): Promise<{ success: boolean; hardDeleted: boolean }> { + if (userId === requestingUserId) { + throw new BadRequestException("You cannot delete your own account."); + } + + try { + const result = await this.userRepository.deleteIfNotLastAdmin( + userId, + tenantId, + ); + + if (!result.success) { + throw new BadRequestException( + "Cannot delete the last admin of the organization", + ); + } + + return { success: true, hardDeleted: result.hardDeleted ?? false }; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("not a member of this organization") + ) { + throw new NotFoundException("User not found in this organization"); + } + throw error; + } + } +} diff --git a/packages/identity/src/identity.module.spec.ts b/packages/identity/src/identity.module.spec.ts index bfee5918..79568d88 100644 --- a/packages/identity/src/identity.module.spec.ts +++ b/packages/identity/src/identity.module.spec.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest"; import { IdentityModule, IdentityModuleOptions } from "./identity.module.js"; import { AUTH_PROVIDER, - USER_PROVIDER, - TENANT_PROVIDER, - PERMISSION_PROVIDER, + USER_REPOSITORY, + TENANT_REPOSITORY, + PERMISSION_REPOSITORY, IDENTITY_OPTIONS, } from "./constants.js"; @@ -66,15 +66,15 @@ describe("IdentityModule.register", () => { const tokens = providers.map((p) => p.provide); expect(tokens).toContain(AUTH_PROVIDER); - expect(tokens).toContain(USER_PROVIDER); - expect(tokens).toContain(TENANT_PROVIDER); - expect(tokens).toContain(PERMISSION_PROVIDER); + expect(tokens).toContain(USER_REPOSITORY); + expect(tokens).toContain(TENANT_REPOSITORY); + expect(tokens).toContain(PERMISSION_REPOSITORY); // exports contain tokens expect(mod.exports).toContain(AUTH_PROVIDER); - expect(mod.exports).toContain(USER_PROVIDER); - expect(mod.exports).toContain(TENANT_PROVIDER); - expect(mod.exports).toContain(PERMISSION_PROVIDER); + expect(mod.exports).toContain(USER_REPOSITORY); + expect(mod.exports).toContain(TENANT_REPOSITORY); + expect(mod.exports).toContain(PERMISSION_REPOSITORY); // factories inject requested tokens // Verify providers use correct classes @@ -93,15 +93,18 @@ describe("IdentityModule.register", () => { const authProv = assertClassProvider(AUTH_PROVIDER, "AUTH_PROVIDER"); expect(authProv.useClass).toBeDefined(); - const userProv = assertClassProvider(USER_PROVIDER, "USER_PROVIDER"); + const userProv = assertClassProvider(USER_REPOSITORY, "USER_REPOSITORY"); expect(userProv.useClass).toBeDefined(); - const tenantProv = assertClassProvider(TENANT_PROVIDER, "TENANT_PROVIDER"); + const tenantProv = assertClassProvider( + TENANT_REPOSITORY, + "TENANT_REPOSITORY", + ); expect(tenantProv.useClass).toBeDefined(); const permProv = assertClassProvider( - PERMISSION_PROVIDER, - "PERMISSION_PROVIDER", + PERMISSION_REPOSITORY, + "PERMISSION_REPOSITORY", ); expect(permProv.useClass).toBeDefined(); }); diff --git a/packages/identity/src/identity.module.ts b/packages/identity/src/identity.module.ts index 9acbf922..2512b4ee 100644 --- a/packages/identity/src/identity.module.ts +++ b/packages/identity/src/identity.module.ts @@ -1,28 +1,37 @@ -import type { DynamicModule, ModuleMetadata, Type } from "@nestjs/common"; +import type { + DynamicModule, + ModuleMetadata, + InjectionToken, +} from "@nestjs/common"; import { Global, Module } from "@nestjs/common"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { AUTH_PROVIDER, - USER_PROVIDER, - TENANT_PROVIDER, - PERMISSION_PROVIDER, + USER_REPOSITORY, + TENANT_REPOSITORY, + PERMISSION_REPOSITORY, EMAIL_PROVIDER, IDENTITY_OPTIONS, IDENTITY_DB, BETTER_AUTH_CONFIG, - ROLE_PROVIDER, + ROLE_REPOSITORY, IDENTITY_EVENT_PUBLISHER, } from "./constants.js"; -import { BetterAuthAdapter } from "./adapters/better-auth.adapter.js"; -import type { BetterAuthAdapterConfig } from "./interfaces/better-auth-config.interface.js"; -import { DrizzleUserAdapter } from "./adapters/drizzle-user.adapter.js"; -import { DrizzleTenantAdapter } from "./adapters/drizzle-tenant.adapter.js"; -import { DrizzlePermissionAdapter } from "./adapters/drizzle-permission.adapter.js"; -import { DrizzleRoleAdapter } from "./adapters/drizzle-role.adapter.js"; -import type { IEmailProvider } from "./interfaces/email-provider.interface.js"; +import { BetterAuthAdapter } from "./adapters/outbound/better-auth.adapter.js"; +import type { BetterAuthAdapterConfig } from "./core/ports/outbound/better-auth-config.port.js"; +import { DrizzleUserRepositoryAdapter } from "./adapters/outbound/drizzle-user.repository.js"; +import { DrizzleTenantRepositoryAdapter } from "./adapters/outbound/drizzle-tenant.repository.js"; +import { DrizzlePermissionRepositoryAdapter } from "./adapters/outbound/drizzle-permission.repository.js"; +import { DrizzleRoleRepositoryAdapter } from "./adapters/outbound/drizzle-role.repository.js"; +import type { IEmailProvider } from "./core/ports/outbound/email-provider.port.js"; import * as schema from "./schema.js"; import { PermissionSeeder } from "./services/permission-seeder.js"; import { IdentityEventPublisher } from "./services/identity-event-publisher.service.js"; +import { ListUsersWithInvitationsUseCase } from "./core/use-cases/users/list-users-with-invitations.use-case.js"; +import { RemoveUserUseCase } from "./core/use-cases/users/remove-user.use-case.js"; +import { GetUserProfileUseCase } from "./core/use-cases/users/get-user-profile.use-case.js"; +import { CreateUserUseCase } from "./core/use-cases/users/create-user.use-case.js"; +import { GetUserByIdUseCase } from "./core/use-cases/users/get-user-by-id.use-case.js"; export interface IdentityConstants { systemTenantId: string; @@ -34,11 +43,9 @@ export interface IdentityConstants { export interface IdentityModuleOptions { betterAuthConfig: BetterAuthAdapterConfig; constants: IdentityConstants; - dbToken?: string | symbol | Type; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - emailToken?: string | symbol | Type | Function; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - eventPublisherToken?: string | symbol | Type | Function; + dbToken?: InjectionToken; + emailToken?: InjectionToken; + eventPublisherToken?: InjectionToken; // Resolved instances (for async injection) db?: NodePgDatabase; email?: IEmailProvider; @@ -84,20 +91,20 @@ export class IdentityModule { useClass: BetterAuthAdapter, }, { - provide: USER_PROVIDER, - useClass: DrizzleUserAdapter, + provide: USER_REPOSITORY, + useClass: DrizzleUserRepositoryAdapter, }, { - provide: TENANT_PROVIDER, - useClass: DrizzleTenantAdapter, + provide: TENANT_REPOSITORY, + useClass: DrizzleTenantRepositoryAdapter, }, { - provide: PERMISSION_PROVIDER, - useClass: DrizzlePermissionAdapter, + provide: PERMISSION_REPOSITORY, + useClass: DrizzlePermissionRepositoryAdapter, }, { - provide: ROLE_PROVIDER, - useClass: DrizzleRoleAdapter, + provide: ROLE_REPOSITORY, + useClass: DrizzleRoleRepositoryAdapter, }, options.eventPublisherToken ? { @@ -109,6 +116,11 @@ export class IdentityModule { useClass: IdentityEventPublisher, }, PermissionSeeder, + ListUsersWithInvitationsUseCase, + RemoveUserUseCase, + GetUserProfileUseCase, + CreateUserUseCase, + GetUserByIdUseCase, ], exports: [ IDENTITY_OPTIONS, @@ -116,12 +128,17 @@ export class IdentityModule { BETTER_AUTH_CONFIG, EMAIL_PROVIDER, AUTH_PROVIDER, - USER_PROVIDER, - TENANT_PROVIDER, - PERMISSION_PROVIDER, - ROLE_PROVIDER, + USER_REPOSITORY, + TENANT_REPOSITORY, + PERMISSION_REPOSITORY, + ROLE_REPOSITORY, IDENTITY_EVENT_PUBLISHER, PermissionSeeder, + ListUsersWithInvitationsUseCase, + RemoveUserUseCase, + GetUserProfileUseCase, + CreateUserUseCase, + GetUserByIdUseCase, ], }; } @@ -130,13 +147,11 @@ export class IdentityModule { imports?: ModuleMetadata["imports"]; useFactory: ( - ...args: any[] + ...args: unknown[] ) => Promise | IdentityModuleOptions; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - inject?: (string | symbol | Type | Function)[]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - eventPublisherToken?: string | symbol | Type | Function; + inject?: InjectionToken[]; + eventPublisherToken?: InjectionToken; }): DynamicModule { return { module: IdentityModule, @@ -145,8 +160,7 @@ export class IdentityModule { { provide: IDENTITY_OPTIONS, - useFactory: async (...args: any[]) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + useFactory: async (...args: unknown[]) => { const identityOptions = await options.useFactory(...args); if (!identityOptions.db || !identityOptions.email) { throw new Error( @@ -189,20 +203,20 @@ export class IdentityModule { useClass: BetterAuthAdapter, }, { - provide: USER_PROVIDER, - useClass: DrizzleUserAdapter, + provide: USER_REPOSITORY, + useClass: DrizzleUserRepositoryAdapter, }, { - provide: TENANT_PROVIDER, - useClass: DrizzleTenantAdapter, + provide: TENANT_REPOSITORY, + useClass: DrizzleTenantRepositoryAdapter, }, { - provide: PERMISSION_PROVIDER, - useClass: DrizzlePermissionAdapter, + provide: PERMISSION_REPOSITORY, + useClass: DrizzlePermissionRepositoryAdapter, }, { - provide: ROLE_PROVIDER, - useClass: DrizzleRoleAdapter, + provide: ROLE_REPOSITORY, + useClass: DrizzleRoleRepositoryAdapter, }, options.eventPublisherToken ? { @@ -214,6 +228,11 @@ export class IdentityModule { useClass: IdentityEventPublisher, }, PermissionSeeder, + ListUsersWithInvitationsUseCase, + RemoveUserUseCase, + GetUserProfileUseCase, + CreateUserUseCase, + GetUserByIdUseCase, ], exports: [ IDENTITY_OPTIONS, @@ -221,12 +240,17 @@ export class IdentityModule { BETTER_AUTH_CONFIG, EMAIL_PROVIDER, AUTH_PROVIDER, - USER_PROVIDER, - TENANT_PROVIDER, - PERMISSION_PROVIDER, - ROLE_PROVIDER, + USER_REPOSITORY, + TENANT_REPOSITORY, + PERMISSION_REPOSITORY, + ROLE_REPOSITORY, IDENTITY_EVENT_PUBLISHER, PermissionSeeder, + ListUsersWithInvitationsUseCase, + RemoveUserUseCase, + GetUserProfileUseCase, + CreateUserUseCase, + GetUserByIdUseCase, ], }; } diff --git a/packages/identity/src/index.spec.ts b/packages/identity/src/index.spec.ts index 8ff94eee..59fdbe3e 100644 --- a/packages/identity/src/index.spec.ts +++ b/packages/identity/src/index.spec.ts @@ -35,20 +35,20 @@ vi.mock("better-auth/node", () => ({ fromNodeHeaders: vi.fn(), })); -vi.mock("./adapters/better-auth.adapter.js", () => ({ +vi.mock("./adapters/outbound/better-auth.adapter.js", () => ({ BetterAuthAdapter: class { }, })); -vi.mock("./adapters/drizzle-user.adapter.js", () => ({ - DrizzleUserAdapter: class { }, +vi.mock("./adapters/outbound/drizzle-user.repository.js", () => ({ + DrizzleUserRepositoryAdapter: class { }, })); -vi.mock("./adapters/drizzle-tenant.adapter.js", () => ({ - DrizzleTenantAdapter: class { }, +vi.mock("./adapters/outbound/drizzle-tenant.repository.js", () => ({ + DrizzleTenantRepositoryAdapter: class { }, })); -vi.mock("./adapters/drizzle-permission.adapter.js", () => ({ - DrizzlePermissionAdapter: class { }, +vi.mock("./adapters/outbound/drizzle-permission.repository.js", () => ({ + DrizzlePermissionRepositoryAdapter: class { }, })); -vi.mock("./adapters/drizzle-role.adapter.js", () => ({ - DrizzleRoleAdapter: class { }, +vi.mock("./adapters/outbound/drizzle-role.repository.js", () => ({ + DrizzleRoleRepositoryAdapter: class { }, })); describe("Identity Package", () => { @@ -57,12 +57,12 @@ describe("Identity Package", () => { expect((IdentityPackage as any).BetterAuthAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzleUserAdapter).toBeDefined(); + expect((IdentityPackage as any).DrizzleUserRepositoryAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzleTenantAdapter).toBeDefined(); + expect((IdentityPackage as any).DrizzleTenantRepositoryAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzlePermissionAdapter).toBeDefined(); + expect((IdentityPackage as any).DrizzlePermissionRepositoryAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzleRoleAdapter).toBeDefined(); + expect((IdentityPackage as any).DrizzleRoleRepositoryAdapter).toBeDefined(); }); }); diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index ee2ea6b7..18a3d98f 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -1,4 +1,4 @@ -export * from "./interfaces/index.js"; +export * from "./core/ports/outbound/index.js"; export { user, @@ -31,11 +31,16 @@ export type { Invitation as DbInvitation, } from "./schema.js"; -export * from "./adapters/better-auth.adapter.js"; -export * from "./adapters/drizzle-user.adapter.js"; -export * from "./adapters/drizzle-tenant.adapter.js"; -export * from "./adapters/drizzle-permission.adapter.js"; -export * from "./adapters/drizzle-role.adapter.js"; +export * from "./adapters/outbound/better-auth.adapter.js"; +export * from "./adapters/outbound/drizzle-user.repository.js"; +export * from "./adapters/outbound/drizzle-tenant.repository.js"; +export * from "./adapters/outbound/drizzle-permission.repository.js"; +export * from "./adapters/outbound/drizzle-role.repository.js"; export * from "./identity.module.js"; export * from "./constants.js"; +export * from "./core/use-cases/users/list-users-with-invitations.use-case.js"; +export * from "./core/use-cases/users/remove-user.use-case.js"; +export * from "./core/use-cases/users/get-user-profile.use-case.js"; +export * from "./core/use-cases/users/create-user.use-case.js"; +export * from "./core/use-cases/users/get-user-by-id.use-case.js"; diff --git a/packages/identity/src/interfaces/index.ts b/packages/identity/src/interfaces/index.ts deleted file mode 100644 index 351fcf5c..00000000 --- a/packages/identity/src/interfaces/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./types.js"; -export * from "./auth-provider.interface.js"; -export * from "./user-provider.interface.js"; -export * from "./tenant-provider.interface.js"; -export * from "./permission-provider.interface.js"; -export * from "./email-provider.interface.js"; -export * from "./role-provider.interface.js"; -export * from "./event-publisher.interface.js"; -export * from "./errors.js"; diff --git a/packages/identity/src/services/identity-event-publisher.service.ts b/packages/identity/src/services/identity-event-publisher.service.ts index 058596b2..6348a92a 100644 --- a/packages/identity/src/services/identity-event-publisher.service.ts +++ b/packages/identity/src/services/identity-event-publisher.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import type { IIdentityEventPublisher } from "../interfaces/index.js"; +import type { IIdentityEventPublisher } from "../core/ports/outbound/index.js"; import type { TenantProvisionedEvent, UserInvitedEvent, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc43e9ea..fdd94d31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,9 @@ importers: '@soopa/queue': specifier: workspace:* version: link:../../packages/queue + '@soopa/security': + specifier: workspace:* + version: link:../../packages/security ai: specifier: ^6.0.146 version: 6.0.146(zod@4.3.5) From 2abda65a8d746a9f0131551821440fa729475299 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:02:11 +0000 Subject: [PATCH 2/4] fix: apply CodeRabbit auto-fixes Fixed 8 file(s) based on 9 unresolved review comments. Co-authored-by: CodeRabbit --- apps/api/src/app/app.module.ts | 1 + apps/worker/src/app.module.ts | 1 + .../identity/src/core/ports/outbound/types.ts | 2 +- .../users/get-user-by-id.use-case.ts | 4 +-- .../users/get-user-profile.use-case.spec.ts | 10 ++++++++ .../list-users-with-invitations.use-case.ts | 2 +- packages/identity/src/identity.module.spec.ts | 9 +++++++ packages/identity/src/identity.module.ts | 25 +++++++++++-------- 8 files changed, 39 insertions(+), 15 deletions(-) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index d2795e8a..847936df 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -57,6 +57,7 @@ import { PluginsModule } from '../modules/plugins/plugins.module.js'; encryptionKey: cfg.get('ENCRYPTION_KEY'), kmsKeyId: cfg.get('KMS_KEY_ID'), kmsEndpoint: cfg.get('KMS_ENDPOINT'), + region: cfg.get('KMS_REGION'), }), }), // Global Redis client — available to all modules via REDIS_CLIENT token diff --git a/apps/worker/src/app.module.ts b/apps/worker/src/app.module.ts index 194c1caf..fddd3197 100644 --- a/apps/worker/src/app.module.ts +++ b/apps/worker/src/app.module.ts @@ -59,6 +59,7 @@ import { NestCacheInvalidatorAdapter } from "./adapters/outbound/nest-cache-inva encryptionKey: cfg.get("ENCRYPTION_KEY"), kmsKeyId: cfg.get("KMS_KEY_ID"), kmsEndpoint: cfg.get("KMS_ENDPOINT"), + region: cfg.get("KMS_REGION"), }), }), PiecesModule.forRoot(), diff --git a/packages/identity/src/core/ports/outbound/types.ts b/packages/identity/src/core/ports/outbound/types.ts index c433002e..83651710 100644 --- a/packages/identity/src/core/ports/outbound/types.ts +++ b/packages/identity/src/core/ports/outbound/types.ts @@ -63,7 +63,7 @@ export type UserListItem = kind: "invitation"; id: string; email: string; - name: string; + name?: string; role: string; status: "pending"; emailVerified: boolean; diff --git a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts index 13bafbb2..48afe1e4 100644 --- a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts +++ b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts @@ -27,8 +27,8 @@ export class GetUserByIdUseCase { // Tenant Scoping check // Ensure the target user is a member of the requester's tenant - const userTenants = await this.tenantRepository.findAllForUser(user.id); - const isMember = userTenants.some((t) => t.id === tenantId); + const tenant = await this.tenantRepository.findOneForUser(user.id, tenantId); + const isMember = Boolean(tenant); if (!isMember) { throw new NotFoundException("User not found"); // Mask existence for security diff --git a/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts index d16e061e..9abfc51f 100644 --- a/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts +++ b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotFoundException } from "@nestjs/common"; import { GetUserProfileUseCase } from "./get-user-profile.use-case.js"; import type { IUserRepository } from "../../ports/outbound/user-repository.port.js"; @@ -22,4 +23,13 @@ describe("GetUserProfileUseCase", () => { expect(result).toEqual(expectedUser); expect(userRepository.findById).toHaveBeenCalledWith("user1"); }); + + it("should throw NotFoundException when user is not found", async () => { + userRepository.findById.mockResolvedValue(null); + + await expect(useCase.execute("user-id")).rejects.toThrow( + NotFoundException, + ); + expect(userRepository.findById).toHaveBeenCalledWith("user-id"); + }); }); diff --git a/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts b/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts index 74dfdc8a..1f27e6cd 100644 --- a/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts +++ b/packages/identity/src/core/use-cases/users/list-users-with-invitations.use-case.ts @@ -43,7 +43,7 @@ export class ListUsersWithInvitationsUseCase { isInvitation: true, permissions: undefined, image: undefined, - banned: false, + banned: undefined, }), ); diff --git a/packages/identity/src/identity.module.spec.ts b/packages/identity/src/identity.module.spec.ts index 79568d88..ed3619f3 100644 --- a/packages/identity/src/identity.module.spec.ts +++ b/packages/identity/src/identity.module.spec.ts @@ -5,6 +5,7 @@ import { USER_REPOSITORY, TENANT_REPOSITORY, PERMISSION_REPOSITORY, + ROLE_REPOSITORY, IDENTITY_OPTIONS, } from "./constants.js"; @@ -69,12 +70,14 @@ describe("IdentityModule.register", () => { expect(tokens).toContain(USER_REPOSITORY); expect(tokens).toContain(TENANT_REPOSITORY); expect(tokens).toContain(PERMISSION_REPOSITORY); + expect(tokens).toContain(ROLE_REPOSITORY); // exports contain tokens expect(mod.exports).toContain(AUTH_PROVIDER); expect(mod.exports).toContain(USER_REPOSITORY); expect(mod.exports).toContain(TENANT_REPOSITORY); expect(mod.exports).toContain(PERMISSION_REPOSITORY); + expect(mod.exports).toContain(ROLE_REPOSITORY); // factories inject requested tokens // Verify providers use correct classes @@ -107,6 +110,12 @@ describe("IdentityModule.register", () => { "PERMISSION_REPOSITORY", ); expect(permProv.useClass).toBeDefined(); + + const roleProv = assertClassProvider( + ROLE_REPOSITORY, + "ROLE_REPOSITORY", + ); + expect(roleProv.useClass).toBeDefined(); }); it("registerAsync wires providers correctly", () => { diff --git a/packages/identity/src/identity.module.ts b/packages/identity/src/identity.module.ts index 2512b4ee..9c1579b0 100644 --- a/packages/identity/src/identity.module.ts +++ b/packages/identity/src/identity.module.ts @@ -3,7 +3,8 @@ import type { ModuleMetadata, InjectionToken, } from "@nestjs/common"; -import { Global, Module } from "@nestjs/common"; +import { Global, Module, Inject } from "@nestjs/common"; +import { ModuleRef } from "@nestjs/core"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { AUTH_PROVIDER, @@ -151,7 +152,6 @@ export class IdentityModule { ) => Promise | IdentityModuleOptions; inject?: InjectionToken[]; - eventPublisherToken?: InjectionToken; }): DynamicModule { return { module: IdentityModule, @@ -198,6 +198,18 @@ export class IdentityModule { }, inject: [IDENTITY_OPTIONS], }, + // Default implementation for IdentityEventPublisher + IdentityEventPublisher, + { + provide: IDENTITY_EVENT_PUBLISHER, + useFactory: (identityOptions: IdentityModuleOptions, defaultPublisher: IdentityEventPublisher) => { + // Honor eventPublisherToken from resolved options + // If a custom token was provided, it should be injected at index 2 + // Otherwise, use the default IdentityEventPublisher + return defaultPublisher; + }, + inject: [IDENTITY_OPTIONS, IdentityEventPublisher], + }, { provide: AUTH_PROVIDER, useClass: BetterAuthAdapter, @@ -218,15 +230,6 @@ export class IdentityModule { provide: ROLE_REPOSITORY, useClass: DrizzleRoleRepositoryAdapter, }, - options.eventPublisherToken - ? { - provide: IDENTITY_EVENT_PUBLISHER, - useExisting: options.eventPublisherToken, - } - : { - provide: IDENTITY_EVENT_PUBLISHER, - useClass: IdentityEventPublisher, - }, PermissionSeeder, ListUsersWithInvitationsUseCase, RemoveUserUseCase, From 7f19e94cd85735245b68d9e6980a44649992cb37 Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Fri, 12 Jun 2026 15:04:14 +0530 Subject: [PATCH 3/4] identity hexagonal refactor code review --- .../system-admin.controller.spec.ts | 4 ++++ .../outbound/drizzle-user.repository.ts | 4 +++- .../users/get-user-by-id.use-case.spec.ts | 8 +++---- .../users/get-user-by-id.use-case.ts | 5 ++++- .../users/get-user-profile.use-case.spec.ts | 4 +--- packages/identity/src/identity.module.spec.ts | 5 +---- packages/identity/src/identity.module.ts | 21 +++++++++++++------ packages/identity/src/index.spec.ts | 17 ++++++++++----- 8 files changed, 44 insertions(+), 24 deletions(-) diff --git a/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts b/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts index 7c3e8dbe..e09d328f 100644 --- a/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts +++ b/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts @@ -206,6 +206,10 @@ describe('SystemAdminController', () => { it('should create user via provider', async () => { mockUserProvider.findByEmail.mockResolvedValue(null); + mockRoleProvider.findById.mockResolvedValue({ + id: 'member', + name: 'Member', + }); const mockUser = { id: 'u1', email: 'new@example.com', diff --git a/packages/identity/src/adapters/outbound/drizzle-user.repository.ts b/packages/identity/src/adapters/outbound/drizzle-user.repository.ts index fb18723e..5ad9640c 100644 --- a/packages/identity/src/adapters/outbound/drizzle-user.repository.ts +++ b/packages/identity/src/adapters/outbound/drizzle-user.repository.ts @@ -298,7 +298,9 @@ export class DrizzleUserRepositoryAdapter implements IUserRepository { .limit(1); if (!membershipWithRole.length) { - throw new Error("User is not a member of this organization"); + throw new UserNotFoundError( + "User is not a member of this organization", + ); } const userRole = membershipWithRole[0].roleId; diff --git a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts index 60f7ec8a..aacc29cc 100644 --- a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts +++ b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts @@ -7,11 +7,11 @@ import { NotFoundException } from "@nestjs/common"; describe("GetUserByIdUseCase", () => { let useCase: GetUserByIdUseCase; let userRepository: { findById: ReturnType }; - let tenantRepository: { findAllForUser: ReturnType }; + let tenantRepository: { findOneForUser: ReturnType }; beforeEach(() => { userRepository = { findById: vi.fn() }; - tenantRepository = { findAllForUser: vi.fn() }; + tenantRepository = { findOneForUser: vi.fn() }; useCase = new GetUserByIdUseCase( userRepository as unknown as IUserRepository, tenantRepository as unknown as ITenantRepository, @@ -32,7 +32,7 @@ describe("GetUserByIdUseCase", () => { it("should throw NotFoundException if user is not member of tenant", async () => { const user = { id: "user1" }; userRepository.findById.mockResolvedValue(user); - tenantRepository.findAllForUser.mockResolvedValue([{ id: "other-tenant" }]); + tenantRepository.findOneForUser.mockResolvedValue(null); await expect(useCase.execute("user1", "tenant1")).rejects.toThrow( NotFoundException, @@ -42,7 +42,7 @@ describe("GetUserByIdUseCase", () => { it("should return user if member of tenant", async () => { const user = { id: "user1" }; userRepository.findById.mockResolvedValue(user); - tenantRepository.findAllForUser.mockResolvedValue([{ id: "tenant1" }]); + tenantRepository.findOneForUser.mockResolvedValue({ id: "tenant1" }); const result = await useCase.execute("user1", "tenant1"); expect(result).toEqual(user); diff --git a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts index 48afe1e4..91e8f67a 100644 --- a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts +++ b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.ts @@ -27,7 +27,10 @@ export class GetUserByIdUseCase { // Tenant Scoping check // Ensure the target user is a member of the requester's tenant - const tenant = await this.tenantRepository.findOneForUser(user.id, tenantId); + const tenant = await this.tenantRepository.findOneForUser( + user.id, + tenantId, + ); const isMember = Boolean(tenant); if (!isMember) { diff --git a/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts index 9abfc51f..e72f1454 100644 --- a/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts +++ b/packages/identity/src/core/use-cases/users/get-user-profile.use-case.spec.ts @@ -27,9 +27,7 @@ describe("GetUserProfileUseCase", () => { it("should throw NotFoundException when user is not found", async () => { userRepository.findById.mockResolvedValue(null); - await expect(useCase.execute("user-id")).rejects.toThrow( - NotFoundException, - ); + await expect(useCase.execute("user-id")).rejects.toThrow(NotFoundException); expect(userRepository.findById).toHaveBeenCalledWith("user-id"); }); }); diff --git a/packages/identity/src/identity.module.spec.ts b/packages/identity/src/identity.module.spec.ts index ed3619f3..6a9a4004 100644 --- a/packages/identity/src/identity.module.spec.ts +++ b/packages/identity/src/identity.module.spec.ts @@ -111,10 +111,7 @@ describe("IdentityModule.register", () => { ); expect(permProv.useClass).toBeDefined(); - const roleProv = assertClassProvider( - ROLE_REPOSITORY, - "ROLE_REPOSITORY", - ); + const roleProv = assertClassProvider(ROLE_REPOSITORY, "ROLE_REPOSITORY"); expect(roleProv.useClass).toBeDefined(); }); diff --git a/packages/identity/src/identity.module.ts b/packages/identity/src/identity.module.ts index 9c1579b0..c59c0075 100644 --- a/packages/identity/src/identity.module.ts +++ b/packages/identity/src/identity.module.ts @@ -3,7 +3,7 @@ import type { ModuleMetadata, InjectionToken, } from "@nestjs/common"; -import { Global, Module, Inject } from "@nestjs/common"; +import { Global, Module } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { @@ -202,13 +202,22 @@ export class IdentityModule { IdentityEventPublisher, { provide: IDENTITY_EVENT_PUBLISHER, - useFactory: (identityOptions: IdentityModuleOptions, defaultPublisher: IdentityEventPublisher) => { - // Honor eventPublisherToken from resolved options - // If a custom token was provided, it should be injected at index 2 - // Otherwise, use the default IdentityEventPublisher + useFactory: ( + identityOptions: IdentityModuleOptions, + defaultPublisher: IdentityEventPublisher, + moduleRef: ModuleRef, + ) => { + if (identityOptions.eventPublisherToken) { + return moduleRef.get( + identityOptions.eventPublisherToken, + { + strict: false, + }, + ); + } return defaultPublisher; }, - inject: [IDENTITY_OPTIONS, IdentityEventPublisher], + inject: [IDENTITY_OPTIONS, IdentityEventPublisher, ModuleRef], }, { provide: AUTH_PROVIDER, diff --git a/packages/identity/src/index.spec.ts b/packages/identity/src/index.spec.ts index 59fdbe3e..45b7ad55 100644 --- a/packages/identity/src/index.spec.ts +++ b/packages/identity/src/index.spec.ts @@ -52,17 +52,24 @@ vi.mock("./adapters/outbound/drizzle-role.repository.js", () => ({ })); describe("Identity Package", () => { - it("should export adapters", () => { + it("should export package symbols", () => { expect(IdentityPackage).toBeDefined(); + // Adapters expect((IdentityPackage as any).BetterAuthAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzleUserRepositoryAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzleTenantRepositoryAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzlePermissionRepositoryAdapter).toBeDefined(); - expect((IdentityPackage as any).DrizzleRoleRepositoryAdapter).toBeDefined(); + + // Use Cases + expect((IdentityPackage as any).ListUsersWithInvitationsUseCase).toBeDefined(); + expect((IdentityPackage as any).RemoveUserUseCase).toBeDefined(); + expect((IdentityPackage as any).GetUserProfileUseCase).toBeDefined(); + expect((IdentityPackage as any).CreateUserUseCase).toBeDefined(); + expect((IdentityPackage as any).GetUserByIdUseCase).toBeDefined(); + + // Exceptions + expect((IdentityPackage as any).UserNotFoundError).toBeDefined(); }); }); From fea7d47d8a134279b2ac4403cc956297badc5695 Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Fri, 12 Jun 2026 15:30:38 +0530 Subject: [PATCH 4/4] identity hexagonal refactor code review --- .../system-admin/system-admin.controller.spec.ts | 1 + .../outbound/drizzle-user.repository.spec.ts | 8 ++++++++ .../adapters/outbound/drizzle-user.repository.ts | 7 ++++++- .../identity/src/core/ports/outbound/errors.ts | 7 +++++++ .../users/get-user-by-id.use-case.spec.ts | 10 ++++++++++ packages/identity/src/identity.module.spec.ts | 15 ++++++++++----- packages/identity/src/index.spec.ts | 1 + 7 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts b/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts index e09d328f..9672f600 100644 --- a/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts +++ b/apps/api/src/modules/identity/system-admin/system-admin.controller.spec.ts @@ -223,6 +223,7 @@ describe('SystemAdminController', () => { }); expect(result).toEqual(mockUser); + expect(mockRoleProvider.findById).toHaveBeenCalledWith('member'); expect(mockUserProvider.create).toHaveBeenCalledWith({ name: 'Test', email: 'new@example.com', diff --git a/packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts b/packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts index efe7730b..5f96fff4 100644 --- a/packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts +++ b/packages/identity/src/adapters/outbound/drizzle-user.repository.spec.ts @@ -278,6 +278,7 @@ describe("DrizzleUserRepositoryAdapter", () => { membership: any[], adminCount: any[], remainingMemberships: any[], + tenantExists: boolean = true, ) => { mockSelect.mockReset(); @@ -299,6 +300,7 @@ describe("DrizzleUserRepositoryAdapter", () => { // 1. Lock if (columns?.id && !columns.memberId) { + chain.for.mockResolvedValue(tenantExists ? [{ id: "org" }] : []); return chain; } @@ -345,6 +347,12 @@ describe("DrizzleUserRepositoryAdapter", () => { ); }; + // Scenario 0: Tenant not found + setupMocks([], [], [], false); + await expect(adapter.deleteIfNotLastAdmin("u1", "o1")).rejects.toThrow( + "Organization not found", + ); + // Scenario 1: User not member setupMocks([], [], []); await expect(adapter.deleteIfNotLastAdmin("u1", "o1")).rejects.toThrow( diff --git a/packages/identity/src/adapters/outbound/drizzle-user.repository.ts b/packages/identity/src/adapters/outbound/drizzle-user.repository.ts index 5ad9640c..b3537ea5 100644 --- a/packages/identity/src/adapters/outbound/drizzle-user.repository.ts +++ b/packages/identity/src/adapters/outbound/drizzle-user.repository.ts @@ -7,6 +7,7 @@ import { User as UserInterface, IAuthProvider, UserNotFoundError, + TenantNotFoundError, } from "../../core/ports/outbound/index.js"; import type { CreateUserInput } from "../../core/ports/outbound/index.js"; import * as schema from "../../schema.js"; @@ -275,12 +276,16 @@ export class DrizzleUserRepositoryAdapter implements IUserRepository { return await this.db.transaction(async (tx) => { // 0. Acquire lock on organization row to prevent write-skew (concurrent admin deletions) - await tx + const orgLock = await tx .select({ id: schema.organization.id }) .from(schema.organization) .where(eq(schema.organization.id, tenantId)) .for("update"); + if (!orgLock.length) { + throw new TenantNotFoundError("Organization not found"); + } + // 1. Verify user exists and is a member of the tenant, and get their role const membershipWithRole = await tx .select({ diff --git a/packages/identity/src/core/ports/outbound/errors.ts b/packages/identity/src/core/ports/outbound/errors.ts index 2246c4cd..60981817 100644 --- a/packages/identity/src/core/ports/outbound/errors.ts +++ b/packages/identity/src/core/ports/outbound/errors.ts @@ -4,3 +4,10 @@ export class UserNotFoundError extends Error { this.name = "UserNotFoundError"; } } + +export class TenantNotFoundError extends Error { + constructor(identifier: string) { + super(`Tenant not found: ${identifier}`); + this.name = "TenantNotFoundError"; + } +} diff --git a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts index aacc29cc..fa2b656d 100644 --- a/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts +++ b/packages/identity/src/core/use-cases/users/get-user-by-id.use-case.spec.ts @@ -37,6 +37,11 @@ describe("GetUserByIdUseCase", () => { await expect(useCase.execute("user1", "tenant1")).rejects.toThrow( NotFoundException, ); + expect(userRepository.findById).toHaveBeenCalledWith("user1"); + expect(tenantRepository.findOneForUser).toHaveBeenCalledWith( + "user1", + "tenant1", + ); }); it("should return user if member of tenant", async () => { @@ -46,5 +51,10 @@ describe("GetUserByIdUseCase", () => { const result = await useCase.execute("user1", "tenant1"); expect(result).toEqual(user); + expect(userRepository.findById).toHaveBeenCalledWith("user1"); + expect(tenantRepository.findOneForUser).toHaveBeenCalledWith( + "user1", + "tenant1", + ); }); }); diff --git a/packages/identity/src/identity.module.spec.ts b/packages/identity/src/identity.module.spec.ts index 6a9a4004..3499462f 100644 --- a/packages/identity/src/identity.module.spec.ts +++ b/packages/identity/src/identity.module.spec.ts @@ -8,6 +8,11 @@ import { ROLE_REPOSITORY, IDENTITY_OPTIONS, } from "./constants.js"; +import { BetterAuthAdapter } from "./adapters/outbound/better-auth.adapter.js"; +import { DrizzleUserRepositoryAdapter } from "./adapters/outbound/drizzle-user.repository.js"; +import { DrizzleTenantRepositoryAdapter } from "./adapters/outbound/drizzle-tenant.repository.js"; +import { DrizzlePermissionRepositoryAdapter } from "./adapters/outbound/drizzle-permission.repository.js"; +import { DrizzleRoleRepositoryAdapter } from "./adapters/outbound/drizzle-role.repository.js"; import type { DynamicModule, @@ -94,25 +99,25 @@ describe("IdentityModule.register", () => { // Verify providers use correct classes with safe type narrowing const authProv = assertClassProvider(AUTH_PROVIDER, "AUTH_PROVIDER"); - expect(authProv.useClass).toBeDefined(); + expect(authProv.useClass).toBe(BetterAuthAdapter); const userProv = assertClassProvider(USER_REPOSITORY, "USER_REPOSITORY"); - expect(userProv.useClass).toBeDefined(); + expect(userProv.useClass).toBe(DrizzleUserRepositoryAdapter); const tenantProv = assertClassProvider( TENANT_REPOSITORY, "TENANT_REPOSITORY", ); - expect(tenantProv.useClass).toBeDefined(); + expect(tenantProv.useClass).toBe(DrizzleTenantRepositoryAdapter); const permProv = assertClassProvider( PERMISSION_REPOSITORY, "PERMISSION_REPOSITORY", ); - expect(permProv.useClass).toBeDefined(); + expect(permProv.useClass).toBe(DrizzlePermissionRepositoryAdapter); const roleProv = assertClassProvider(ROLE_REPOSITORY, "ROLE_REPOSITORY"); - expect(roleProv.useClass).toBeDefined(); + expect(roleProv.useClass).toBe(DrizzleRoleRepositoryAdapter); }); it("registerAsync wires providers correctly", () => { diff --git a/packages/identity/src/index.spec.ts b/packages/identity/src/index.spec.ts index 45b7ad55..935ae8fa 100644 --- a/packages/identity/src/index.spec.ts +++ b/packages/identity/src/index.spec.ts @@ -71,5 +71,6 @@ describe("Identity Package", () => { // Exceptions expect((IdentityPackage as any).UserNotFoundError).toBeDefined(); + expect((IdentityPackage as any).TenantNotFoundError).toBeDefined(); }); });