Skip to content

Commit

Permalink
Feat/add information on user bucket assignment in modal (#127)
Browse files Browse the repository at this point in the history
* feat: add buckets join for user admin endpoint

* chore: slight dialog refactor

* feat: add popovers, disable on user having access

* fix: remove console log

* fix: build errors
  • Loading branch information
DeutscherDude authored Oct 19, 2024
1 parent c96e21e commit 8be45bf
Show file tree
Hide file tree
Showing 25 changed files with 870 additions and 234 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ import { type DeleteUserCommandHandler } from '../../../application/commandHandl
import { type GrantBucketAccessCommandHandler } from '../../../application/commandHandlers/grantBucketAccessCommandHandler/grantBucketAccessCommandHandler.js';
import { type RevokeBucketAccessCommandHandler } from '../../../application/commandHandlers/revokeBucketAccessCommandHandler/revokeBucketAccessCommandHandler.js';
import { type FindUserQueryHandler } from '../../../application/queryHandlers/findUserQueryHandler/findUserQueryHandler.js';
import { type FindUsersQueryHandler } from '../../../application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.js';
import { type User } from '../../../domain/entities/user/user.js';
import { type UserDTO } from '../common/userDTO.js';
import { type FindUsersWithBucketsQueryHandler } from '../../../application/queryHandlers/findUsersWithBucketsQueryHandler/findUsersWithBucketsQueryHandler.js';
import { type UserWithBuckets, type User } from '../../../domain/entities/user/user.js';
import { type UserWithBucketsDTO, type UserDTO } from '../common/userDTO.js';

export class AdminUserHttpController implements HttpController {
public readonly basePath = '/admin/users';
Expand All @@ -67,7 +67,7 @@ export class AdminUserHttpController implements HttpController {
private readonly createUserCommandHandler: CreateUserCommandHandler,
private readonly deleteUserCommandHandler: DeleteUserCommandHandler,
private readonly findUserQueryHandler: FindUserQueryHandler,
private readonly findUsersQueryHandler: FindUsersQueryHandler,
private readonly findUsersWithBucketsQueryHandler: FindUsersWithBucketsQueryHandler,
private readonly grantBucketAccessCommandHandler: GrantBucketAccessCommandHandler,
private readonly revokeBucketAccessCommandHandler: RevokeBucketAccessCommandHandler,
private readonly accessControlService: AccessControlService,
Expand Down Expand Up @@ -289,15 +289,15 @@ export class AdminUserHttpController implements HttpController {

const pageSize = request.queryParams.pageSize ?? 10;

const { users, totalUsers } = await this.findUsersQueryHandler.execute({
const { users, totalUsers } = await this.findUsersWithBucketsQueryHandler.execute({
page,
pageSize,
});

return {
statusCode: HttpStatusCode.ok,
body: {
data: users.map((user) => this.mapUserToUserDTO(user)),
data: users.map((user) => this.mapUserWithBucketsToUserWithBucketsDto(user)),
metadata: {
page,
pageSize,
Expand Down Expand Up @@ -332,4 +332,13 @@ export class AdminUserHttpController implements HttpController {
role: user.getRole(),
};
}

private mapUserWithBucketsToUserWithBucketsDto(user: UserWithBuckets): UserWithBucketsDTO {
return {
id: user.getId(),
email: user.getEmail(),
role: user.getRole(),
buckets: user.getBuckets(),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type Static, Type } from '@sinclair/typebox';
import type * as contracts from '@common/contracts';

import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js';
import { userDTOSchema } from '../../common/userDTO.js';
import { userWithBucketsDTOSchema } from '../../common/userDTO.js';

export const findUsersQueryParamsDTOSchema = Type.Object({
page: Type.Optional(Type.Integer({ minimum: 1 })),
Expand All @@ -16,7 +16,7 @@ export type FindUsersQueryParamsDTO = TypeExtends<
>;

export const findUsersResponseBodyDTOSchema = Type.Object({
data: Type.Array(userDTOSchema),
data: Type.Array(userWithBucketsDTOSchema),
metadata: Type.Object({
page: Type.Integer(),
pageSize: Type.Integer(),
Expand All @@ -26,5 +26,5 @@ export const findUsersResponseBodyDTOSchema = Type.Object({

export type FindUsersResponseBodyDTO = TypeExtends<
Static<typeof findUsersResponseBodyDTOSchema>,
contracts.FindUsersResponseBody
contracts.FindUsersWithBucketsResponseBody
>;
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,19 @@ export const userDTOSchema = Type.Object({
role: Type.Enum(contracts.UserRole),
});

export const userWithBucketsDTOSchema = Type.Object({
id: Type.String(),
email: Type.String(),
role: Type.Enum(contracts.UserRole),
buckets: Type.Array(
Type.Object({
id: Type.String({ format: 'uuid' }),
bucketName: Type.String(),
userId: Type.String({ format: 'uuid' }),
}),
),
});

export type UserDTO = Static<typeof userDTOSchema>;

export type UserWithBucketsDTO = Static<typeof userWithBucketsDTOSchema>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type QueryHandler } from '../../../../../common/types/queryHandler.js';
import { type UserWithBuckets } from '../../../domain/entities/user/user.js';

export interface FindUsersWithBucketsQueryHandlerPayload {
readonly page: number;
readonly pageSize: number;
}

export interface FindUsersWithBucketsQueryHandlerResult {
readonly users: UserWithBuckets[];
readonly totalUsers: number;
}

export type FindUsersWithBucketsQueryHandler = QueryHandler<
FindUsersWithBucketsQueryHandlerPayload,
FindUsersWithBucketsQueryHandlerResult
>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
type FindUsersWithBucketsQueryHandler,
type FindUsersWithBucketsQueryHandlerPayload,
type FindUsersWithBucketsQueryHandlerResult,
} from './findUsersWithBucketsQueryHandler.js';
import { type UserRepository } from '../../../domain/repositories/userRepository/userRepository.js';

export class FindUsersWithBucketsQueryHandlerImpl implements FindUsersWithBucketsQueryHandler {
public constructor(private readonly userRepository: UserRepository) {}

public async execute(
payload: FindUsersWithBucketsQueryHandlerPayload,
): Promise<FindUsersWithBucketsQueryHandlerResult> {
const { page, pageSize } = payload;

const [usersWithBuckets, totalUsers] = await Promise.all([
this.userRepository.findUsersWithBuckets({
page,
pageSize,
}),
this.userRepository.countUsers(),
]);

return {
users: usersWithBuckets,
totalUsers,
};
}
}
19 changes: 19 additions & 0 deletions apps/backend/src/modules/userModule/domain/entities/user/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type UserRole } from '@common/contracts';

import { type UserBucketRawEntity } from '../../../infrastructure/databases/userDatabase/tables/userBucketTable/userBucketRawEntity.js';

export interface UserDraft {
readonly id: string;
readonly email: string;
Expand Down Expand Up @@ -55,3 +57,20 @@ export class User {
return this.state;
}
}

export interface UserWithBucketsDraft extends UserDraft {
buckets: UserBucketRawEntity[];
}

export class UserWithBuckets extends User {
private readonly buckets: UserBucketRawEntity[];
public constructor(draft: UserWithBucketsDraft) {
super(draft);

this.buckets = draft.buckets;
}

public getBuckets(): UserBucketRawEntity[] {
return [...this.buckets];
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type UserState, type User } from '../../../domain/entities/user/user.js';
import { type UserState, type User, type UserWithBuckets } from '../../../domain/entities/user/user.js';

export interface SaveUserPayload {
readonly user: UserState;
Expand All @@ -22,6 +22,7 @@ export interface UserRepository {
saveUser(input: SaveUserPayload): Promise<User>;
findUser(input: FindUserPayload): Promise<User | null>;
findUsers(input: FindUsersPayload): Promise<User[]>;
findUsersWithBuckets(payload: FindUsersPayload): Promise<UserWithBuckets[]>;
countUsers(): Promise<number>;
deleteUser(input: DeleteUserPayload): Promise<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ export interface UserRawEntity {
readonly password: string;
readonly role: UserRole;
}

export interface UserWithBucketsJoinRawEntity extends Omit<UserRawEntity, 'id'> {
readonly userId: string;
readonly bucketName: string;
readonly bucketId: string;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { type User } from '../../../../domain/entities/user/user.js';
import { type UserRawEntity } from '../../../databases/userDatabase/tables/userTable/userRawEntity.js';
import { type UserWithBuckets, type User } from '../../../../domain/entities/user/user.js';
import {
type UserWithBucketsJoinRawEntity,
type UserRawEntity,
} from '../../../databases/userDatabase/tables/userTable/userRawEntity.js';

export interface UserMapper {
mapToDomain(rawEntity: UserRawEntity): User;
mapToDomainWithBuckets(rawEntities: UserWithBucketsJoinRawEntity[]): UserWithBuckets[];
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
import { type UserMapper } from './userMapper.js';
import { User } from '../../../../domain/entities/user/user.js';
import { type UserRawEntity } from '../../../databases/userDatabase/tables/userTable/userRawEntity.js';
import { User, UserWithBuckets } from '../../../../domain/entities/user/user.js';
import {
type UserWithBucketsJoinRawEntity,
type UserRawEntity,
} from '../../../databases/userDatabase/tables/userTable/userRawEntity.js';

export class UserMapperImpl implements UserMapper {
public mapToDomainWithBuckets(rawEntities: Array<UserWithBucketsJoinRawEntity>): UserWithBuckets[] {
const usersWithBucketsMap = rawEntities.reduce((agg, entity) => {
const existingUser = agg.get(entity.userId);

if (existingUser) {
if (entity.bucketId !== null) {
const updatedUser = new UserWithBuckets({
id: existingUser.getId(),
email: existingUser.getEmail(),
password: existingUser.getPassword(),
role: existingUser.getRole(),
buckets: [
...existingUser.getBuckets(),
{
bucketName: entity.bucketName,
id: entity.bucketId,
userId: entity.userId,
},
],
});

agg.set(entity.userId, updatedUser);
}
} else {
const newUser = new UserWithBuckets({
id: entity.userId,
email: entity.email,
password: entity.password,
role: entity.role,
buckets: entity.bucketId
? [
{
bucketName: entity.bucketName,
id: entity.bucketId,
userId: entity.userId,
},
]
: [],
});

agg.set(entity.userId, newUser);
}

return agg;
}, new Map<string, UserWithBuckets>());

return Array.from(usersWithBucketsMap.values());
}

public mapToDomain(entity: UserRawEntity): User {
const { id, email, password, role } = entity;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@ import { RepositoryError } from '../../../../../common/errors/common/repositoryE
import { ResourceNotFoundError } from '../../../../../common/errors/common/resourceNotFoundError.js';
import { type SqliteDatabaseClient } from '../../../../../core/database/sqliteDatabaseClient/sqliteDatabaseClient.js';
import { type UuidService } from '../../../../../libs/uuid/services/uuidService/uuidService.js';
import { type User } from '../../../domain/entities/user/user.js';
import { type UserWithBuckets, type User } from '../../../domain/entities/user/user.js';
import {
type UserRepository,
type SaveUserPayload,
type FindUserPayload,
type DeleteUserPayload,
type FindUsersPayload,
} from '../../../domain/repositories/userRepository/userRepository.js';
import { type UserRawEntity } from '../../databases/userDatabase/tables/userTable/userRawEntity.js';
import { UserBucketTable } from '../../databases/userDatabase/tables/userBucketTable/userBucketTable.js';
import {
type UserWithBucketsJoinRawEntity,
type UserRawEntity,
} from '../../databases/userDatabase/tables/userTable/userRawEntity.js';
import { UserTable } from '../../databases/userDatabase/tables/userTable/userTable.js';

export class UserRepositoryImpl implements UserRepository {
private readonly userTable = new UserTable();
private readonly userBucketTable = new UserBucketTable();

public constructor(
private readonly sqliteDatabaseClient: SqliteDatabaseClient,
Expand Down Expand Up @@ -108,6 +113,7 @@ export class UserRepositoryImpl implements UserRepository {
try {
rawEntities = await this.sqliteDatabaseClient<UserRawEntity>(this.userTable.name)
.select('*')
.orderBy('id', 'asc')
.offset((page - 1) * pageSize)
.limit(pageSize);
} catch (error) {
Expand All @@ -120,6 +126,41 @@ export class UserRepositoryImpl implements UserRepository {
return rawEntities.map((rawEntity) => this.userMapper.mapToDomain(rawEntity));
}

public async findUsersWithBuckets(payload: FindUsersPayload): Promise<UserWithBuckets[]> {
const { page, pageSize } = payload;

let rawEntities: UserWithBucketsJoinRawEntity[];

try {
const result = await this.sqliteDatabaseClient<UserRawEntity>(this.userTable.name)
.select(
`${this.userTable.name}.id as userId`,
`email`,
'role',
'password',
'userBuckets.id as bucketId',
'bucketName as bucketName',
)
.leftJoin(this.userBucketTable.name, (joinCallback) => {
joinCallback.on(`${this.userBucketTable.name}.userId`, '=', `${this.userTable.name}.id`);
})
.orderBy(`${this.userTable.name}.id`, 'asc')
.offset((page - 1) * pageSize)
.limit(pageSize);

rawEntities = result;
} catch (error) {
console.error(error);

throw new RepositoryError({
entity: 'Users',
operation: 'findWithBuckets',
});
}

return this.userMapper.mapToDomainWithBuckets(rawEntities);
}

public async countUsers(): Promise<number> {
try {
const result = await this.sqliteDatabaseClient<UserRawEntity>(this.userTable.name).count('* as count').first();
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/modules/userModule/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const symbols = {
revokeBucketAccessCommandHandler: Symbol('revokeBucketAccessCommandHandler'),
findUserQueryHandler: Symbol('findUserQueryHandler'),
findUsersQueryHandler: Symbol('findUsersQueryHandler'),
findUsersWithBucketsQueryHandler: Symbol('findUsersWithBucketsQueryHandler'),
findUserBucketsQueryHandler: Symbol('findUserBucketsQueryHandler'),
loginUserCommandHandler: Symbol('loginUserCommandHandler'),
refreshUserTokensCommandHandler: Symbol('refreshUserTokensCommandHandler'),
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/src/modules/userModule/userModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { type FindUserQueryHandler } from './application/queryHandlers/findUserQ
import { FindUserQueryHandlerImpl } from './application/queryHandlers/findUserQueryHandler/findUserQueryHandlerImpl.js';
import { type FindUsersQueryHandler } from './application/queryHandlers/findUsersQueryHandler/findUsersQueryHandler.js';
import { FindUsersQueryHandlerImpl } from './application/queryHandlers/findUsersQueryHandler/findUsersQueryHandlerImpl.js';
import { type FindUsersWithBucketsQueryHandler } from './application/queryHandlers/findUsersWithBucketsQueryHandler/findUsersWithBucketsQueryHandler.js';
import { FindUsersWithBucketsQueryHandlerImpl } from './application/queryHandlers/findUsersWithBucketsQueryHandler/findUsersWithBucketsQueryHandlerImpl.js';
import { type HashService } from './application/services/hashService/hashService.js';
import { HashServiceImpl } from './application/services/hashService/hashServiceImpl.js';
import { type PasswordValidationService } from './application/services/passwordValidationService/passwordValidationService.js';
Expand Down Expand Up @@ -197,6 +199,11 @@ export class UserModule implements DependencyInjectionModule {
),
);

container.bind<FindUsersWithBucketsQueryHandler>(
symbols.findUsersWithBucketsQueryHandler,
() => new FindUsersWithBucketsQueryHandlerImpl(container.get<UserRepository>(symbols.userRepository)),
);

container.bind<UserHttpController>(
symbols.userHttpController,
() =>
Expand All @@ -216,7 +223,7 @@ export class UserModule implements DependencyInjectionModule {
container.get<CreateUserCommandHandler>(symbols.createUserCommandHandler),
container.get<DeleteUserCommandHandler>(symbols.deleteUserCommandHandler),
container.get<FindUserQueryHandler>(symbols.findUserQueryHandler),
container.get<FindUsersQueryHandler>(symbols.findUsersQueryHandler),
container.get<FindUsersWithBucketsQueryHandler>(symbols.findUsersWithBucketsQueryHandler),
container.get<GrantBucketAccessCommandHandler>(symbols.grantBucketAccessCommandHandler),
container.get<RevokeBucketAccessCommandHandler>(symbols.revokeBucketAccessCommandHandler),
container.get<AccessControlService>(authSymbols.accessControlService),
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.51.11",
"@tanstack/react-router": "^1.45.8",
"@tanstack/react-table": "^8.19.3",
Expand Down
Loading

0 comments on commit 8be45bf

Please sign in to comment.