Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

회원 탈퇴 및 그룹 탈퇴 로직 세부 추가 구현 #621

Merged
merged 5 commits into from
Jan 12, 2024
Merged
2 changes: 2 additions & 0 deletions BE/src/auth/application/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { InvalidIdentifierException } from '../exception/invalid-identifier.exce
import { RevokeRequestFailException } from '../exception/revoke-request-fail.exception';
import { transactionTest } from '../../../test/common/transaction-test';
import { DataSource } from 'typeorm';
import { GroupModule } from '../../group/group/group.module';

describe('AuthService', () => {
let authService: AuthService;
Expand All @@ -44,6 +45,7 @@ describe('AuthService', () => {
imports: [
HttpModule,
CacheModule.register(),
GroupModule,
AuthTestModule,
UsersTestModule,
JwtModule.register({}),
Expand Down
6 changes: 6 additions & 0 deletions BE/src/auth/application/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RevokeAppleAuthRequest } from '../dto/revoke-apple-auth-request.dto';
import { RevokeAppleAuthResponse } from '../dto/revoke-apple-auth-response.dto';
import { RevokeRequestFailException } from '../exception/revoke-request-fail.exception';
import { InvalidIdentifierException } from '../exception/invalid-identifier.exception';
import { GroupService } from '../../group/group/application/group.service';

@Injectable()
export class AuthService {
Expand All @@ -29,6 +30,7 @@ export class AuthService {
private readonly userCodeGenerator: UserCodeGenerator,
private readonly jwtUtils: JwtUtils,
private readonly avatarHolder: AvatarHolder,
private readonly groupService: GroupService,
) {}

@Transactional()
Expand Down Expand Up @@ -78,13 +80,17 @@ export class AuthService {
return new RefreshAuthResponseDto(UserDto.from(user), accessToken);
}

@Transactional()
async revoke(user: User, revokeAuthRequest: RevokeAppleAuthRequest) {
const requestUserIdentifier = await this.oauthHandler.getUserIdentifier(
revokeAuthRequest.identityToken,
);
if (user.userIdentifier !== requestUserIdentifier)
throw new InvalidIdentifierException();

// TODO : 그룹 탈퇴 로직을 서비스 하위 레이어로 분리해서 AuthService, GroupService 에서 해당 레이어를 참조하는 방식으로 추후에 개선 필요
await this.groupService.removeUserFromAllGroup(user);

const authorizationCode = revokeAuthRequest.authorizationCode;

const revokeResponse =
Expand Down
2 changes: 2 additions & 0 deletions BE/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { RedisClientOptions } from 'redis';
import { AvatarHolder } from './application/avatar.holder';
import { AdminTokenGuard } from './guard/admin-token.guard';
import { AdminPageTokenGuard } from './guard/admin-page-token.guard';
import { GroupModule } from '../group/group/group.module';

@Global()
@Module({
Expand All @@ -25,6 +26,7 @@ import { AdminPageTokenGuard } from './guard/admin-page-token.guard';
JwtModule.register({}),
CacheModule.registerAsync<RedisClientOptions>(redisModuleOptions),
CustomTypeOrmModule.forCustomRepository([UserRepository]),
GroupModule,
forwardRef(() => UsersModule),
],
controllers: [AuthController],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class GroupAchievementEntity extends BaseTimeEntity {
@JoinColumn({ name: 'group_id', referencedColumnName: 'id' })
group: GroupEntity;

@ManyToOne(() => GroupCategoryEntity)
@ManyToOne(() => GroupCategoryEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'group_category_id', referencedColumnName: 'id' })
groupCategory: GroupCategoryEntity;

Expand Down
2 changes: 1 addition & 1 deletion BE/src/group/category/entities/group-category.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class GroupCategoryEntity extends BaseTimeEntity {
@JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
user: UserEntity;

@ManyToOne(() => GroupEntity)
@ManyToOne(() => GroupEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'group_id', referencedColumnName: 'id' })
group: GroupEntity;

Expand Down
97 changes: 81 additions & 16 deletions BE/src/group/group/application/group.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { GroupAchievementFixture } from '../../../../test/group/achievement/grou
import { GroupAchievementTestModule } from '../../../../test/group/achievement/group-achievement-test.module';
import { GroupCategoryTestModule } from '../../../../test/group/category/group-category-test.module';
import { dateFormat } from '../../../common/utils/date-formatter';
import { LeaderNotAllowedToLeaveException } from '../exception/leader-not-allowed-to-leave.exception';
import { NoSuchUserGroupException } from '../exception/no-such-user-group.exception';
import { InviteGroupRequest } from '../dto/invite-group-request.dto';
import { InvitePermissionDeniedException } from '../exception/invite-permission-denied.exception';
Expand Down Expand Up @@ -204,22 +203,7 @@ describe('GroupSerivce Test', () => {
expect(groupLeaveResponse.userId).toEqual(user2.id);
});
});
test('리더가 탈퇴 시도를 하는 경우에는 LeaderNotAllowedToLeaveException를 던진다.', async () => {
// given
await transactionTest(dataSource, async () => {
// given
const user1 = await usersFixture.getUser('ABC');
const user2 = await usersFixture.getUser('DEF');
const group = await groupFixture.createGroup('Test Group', user1);
await groupFixture.addMember(group, user2, UserGroupGrade.PARTICIPANT);

// when
// then
await expect(groupService.removeUser(user1, group.id)).rejects.toThrow(
LeaderNotAllowedToLeaveException,
);
});
});
test('내가 속한 그룹이 아닌 그룹에 대한 탈퇴 시도에 대해서는 NoSuchUserGroupException 예외를 던진다.', async () => {
// given
await transactionTest(dataSource, async () => {
Expand Down Expand Up @@ -596,4 +580,85 @@ describe('GroupSerivce Test', () => {
});
});
});

test('리더가 탈퇴하면 남아있는 멤버중 가입일이 가장 오래된 멤버가 리더가 된다.', async () => {
await transactionTest(dataSource, async () => {
// given
const leader = await usersFixture.getUser('ABC');
const group = await groupFixture.createGroup('Test Group', leader);
const firstMember = await usersFixture.getUser('DEF');
const secondMember = await usersFixture.getUser('EFG');
const thirdMember = await usersFixture.getUser('FGH');
await groupFixture.addMember(
group,
firstMember,
UserGroupGrade.PARTICIPANT,
);
await groupFixture.addMember(
group,
secondMember,
UserGroupGrade.PARTICIPANT,
);
await groupFixture.addMember(
group,
thirdMember,
UserGroupGrade.PARTICIPANT,
);

// when
await groupService.removeUser(leader, group.id);

// then
const newLeader = await userGroupRepository.findOneByUserIdAndGroupId(
firstMember.id,
group.id,
);

expect(newLeader.grade).toEqual(UserGroupGrade.LEADER);
});
});

test('마지막 멤버가 탈퇴하면 그룹은 삭제된다.', async () => {
await transactionTest(dataSource, async () => {
// given
const user = await usersFixture.getUser('ABC');
const group = await groupFixture.createGroup('Test Group', user);

// when
await groupService.removeUser(user, group.id);

// then
const expected = await groupRepository.findById(group.id);
expect(expected).toBeUndefined();
});
});

test('멤버가 속한 모든 그룹에 대해서 그룹 탈퇴를 할 수 있다.', async () => {
await transactionTest(dataSource, async () => {
// given
const user1 = await usersFixture.getUser('ABC');
const user2 = await usersFixture.getUser('DEF');

const group1 = await groupFixture.createGroup('Test Group1', user1);
const group2 = await groupFixture.createGroup('Test Group2', user1);
const group3 = await groupFixture.createGroup('Test Group3', user1);
const group4 = await groupFixture.createGroup('Test Group4', user1);

await groupFixture.addMember(group2, user2, UserGroupGrade.PARTICIPANT);

// when
await groupService.removeUserFromAllGroup(user1);

// then
const removed1 = await groupRepository.findById(group1.id);
const expected = await groupRepository.findById(group2.id);
const removed2 = await groupRepository.findById(group3.id);
const removed3 = await groupRepository.findById(group4.id);

expect(removed1).toBeUndefined();
expect(expected).not.toBeUndefined();
expect(removed2).toBeUndefined();
expect(removed3).toBeUndefined();
});
});
});
46 changes: 42 additions & 4 deletions BE/src/group/group/application/group.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { GroupListResponse } from '../dto/group-list-response';
import { GroupAvatarHolder } from './group-avatar.holder';
import { UserGroupRepository } from '../entities/user-group.repository';
import { GroupLeaveResponse } from '../dto/group-leave-response.dto';
import { LeaderNotAllowedToLeaveException } from '../exception/leader-not-allowed-to-leave.exception';
import { NoSuchUserGroupException } from '../exception/no-such-user-group.exception';
import { InviteGroupRequest } from '../dto/invite-group-request.dto';
import { UserRepository } from '../../../users/entities/user.repository';
Expand Down Expand Up @@ -61,16 +60,30 @@ export class GroupService {
@Transactional()
async removeUser(user: User, groupId: number) {
const userGroup = await this.getUserGroup(user.id, groupId);
if (userGroup.grade === UserGroupGrade.LEADER)
throw new LeaderNotAllowedToLeaveException();
user.leaveGroup();

if (await this.isLastMember(groupId, user.id)) {
await this.groupRepository.repository.delete(groupId);
return new GroupLeaveResponse(user.id, groupId);
}

if (userGroup.grade === UserGroupGrade.LEADER) {
await this.assignNextLeader(groupId, user.id);
}
user.leaveGroup();
await this.userRepository.updateUser(user);
await this.userGroupRepository.repository.softDelete(userGroup);

return new GroupLeaveResponse(user.id, groupId);
}

@Transactional()
async removeUserFromAllGroup(user: User) {
const userGroups = await this.userGroupRepository.findAllByUserId(user.id);
for (const userGroup of userGroups) {
await this.removeUser(user, userGroup.group.id);
}
}

@Transactional()
async invite(
user: User,
Expand Down Expand Up @@ -190,4 +203,29 @@ export class GroupService {
)
throw new InvitePermissionDeniedException();
}

private async assignNextLeader(groupId: number, userId: number) {
const members =
await this.userGroupRepository.findAllByGroupIdAndUserIdNotOrderByCreatedAtAsc(
groupId,
userId,
);
if (members) {
const nextLeader = members[0];
nextLeader.grade = UserGroupGrade.LEADER;
await this.userGroupRepository.repository.update(
nextLeader.id,
nextLeader,
);
}
}

private async isLastMember(groupId: number, userId: number) {
return (
(await this.userGroupRepository.findCountByGroupIdAndUserIdNot(
groupId,
userId,
)) === 0
);
}
}
1 change: 1 addition & 0 deletions BE/src/group/group/entities/group.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class GroupEntity extends BaseTimeEntity {
@OneToMany(
() => GroupAchievementEntity,
(groupAchievement) => groupAchievement.group,
{ cascade: true },
)
achievements: GroupAchievementEntity[];

Expand Down
2 changes: 1 addition & 1 deletion BE/src/group/group/entities/user-group.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class UserGroupEntity extends BaseTimeEntity {
@JoinColumn({ name: 'user_id', referencedColumnName: 'id' })
user: UserEntity;

@ManyToOne(() => GroupEntity)
@ManyToOne(() => GroupEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'group_id', referencedColumnName: 'id' })
group: GroupEntity;

Expand Down
67 changes: 67 additions & 0 deletions BE/src/group/group/entities/user-group.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,71 @@ describe('UserGroupRepository Test', () => {
expect(userGroup.group.id).toEqual(group.id);
});
});

test('특정 유저를 제외하고 가입일이 가장 오래된 유저를 조회할 수 있다.', async () => {
await transactionTest(dataSource, async () => {
// given
const leader = await usersFixture.getUser('ABC');
const group = await groupFixture.createGroup('Test Group', leader);
const user1 = await usersFixture.getUser('DEF');
const user2 = await usersFixture.getUser('EFG');
const user3 = await usersFixture.getUser('FGH');
await groupFixture.addMember(group, user1, UserGroupGrade.PARTICIPANT);
await groupFixture.addMember(group, user2, UserGroupGrade.PARTICIPANT);
await groupFixture.addMember(group, user3, UserGroupGrade.PARTICIPANT);

// when
const userGroups =
await userGroupRepository.findAllByGroupIdAndUserIdNotOrderByCreatedAtAsc(
group.id,
leader.id,
);

// then
expect(userGroups.length).toEqual(3);
expect(userGroups[0].user.id).toEqual(user1.id);
expect(userGroups[1].user.id).toEqual(user2.id);
expect(userGroups[2].user.id).toEqual(user3.id);
});
});

test('특정 유저를 제외한 멤버수를 조회할 수 있다.', async () => {
await transactionTest(dataSource, async () => {
// given
const leader = await usersFixture.getUser('ABC');
const group = await groupFixture.createGroup('Test Group', leader);
const user1 = await usersFixture.getUser('DEF');
const user2 = await usersFixture.getUser('EFG');
const user3 = await usersFixture.getUser('FGH');
await groupFixture.addMember(group, user1, UserGroupGrade.PARTICIPANT);
await groupFixture.addMember(group, user2, UserGroupGrade.PARTICIPANT);
await groupFixture.addMember(group, user3, UserGroupGrade.PARTICIPANT);

// when
const rest = await userGroupRepository.findCountByGroupIdAndUserIdNot(
group.id,
leader.id,
);

// then
expect(rest).toEqual(3);
});
});

test('유저가 속한 모든 그룹에 대해 조회할 수 있다.', async () => {
await transactionTest(dataSource, async () => {
// given
const user = await usersFixture.getUser('ABC');
await groupFixture.createGroup('Test Group1', user);
await groupFixture.createGroup('Test Group2', user);
await groupFixture.createGroup('Test Group3', user);
await groupFixture.createGroup('Test Group4', user);

// when
const userGroups = await userGroupRepository.findAllByUserId(user.id);

// then
expect(userGroups.length).toEqual(4);
});
});
});
Loading