diff --git a/.github/workflows/be.cd.yml b/.github/workflows/be.cd.yml index 0d4159df..78309596 100644 --- a/.github/workflows/be.cd.yml +++ b/.github/workflows/be.cd.yml @@ -52,6 +52,13 @@ jobs: SWAGGER_VERSION=${{ secrets.SWAGGER_VERSION }} SWAGGER_TAG=${{ secrets.SWAGGER_TAG }} APPLE_PUBLIC_KEY_URL=${{ secrets.APPLE_PUBLIC_KEY_URL }} + APPLE_TOKEN_URL=${{ secrets.APPLE_TOKEN_URL }} + APPLE_REVOKE_URL=${{ secrets.APPLE_REVOKE_URL }} + APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} + APPLE_KEY_ID=${{ secrets.APPLE_KEY_ID }} + APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }} + APPLE_AUD=${{ secrets.APPLE_AUD }} + APPLE_PRIVATE_KEY=${{ secrets.APPLE_PRIVATE_KEY }} JWT_SECRET=${{ secrets.JWT_SECRET }} JWT_VALIDITY=${{ secrets.JWT_VALIDITY }} REFRESH_JWT_SECRET=${{ secrets.REFRESH_JWT_SECRET }} @@ -107,4 +114,4 @@ jobs: docker pull ${{ secrets.NCP_REGISTRY }}/motimate:latest docker stop motimate docker rm motimate - docker run -v /home/iOS02-moti/BE/.production.env:/usr/src/app/.production.env -d -p 3000:3000 --name motimate ${{ secrets.NCP_REGISTRY }}/motimate:latest \ No newline at end of file + docker run -v /home/iOS02-moti/BE/.production.env:/usr/src/app/.production.env -e TZ=Asia/Seoul -d -p 3000:3000 --name motimate ${{ secrets.NCP_REGISTRY }}/motimate:latest \ No newline at end of file diff --git a/.github/workflows/be.ci.yml b/.github/workflows/be.ci.yml index 229eec03..f9508cec 100644 --- a/.github/workflows/be.ci.yml +++ b/.github/workflows/be.ci.yml @@ -56,6 +56,13 @@ jobs: SWAGGER_VERSION=${{ secrets.SWAGGER_VERSION }} SWAGGER_TAG=${{ secrets.SWAGGER_TAG }} APPLE_PUBLIC_KEY_URL=${{ secrets.APPLE_PUBLIC_KEY_URL }} + APPLE_TOKEN_URL=${{ secrets.APPLE_TOKEN_URL }} + APPLE_REVOKE_URL=${{ secrets.APPLE_REVOKE_URL }} + APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} + APPLE_KEY_ID=${{ secrets.APPLE_KEY_ID }} + APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }} + APPLE_AUD=${{ secrets.APPLE_AUD }} + APPLE_PRIVATE_KEY=${{ secrets.APPLE_PRIVATE_KEY }} JWT_SECRET=${{ secrets.JWT_SECRET }} JWT_VALIDITY=${{ secrets.JWT_VALIDITY }} REFRESH_JWT_SECRET=${{ secrets.REFRESH_JWT_SECRET }} diff --git a/BE/public/index.html b/BE/public/index.html index 3929973a..6ec56cac 100644 --- a/BE/public/index.html +++ b/BE/public/index.html @@ -12,7 +12,9 @@

모티 앱 v0.3 Download

-

Last updated: 2023-12.07 23:59

+

모티 앱 v1.0 Download

+ +

Last updated: 2023-12.11 10:00

\ No newline at end of file diff --git a/BE/src/achievement/entities/achievement.entity.ts b/BE/src/achievement/entities/achievement.entity.ts index 396f5e64..2f73c963 100644 --- a/BE/src/achievement/entities/achievement.entity.ts +++ b/BE/src/achievement/entities/achievement.entity.ts @@ -20,7 +20,7 @@ export class AchievementEntity extends BaseTimeEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user: UserEntity; diff --git a/BE/src/achievement/entities/achievement.repository.spec.ts b/BE/src/achievement/entities/achievement.repository.spec.ts index a052c3d7..f0ebd6e2 100644 --- a/BE/src/achievement/entities/achievement.repository.spec.ts +++ b/BE/src/achievement/entities/achievement.repository.spec.ts @@ -17,7 +17,6 @@ import { CategoryTestModule } from '../../../test/category/category-test.module' import { Achievement } from '../domain/achievement.domain'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; import { ImageFixture } from '../../../test/image/image-fixture'; -import { Category } from '../../category/domain/category.domain'; describe('AchievementRepository test', () => { let achievementRepository: AchievementRepository; @@ -300,6 +299,31 @@ describe('AchievementRepository test', () => { }); }); + test('자신의 미설정 카테고리의 도전기록을 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const otherUser1 = await usersFixture.getUser('BCD'); + const otherUser2 = await usersFixture.getUser('BCD'); + await achievementFixture.getAchievements(10, otherUser1, null); + await achievementFixture.getAchievements(10, otherUser2, null); + + const user = await usersFixture.getUser('ABC'); + + const achievements: Achievement[] = + await achievementFixture.getAchievements(10, user, null); + + // when + const achievementDetail = + await achievementRepository.findAchievementDetail( + user.id, + achievements[9].id, + ); + + // then + expect(achievementDetail.category.achieveCount).toEqual(10); + }); + }); + test('유효하지 않은 달성 기록 id를 통해 조회하면 null을 반환한다.', async () => { await transactionTest(dataSource, async () => { // given diff --git a/BE/src/achievement/entities/achievement.repository.ts b/BE/src/achievement/entities/achievement.repository.ts index 5d199ea4..79054ab2 100644 --- a/BE/src/achievement/entities/achievement.repository.ts +++ b/BE/src/achievement/entities/achievement.repository.ts @@ -6,9 +6,6 @@ import { FindOptionsWhere, IsNull, LessThan } from 'typeorm'; import { PaginateAchievementRequest } from '../dto/paginate-achievement-request'; import { AchievementDetailResponse } from '../dto/achievement-detail-response'; import { IAchievementDetail } from '../index'; -import { User } from '../../users/domain/user.domain'; -import { ICategoryMetaData } from '../../category'; -import { CategoryMetaData } from '../../category/dto/category-metadata'; @CustomRepository(AchievementEntity) export class AchievementRepository extends TransactionalRepository { @@ -62,7 +59,7 @@ export class AchievementRepository extends TransactionalRepository UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/auth/application/auth.service.spec.ts b/BE/src/auth/application/auth.service.spec.ts index 7b50d9a0..7ac61b4f 100644 --- a/BE/src/auth/application/auth.service.spec.ts +++ b/BE/src/auth/application/auth.service.spec.ts @@ -21,11 +21,21 @@ import { RefreshTokenNotFoundException } from '../exception/refresh-token-not-fo import { AuthTestModule } from '../../../test/auth/auth-test.module'; import { anyString, instance, mock, when } from 'ts-mockito'; import { AvatarHolder } from './avatar.holder'; +import { RevokeAppleAuthRequest } from '../dto/revoke-apple-auth-request.dto'; +import { UsersFixture } from '../../../test/user/users-fixture'; +import { UsersTestModule } from '../../../test/user/users-test.module'; +import { InvalidIdentifierException } from '../exception/invalid-identifier.exception'; +import { RevokeRequestFailException } from '../exception/revoke-request-fail.exception'; +import { transactionTest } from '../../../test/common/transaction-test'; +import { DataSource } from 'typeorm'; describe('AuthService', () => { let authService: AuthService; let mockOauthHandler: OauthHandler; let refreshTokenStore: Cache; + let userFixture: UsersFixture; + let userRepository: UserRepository; + let dataSource: DataSource; beforeAll(async () => { mockOauthHandler = mock(OauthHandler); @@ -35,6 +45,7 @@ describe('AuthService', () => { HttpModule, CacheModule.register(), AuthTestModule, + UsersTestModule, JwtModule.register({}), CustomTypeOrmModule.forCustomRepository([UserRepository]), ConfigModule.forRoot(configServiceModuleOptions), @@ -54,7 +65,14 @@ describe('AuthService', () => { .compile(); authService = module.get(AuthService); + userRepository = module.get(UserRepository); + userFixture = module.get(UsersFixture); refreshTokenStore = module.get(CACHE_MANAGER); + dataSource = module.get(DataSource); + }); + + afterAll(async () => { + await dataSource.destroy(); }); test('authService가 정의되어 있어야 한다.', () => { @@ -62,66 +80,146 @@ describe('AuthService', () => { }); test('apple login을 처리하고 자체 accessToken, refreshToken을 발급한다.', async () => { - // given - const appleLoginRequest = new AppleLoginRequest( - 'eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vdGltYXRlLm1vdGkiLCJleHAiOjE2OTk2OTkxMzIsImlhdCI6MTY5OTYxMjczMiwic3ViIjoiMDAwODQ4LjEyNTU5ZWUxNTkyYjQ0YWY5NzA1ZmRhYmYyOGFlMzhiLjEwMjQiLCJjX2hhc2giOiJxSkd3ZEhyNEZYb055Qllobm5vQ21RIiwiYXV0aF90aW1lIjoxNjk5NjEyNzMyLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.dGi4dKG9ezLUu0Zm51sgZaORtQwlbsLj8kMqrIb9wZ52pLeT7-SwFbo1rA-ATZh5PEXS-VGpw6fz7AyTzzYWzxWxvJ8oEDOJE8fHI5JMJiYLHjujim565RT7t36zKWwhWDS1pOn9eQc-ivIEmfSeslzOgre8HucYWfoHu1bsiyWbP1mp3e6Tu_RfR41KUD_E0oOFVw-sDHHkUejjLu2ZjyFYQ75AvivpArfOabsF3D1kl-ONtP2-ImRxLMgFZ9D5y9_8SCtP57kTgFFUm_07ik6srgcg2sn5qhKzQOxOsdHG46pI7XUHuX8N5XoU0cYbA-HefDHCPVfy4N8ikUcQkQ', - ); - when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( - '123456.12559ee1592b44af9705fdabf28ae38b.1234', - ); - - // when - const response = await authService.appleLogin(appleLoginRequest); - - // then - const refreshTokenFromStore = await refreshTokenStore.get( - response.user.userCode, - ); - expect(response.user).toBeDefined(); - expect(response.user.userCode).toBeDefined(); - expect(response.user.avatarUrl).toBeDefined(); - expect(response.accessToken).toBeDefined(); - expect(response.refreshToken).toBeDefined(); - expect(response.refreshToken).toBeDefined(); - expect(refreshTokenFromStore).toBeDefined(); + await transactionTest(dataSource, async () => { + // given + const appleLoginRequest = new AppleLoginRequest( + 'eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vdGltYXRlLm1vdGkiLCJleHAiOjE2OTk2OTkxMzIsImlhdCI6MTY5OTYxMjczMiwic3ViIjoiMDAwODQ4LjEyNTU5ZWUxNTkyYjQ0YWY5NzA1ZmRhYmYyOGFlMzhiLjEwMjQiLCJjX2hhc2giOiJxSkd3ZEhyNEZYb055Qllobm5vQ21RIiwiYXV0aF90aW1lIjoxNjk5NjEyNzMyLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.dGi4dKG9ezLUu0Zm51sgZaORtQwlbsLj8kMqrIb9wZ52pLeT7-SwFbo1rA-ATZh5PEXS-VGpw6fz7AyTzzYWzxWxvJ8oEDOJE8fHI5JMJiYLHjujim565RT7t36zKWwhWDS1pOn9eQc-ivIEmfSeslzOgre8HucYWfoHu1bsiyWbP1mp3e6Tu_RfR41KUD_E0oOFVw-sDHHkUejjLu2ZjyFYQ75AvivpArfOabsF3D1kl-ONtP2-ImRxLMgFZ9D5y9_8SCtP57kTgFFUm_07ik6srgcg2sn5qhKzQOxOsdHG46pI7XUHuX8N5XoU0cYbA-HefDHCPVfy4N8ikUcQkQ', + ); + when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( + '123456.12559ee1592b44af9705fdabf28ae38b.1234', + ); + + // when + const response = await authService.appleLogin(appleLoginRequest); + + // then + const refreshTokenFromStore = await refreshTokenStore.get( + response.user.userCode, + ); + expect(response.user).toBeDefined(); + expect(response.user.userCode).toBeDefined(); + expect(response.user.avatarUrl).toBeDefined(); + expect(response.accessToken).toBeDefined(); + expect(response.refreshToken).toBeDefined(); + expect(response.refreshToken).toBeDefined(); + expect(refreshTokenFromStore).toBeDefined(); + }); }); test('refreshToken을 통해 새로운 accessToken을 발급한다.', async () => { - // given - const appleLoginRequest = new AppleLoginRequest( - 'eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vdGltYXRlLm1vdGkiLCJleHAiOjE2OTk2OTkxMzIsImlhdCI6MTY5OTYxMjczMiwic3ViIjoiMDAwODQ4LjEyNTU5ZWUxNTkyYjQ0YWY5NzA1ZmRhYmYyOGFlMzhiLjEwMjQiLCJjX2hhc2giOiJxSkd3ZEhyNEZYb055Qllobm5vQ21RIiwiYXV0aF90aW1lIjoxNjk5NjEyNzMyLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.dGi4dKG9ezLUu0Zm51sgZaORtQwlbsLj8kMqrIb9wZ52pLeT7-SwFbo1rA-ATZh5PEXS-VGpw6fz7AyTzzYWzxWxvJ8oEDOJE8fHI5JMJiYLHjujim565RT7t36zKWwhWDS1pOn9eQc-ivIEmfSeslzOgre8HucYWfoHu1bsiyWbP1mp3e6Tu_RfR41KUD_E0oOFVw-sDHHkUejjLu2ZjyFYQ75AvivpArfOabsF3D1kl-ONtP2-ImRxLMgFZ9D5y9_8SCtP57kTgFFUm_07ik6srgcg2sn5qhKzQOxOsdHG46pI7XUHuX8N5XoU0cYbA-HefDHCPVfy4N8ikUcQkQ', - ); - when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( - '123456.12559ee1592b44af9705fdabf28ae38b.1234', - ); - - const loginResponse = await authService.appleLogin(appleLoginRequest); - const user = User.from('123456.12559ee1592b44af9705fdabf28ae38b.1234'); - user.assignUserCode(loginResponse.user.userCode); - - // // when - const response = await authService.refresh( - user, - new RefreshAuthRequestDto(loginResponse.refreshToken), - ); - - // then - expect(response.user).toBeDefined(); - expect(response.accessToken).toBeDefined(); + await transactionTest(dataSource, async () => { + // given + const appleLoginRequest = new AppleLoginRequest( + 'eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vdGltYXRlLm1vdGkiLCJleHAiOjE2OTk2OTkxMzIsImlhdCI6MTY5OTYxMjczMiwic3ViIjoiMDAwODQ4LjEyNTU5ZWUxNTkyYjQ0YWY5NzA1ZmRhYmYyOGFlMzhiLjEwMjQiLCJjX2hhc2giOiJxSkd3ZEhyNEZYb055Qllobm5vQ21RIiwiYXV0aF90aW1lIjoxNjk5NjEyNzMyLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.dGi4dKG9ezLUu0Zm51sgZaORtQwlbsLj8kMqrIb9wZ52pLeT7-SwFbo1rA-ATZh5PEXS-VGpw6fz7AyTzzYWzxWxvJ8oEDOJE8fHI5JMJiYLHjujim565RT7t36zKWwhWDS1pOn9eQc-ivIEmfSeslzOgre8HucYWfoHu1bsiyWbP1mp3e6Tu_RfR41KUD_E0oOFVw-sDHHkUejjLu2ZjyFYQ75AvivpArfOabsF3D1kl-ONtP2-ImRxLMgFZ9D5y9_8SCtP57kTgFFUm_07ik6srgcg2sn5qhKzQOxOsdHG46pI7XUHuX8N5XoU0cYbA-HefDHCPVfy4N8ikUcQkQ', + ); + when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( + '123456.12559ee1592b44af9705fdabf28ae38b.1234', + ); + + const loginResponse = await authService.appleLogin(appleLoginRequest); + const user = User.from('123456.12559ee1592b44af9705fdabf28ae38b.1234'); + user.assignUserCode(loginResponse.user.userCode); + + // // when + const response = await authService.refresh( + user, + new RefreshAuthRequestDto(loginResponse.refreshToken), + ); + + // then + expect(response.user).toBeDefined(); + expect(response.accessToken).toBeDefined(); + }); }); test('유효하지 않은 refreshToken에 대한 요청에 대해서 RefreshTokenNotFoundException를 던진다.', async () => { - // given - const user = User.from('123456.12559ee1592b44af9705fdabf28ae38b.1234'); - user.assignUserCode('ABCDEF1'); - - // when - // then - await expect( - authService.refresh( + await transactionTest(dataSource, async () => { + // given + const user = User.from('123456.12559ee1592b44af9705fdabf28ae38b.1234'); + user.assignUserCode('ABCDEF1'); + + // when + // then + await expect( + authService.refresh( + user, + new RefreshAuthRequestDto('INVALID_REFRESH_TOKEN'), + ), + ).rejects.toThrow(RefreshTokenNotFoundException); + }); + }); + + test('apple login에 대해 revoke 처리하고 유저를 삭제한다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await userFixture.getUser(1); + + when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( + user.userIdentifier, + ); + when(mockOauthHandler.revokeUser(anyString())).thenResolve({ + status: 200, + statusText: 'OK', + data: null, + headers: null, + config: null, + }); + + // when + const revokeAppleAuthResponse = await authService.revoke( user, - new RefreshAuthRequestDto('INVALID_REFRESH_TOKEN'), - ), - ).rejects.toThrow(RefreshTokenNotFoundException); + new RevokeAppleAuthRequest(user.userIdentifier, 'authorizationCode'), + ); + + //then + const removed = await userRepository.findOneByUserCode(user.userCode); + expect(removed).toBeUndefined(); + expect(revokeAppleAuthResponse.userCode).toEqual(user.userCode); + }); + }); + + test('유효하지 않은 userIdentifier 요청에는 InvalidIdentifierException를 던진다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await userFixture.getUser(1); + when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( + 'unmatched', + ); + // when + //then + await expect( + authService.revoke( + user, + new RevokeAppleAuthRequest(user.userIdentifier, 'authorizationCode'), + ), + ).rejects.toThrow(InvalidIdentifierException); + }); + }); + + test('애플 서버로부터 revoke 실패 응답을 받는 경우에는 RevokeRequestFailException 던진다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await userFixture.getUser(1); + + when(mockOauthHandler.getUserIdentifier(anyString())).thenResolve( + user.userIdentifier, + ); + when(mockOauthHandler.revokeUser(anyString())).thenResolve({ + status: 500, + statusText: 'Internal Server Error', + data: null, + headers: null, + config: null, + }); + + // when + //then + await expect( + authService.revoke( + user, + new RevokeAppleAuthRequest(user.userIdentifier, 'authorizationCode'), + ), + ).rejects.toThrow(RevokeRequestFailException); + }); }); }); diff --git a/BE/src/auth/application/auth.service.ts b/BE/src/auth/application/auth.service.ts index ee2d8955..3a477437 100644 --- a/BE/src/auth/application/auth.service.ts +++ b/BE/src/auth/application/auth.service.ts @@ -15,6 +15,10 @@ import { RefreshTokenNotFoundException } from '../exception/refresh-token-not-fo import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { AvatarHolder } from './avatar.holder'; +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'; @Injectable() export class AuthService { @@ -73,4 +77,21 @@ export class AuthService { const accessToken = this.jwtUtils.createToken(claim, new Date()); return new RefreshAuthResponseDto(UserDto.from(user), accessToken); } + + async revoke(user: User, revokeAuthRequest: RevokeAppleAuthRequest) { + const requestUserIdentifier = await this.oauthHandler.getUserIdentifier( + revokeAuthRequest.identityToken, + ); + if (user.userIdentifier !== requestUserIdentifier) + throw new InvalidIdentifierException(); + + const authorizationCode = revokeAuthRequest.authorizationCode; + + const revokeResponse = + await this.oauthHandler.revokeUser(authorizationCode); + if (revokeResponse.status !== 200) throw new RevokeRequestFailException(); + + await this.usersRepository.repository.delete(user.id); + return new RevokeAppleAuthResponse(user.userCode); + } } diff --git a/BE/src/auth/application/jwt-utils.ts b/BE/src/auth/application/jwt-utils.ts index 34e46904..fce1b74d 100644 --- a/BE/src/auth/application/jwt-utils.ts +++ b/BE/src/auth/application/jwt-utils.ts @@ -12,6 +12,11 @@ export class JwtUtils { private readonly validityInMilliseconds: number; private readonly refreshSecretKey: string; private readonly refreshValidityInMilliseconds: number; + private readonly clientId: string; + private readonly keyId: string; + private readonly teamId: string; + private readonly aud: string; + private readonly privateKey: string; constructor( private readonly jwtService: JwtService, configService: ConfigService, @@ -22,6 +27,14 @@ export class JwtUtils { this.refreshValidityInMilliseconds = configService.get( 'REFRESH_JWT_VALIDITY', ); + this.clientId = configService.get('APPLE_CLIENT_ID'); + this.keyId = configService.get('APPLE_KEY_ID'); + this.teamId = configService.get('APPLE_TEAM_ID'); + this.aud = configService.get('APPLE_AUD'); + this.privateKey = configService + .get('APPLE_PRIVATE_KEY') + .split(',') + .join('\n'); } createToken(claim: JwtClaim, from: Date) { @@ -96,6 +109,28 @@ export class JwtUtils { return this.jwtService.decode(token); } + clientSecretGenerator(from: Date) { + const issuedAt = Math.floor(from.getTime() / 1000); + const validity = Math.floor( + new Date(from.getTime() + 6048000).getTime() / 1000, + ); + + const header = { alg: 'ES256', kid: this.keyId }; + const payload = { + iat: issuedAt, + exp: validity, + aud: this.aud, + sub: this.clientId, + iss: this.teamId, + }; + + return this.jwtService.sign(payload, { + secret: this.privateKey, + header: header, + algorithm: 'ES256', + }); + } + private toPemFormat(publicKey: PublicKey) { const publicKeyJwkFormat = createPublicKey({ format: 'jwk', diff --git a/BE/src/auth/application/oauth-handler.spec.ts b/BE/src/auth/application/oauth-handler.spec.ts index 3b702887..42df7119 100644 --- a/BE/src/auth/application/oauth-handler.spec.ts +++ b/BE/src/auth/application/oauth-handler.spec.ts @@ -1,18 +1,29 @@ import { OauthRequester } from './oauth-requester'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { PublicKeysResponse } from '../index'; import { JwtUtils } from './jwt-utils'; import { OauthHandler } from './oauth-handler'; +import { Test, TestingModule } from '@nestjs/testing'; +import { configServiceModuleOptions } from '../../config/config'; +import { anyString, instance, mock, when } from 'ts-mockito'; describe('OauthHandler test', () => { - const oauthRequester = new OauthRequester( - new ConfigService(), - new HttpService(), - ); - const jwtUtils = new JwtUtils(new JwtService(), new ConfigService()); - const oauthHandler = new OauthHandler(oauthRequester, jwtUtils); + const mockOauthRequester: OauthRequester = + mock(OauthRequester); + const oauthRequester = instance(mockOauthRequester); + let jwtUtils: JwtUtils; + let oauthHandler: OauthHandler; + let configService: ConfigService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(configServiceModuleOptions)], + }).compile(); + configService = module.get(ConfigService); + jwtUtils = new JwtUtils(new JwtService(), configService); + oauthHandler = new OauthHandler(oauthRequester, jwtUtils); + }); test('apple ID 서버로 부터 public key를 가져와서 identityToken을 검증한다.', async () => { const mockPublicKeyResponse: PublicKeysResponse = { @@ -43,9 +54,8 @@ describe('OauthHandler test', () => { }, ], }; - jest - .spyOn(oauthRequester, 'getPublicKeys') - .mockResolvedValue(mockPublicKeyResponse); + + when(mockOauthRequester.getPublicKeys()).thenResolve(mockPublicKeyResponse); const userIdentifier = await oauthHandler.getUserIdentifier( 'eyJraWQiOiJmaDZCczhDIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLm1vdGltYXRlLm1vdGkiLCJleHAiOjE5MjA1OTkxNjgsImlhdCI6MTY5OTYxMjczMiwic3ViIjoiMTIzNDU2LjEyNTU5ZWUxNTkyYjQ0YWY5NzA1ZmRhYmYyOGFlMzhiLjEyMzQiLCJjX2hhc2giOiJxSkd3ZEhyNEZYb055Qllobm5vQ21RIiwiYXV0aF90aW1lIjoxNjk5NjEyNzMyLCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.YdK6WYDeGBskSPCARJl-OSGJ8Bo5RiO36lmo8KV2xRpf-QNbHJXakvfrEREXyxsesNQnGxD6AV0hJTNVYIC5LbDlDRP-09Kihg8RgksAseVvSNLic1Ug0sTN2Aivhmcu9GqB4s0p2Uv4E1mKcx5u5JQtu5c_6oNLrn4AmXJQrwfWebYFyuqkr0gFYaltpd4mVNrgJzuSNJdNyt6-UUefq6KYuPHsm3cIYUTZWzSLxaDV3Kr7vnxta-CFN_EHCDiA5nqRofO0jIDgcE7M8cudpejRtZ7zhzIlOE8ggGyvW3Qg-7MaMTxAaUuIivSqeY_f0GYtsiEl_ouKuWlQ1SPPNA', @@ -55,4 +65,30 @@ describe('OauthHandler test', () => { '123456.12559ee1592b44af9705fdabf28ae38b.1234', ); }); + + test('apple ID 서버로 revoke 요청을 한다.', async () => { + when( + mockOauthRequester.getAccessToken(anyString(), anyString()), + ).thenResolve({ + access_token: 'access_token', + expires_in: 1702108011, + id_token: 'id_token', + refresh_token: 'refresh_token', + token_type: 'access_token', + }); + + when(mockOauthRequester.revoke(anyString(), anyString())).thenResolve({ + status: 200, + statusText: 'OK', + data: null, + headers: null, + config: null, + }); + + const revokeResponse = await oauthHandler.revokeUser( + 'cfbe8301326694c9eb1f6ca027c1002b5.0.srtqw.U4Ma5JMUVe6CeWbsxy5dYA', + ); + + expect(revokeResponse.status).toEqual(200); + }); }); diff --git a/BE/src/auth/application/oauth-handler.ts b/BE/src/auth/application/oauth-handler.ts index 71a48200..ea0487a0 100644 --- a/BE/src/auth/application/oauth-handler.ts +++ b/BE/src/auth/application/oauth-handler.ts @@ -23,4 +23,18 @@ export class OauthHandler implements IOauthHandler { private validateIdentityToken(identityToken: string, publicKey: PublicKey) { this.jwtUtils.validate(identityToken, publicKey); } + + async revokeUser(authorizationCode: string) { + const clientSecret = this.jwtUtils.clientSecretGenerator( + new Date(new Date().toUTCString()), + ); + + const tokenResponse = await this.oauthRequester.getAccessToken( + clientSecret, + authorizationCode, + ); + + const refreshToken = tokenResponse.refresh_token; + return await this.oauthRequester.revoke(clientSecret, refreshToken); + } } diff --git a/BE/src/auth/application/oauth-requester.spec.ts b/BE/src/auth/application/oauth-requester.spec.ts index efd49c4b..0833eabf 100644 --- a/BE/src/auth/application/oauth-requester.spec.ts +++ b/BE/src/auth/application/oauth-requester.spec.ts @@ -4,6 +4,8 @@ import { HttpService } from '@nestjs/axios'; import axios from 'axios'; import { PublicKeysResponse } from '../index'; import { FetchPublicKeyException } from '../exception/fetch-public-key.exception'; +import { FetchAccessTokenException } from '../exception/fetch-access-token.exception'; +import { RevokeRequestFailException } from '../exception/revoke-request-fail.exception'; describe('OauthRequester test', () => { const oauthRequester = new OauthRequester( @@ -60,4 +62,58 @@ describe('OauthRequester test', () => { FetchPublicKeyException, ); }); + + test('Apple ID Server로 부터 토큰(accessToken, refreshToken)를 얻어온다.', async () => { + // given + const tokenResponse = { + access_token: 'access_token', + expires_in: 1702108011, + id_token: 'id_token', + refresh_token: 'refresh_token', + token_type: 'access_token', + }; + + jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse }); + + // when + const result = await oauthRequester.getAccessToken( + 'clientSecret', + 'authorizationCode', + ); + + // then + expect(result).toEqual(tokenResponse); + }); + + test('토큰요청에 실패하면 FetchAccessTokenException 던진다.', async () => { + // given + jest.spyOn(axios, 'post').mockRejectedValue(new Error('Simulated error')); + + // when & then + await expect( + oauthRequester.getAccessToken('clientSecret', 'authorizationCode'), + ).rejects.toThrowError(FetchAccessTokenException); + }); + + test('Apple ID Server에 revoke 요청을 한다.', async () => { + // given + + jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 }); + + // when + const result = await oauthRequester.revoke('clientSecret', 'refreshToken'); + + // then + expect(result.status).toEqual(200); + }); + + test('revoke요청에 실패하면 RevokeRequestFailException 던진다.', async () => { + // given + jest.spyOn(axios, 'post').mockRejectedValue(new Error('Simulated error')); + + // when & then + await expect( + oauthRequester.revoke('clientSecret', 'refreshToken'), + ).rejects.toThrowError(RevokeRequestFailException); + }); }); diff --git a/BE/src/auth/application/oauth-requester.ts b/BE/src/auth/application/oauth-requester.ts index d0f4921a..2002ff6e 100644 --- a/BE/src/auth/application/oauth-requester.ts +++ b/BE/src/auth/application/oauth-requester.ts @@ -3,14 +3,22 @@ import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { PublicKeysResponse } from '../index'; import { FetchPublicKeyException } from '../exception/fetch-public-key.exception'; +import { FetchAccessTokenException } from '../exception/fetch-access-token.exception'; +import { RevokeRequestFailException } from '../exception/revoke-request-fail.exception'; @Injectable() export class OauthRequester { private readonly applePublicKeyUrl: string; + private readonly appleTokenUrl: string; + private readonly appleRevokeUrl: string; + private readonly clientId: string; private readonly httpService: HttpService; constructor(configService: ConfigService, httpService: HttpService) { this.applePublicKeyUrl = configService.get('APPLE_PUBLIC_KEY_URL'); + this.appleTokenUrl = configService.get('APPLE_TOKEN_URL'); + this.appleRevokeUrl = configService.get('APPLE_REVOKE_URL'); + this.clientId = configService.get('APPLE_CLIENT_ID'); this.httpService = httpService; } async getPublicKeys(): Promise { @@ -21,4 +29,44 @@ export class OauthRequester { throw new FetchPublicKeyException(); } } + + async getAccessToken(clientSecret: string, authorizationCode: string) { + const payload = { + code: authorizationCode, + client_id: this.clientId, + grant_type: 'authorization_code', + client_secret: clientSecret, + }; + try { + const res = await this.httpService.axiosRef.post( + this.appleTokenUrl, + payload, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + ); + return res.data; + } catch (err) { + throw new FetchAccessTokenException(); + } + } + + async revoke(clientSecret: string, refreshToken: string) { + const payload = { + token: refreshToken, + client_id: this.clientId, + client_secret: clientSecret, + }; + try { + return await this.httpService.axiosRef.post( + this.appleRevokeUrl, + payload, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + ); + } catch (err) { + throw new RevokeRequestFailException(); + } + } } diff --git a/BE/src/auth/controller/auth.controller.spec.ts b/BE/src/auth/controller/auth.controller.spec.ts index f31d02ab..099e8ae3 100644 --- a/BE/src/auth/controller/auth.controller.spec.ts +++ b/BE/src/auth/controller/auth.controller.spec.ts @@ -19,6 +19,10 @@ import { RefreshAuthRequestDto } from '../dto/refresh-auth-request.dto'; import { RefreshAuthResponseDto } from '../dto/refresh-auth-response.dto'; import { User } from '../../users/domain/user.domain'; import { RefreshTokenNotFoundException } from '../exception/refresh-token-not-found.exception'; +import { RevokeAppleAuthRequest } from '../dto/revoke-apple-auth-request.dto'; +import { RevokeAppleAuthResponse } from '../dto/revoke-apple-auth-response.dto'; +import { InvalidIdentifierException } from '../exception/invalid-identifier.exception'; +import { RevokeRequestFailException } from '../exception/revoke-request-fail.exception'; describe('AchievementController', () => { let app: INestApplication; @@ -278,4 +282,116 @@ describe('AchievementController', () => { }); }); }); + + describe('회원 탈퇴를 한다.', () => { + it('성공 시 200을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + const refreshAuthResponse = new RevokeAppleAuthResponse('ABCDEF1'); + + when( + mockAuthService.revoke( + anyOfClass(User), + anyOfClass(RevokeAppleAuthRequest), + ), + ).thenResolve(refreshAuthResponse); + + // then + // when + return request(app.getHttpServer()) + .delete('/api/v1/auth/revoke') + .set('Authorization', `Bearer ${accessToken}`) + .send(new RevokeAppleAuthRequest('identityToken', 'authorizationCode')) + .expect(200) + .expect((res: request.Response) => { + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual({ + userCode: 'ABCDEF1', + }); + }); + }); + it('존재하지 않는 refresh token으로 요청하는 경우 401을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockAuthService.revoke( + anyOfClass(User), + anyOfClass(RevokeAppleAuthRequest), + ), + ).thenThrow(new InvalidIdentifierException()); + + // then + // when + return request(app.getHttpServer()) + .delete('/api/v1/auth/revoke') + .set('Authorization', `Bearer ${accessToken}`) + .send(new RevokeAppleAuthRequest('identityToken', 'authorizationCode')) + .expect(400) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('유효하지 않는 identifier 입니다.'); + }); + }); + + it('만료된 refresh token에 401을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockAuthService.revoke( + anyOfClass(User), + anyOfClass(RevokeAppleAuthRequest), + ), + ).thenThrow(new RevokeRequestFailException()); + // when + // then + return request(app.getHttpServer()) + .delete('/api/v1/auth/revoke') + .set('Authorization', `Bearer ${accessToken}`) + .send(new RevokeAppleAuthRequest('identityToken', 'authorizationCode')) + .expect(500) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe( + 'Apple ID 서버로 revoke 요청이 실패했습니다.', + ); + }); + }); + + it('잘못된 인증시 401을 반환한다.', async () => { + // given + const accessToken = 'abcd.abcd.efgh'; + + // when + // then + return request(app.getHttpServer()) + .delete('/api/v1/auth/revoke') + .set('Authorization', `Bearer ${accessToken}`) + .send(new RevokeAppleAuthRequest('identityToken', 'authorizationCode')) + .expect(401) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('잘못된 토큰입니다.'); + }); + }); + + it('만료된 인증정보에 401을 반환한다.', async () => { + // given + const { accessToken } = + await authFixture.getExpiredAccessTokenUser('ABC'); + // when + // then + return request(app.getHttpServer()) + .delete('/api/v1/auth/revoke') + .set('Authorization', `Bearer ${accessToken}`) + .send(new RevokeAppleAuthRequest('identityToken', 'authorizationCode')) + .expect(401) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('만료된 토큰입니다.'); + }); + }); + }); }); diff --git a/BE/src/auth/controller/auth.controller.ts b/BE/src/auth/controller/auth.controller.ts index 869d1ecd..cb94f2c8 100644 --- a/BE/src/auth/controller/auth.controller.ts +++ b/BE/src/auth/controller/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Post, UseGuards } from '@nestjs/common'; import { AuthService } from '../application/auth.service'; import { AppleLoginRequest } from '../dto/apple-login-request.dto'; import { @@ -15,6 +15,8 @@ import { AccessTokenGuard } from '../guard/access-token.guard'; import { User } from '../../users/domain/user.domain'; import { AuthenticatedUser } from '../decorator/athenticated-user.decorator'; import { RefreshAuthResponseDto } from '../dto/refresh-auth-response.dto'; +import { RevokeAppleAuthRequest } from '../dto/revoke-apple-auth-request.dto'; +import { RevokeAppleAuthResponse } from '../dto/revoke-apple-auth-response.dto'; @Controller('/api/v1/auth') @ApiTags('auth API') @@ -58,4 +60,27 @@ export class AuthController { ); return ApiData.success(refreshAuthResponse); } + + @Delete('revoke') + @UseGuards(AccessTokenGuard) + @ApiOperation({ + summary: '회원탈퇴 API', + description: 'refreshToken을 통해 accessToken을 재발급한다.', + }) + @ApiResponse({ + status: 200, + description: '회원 탈퇴', + type: RevokeAppleAuthResponse, + }) + @ApiBearerAuth('accessToken') + async deactivateUser( + @AuthenticatedUser() user: User, + @Body() revokeAuthRequest: RevokeAppleAuthRequest, + ) { + const revokeAuthResponse = await this.authService.revoke( + user, + revokeAuthRequest, + ); + return ApiData.success(revokeAuthResponse); + } } diff --git a/BE/src/auth/dto/revoke-apple-auth-request.dto.ts b/BE/src/auth/dto/revoke-apple-auth-request.dto.ts new file mode 100644 index 00000000..24db4119 --- /dev/null +++ b/BE/src/auth/dto/revoke-apple-auth-request.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmptyString } from '../../config/config/validation-decorator'; + +export class RevokeAppleAuthRequest { + @IsNotEmptyString() + @ApiProperty({ description: 'identityToken' }) + identityToken: string; + @IsNotEmptyString() + @ApiProperty({ description: 'authorizationCode' }) + authorizationCode: string; + + constructor(identityToken: string, authorizationCode: string) { + this.identityToken = identityToken; + this.authorizationCode = authorizationCode; + } +} diff --git a/BE/src/auth/dto/revoke-apple-auth-response.dto.ts b/BE/src/auth/dto/revoke-apple-auth-response.dto.ts new file mode 100644 index 00000000..5351b18f --- /dev/null +++ b/BE/src/auth/dto/revoke-apple-auth-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RevokeAppleAuthResponse { + @ApiProperty({ description: 'userCode' }) + userCode: string; + constructor(userCode: string) { + this.userCode = userCode; + } +} diff --git a/BE/src/auth/exception/fetch-access-token.exception.ts b/BE/src/auth/exception/fetch-access-token.exception.ts new file mode 100644 index 00000000..a767ee9d --- /dev/null +++ b/BE/src/auth/exception/fetch-access-token.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../common/exception/error-code'; + +export class FetchAccessTokenException extends MotimateException { + constructor() { + super(ERROR_INFO.FETCH_ACCESS_TOKEN); + } +} diff --git a/BE/src/auth/exception/invalid-identifier.exception.ts b/BE/src/auth/exception/invalid-identifier.exception.ts new file mode 100644 index 00000000..bcb1c148 --- /dev/null +++ b/BE/src/auth/exception/invalid-identifier.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../common/exception/error-code'; + +export class InvalidIdentifierException extends MotimateException { + constructor() { + super(ERROR_INFO.INVALID_IDENTIFIER); + } +} diff --git a/BE/src/auth/exception/revoke-request-fail.exception.ts b/BE/src/auth/exception/revoke-request-fail.exception.ts new file mode 100644 index 00000000..27d4cb70 --- /dev/null +++ b/BE/src/auth/exception/revoke-request-fail.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../common/exception/error-code'; + +export class RevokeRequestFailException extends MotimateException { + constructor() { + super(ERROR_INFO.REVOKE_REQUEST_FAIL); + } +} diff --git a/BE/src/category/entities/category.entity.ts b/BE/src/category/entities/category.entity.ts index 77f39b68..5b92fdd1 100644 --- a/BE/src/category/entities/category.entity.ts +++ b/BE/src/category/entities/category.entity.ts @@ -19,7 +19,7 @@ export class CategoryEntity extends BaseTimeEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/common/exception/error-code.ts b/BE/src/common/exception/error-code.ts index 67c3311c..524bce7d 100644 --- a/BE/src/common/exception/error-code.ts +++ b/BE/src/common/exception/error-code.ts @@ -11,6 +11,18 @@ export const ERROR_INFO = { statusCode: 500, message: 'Apple ID 서버로의 public key 요청이 실패했습니다.', }, + FETCH_ACCESS_TOKEN: { + statusCode: 500, + message: 'Apple ID 서버로의 access token 요청이 실패했습니다.', + }, + REVOKE_REQUEST_FAIL: { + statusCode: 500, + message: 'Apple ID 서버로 revoke 요청이 실패했습니다.', + }, + INVALID_IDENTIFIER: { + statusCode: 400, + message: '유효하지 않는 identifier 입니다.', + }, INVALID_TOKEN: { statusCode: 401, message: '잘못된 토큰입니다.', @@ -71,6 +83,10 @@ export const ERROR_INFO = { statusCode: 400, message: '그룹에 카테고리를 만들 수 없습니다.', }, + NO_SUCH_GROUP: { + statusCode: 404, + message: '존재하지 않는 그룹 입니다.', + }, NO_SUCH_USER_GROUP: { statusCode: 400, message: '그룹의 멤버가 아닙니다.', @@ -95,6 +111,10 @@ export const ERROR_INFO = { statusCode: 400, message: '이미 초대된 그룹원 입니다.', }, + DUPLICATED_JOIN: { + statusCode: 400, + message: '이미 그룹의 그룹원 입니다.', + }, NO_SUCH_GROUP_USER: { statusCode: 400, message: '유저가 속한 그룹이 아닙니다.', diff --git a/BE/src/config/config/index.ts b/BE/src/config/config/index.ts index 4aa52875..bc3b2217 100644 --- a/BE/src/config/config/index.ts +++ b/BE/src/config/config/index.ts @@ -24,6 +24,13 @@ export const configServiceModuleOptions = { SWAGGER_TAG: Joi.string().required(), APPLE_PUBLIC_KEY_URL: Joi.string().required(), + APPLE_TOKEN_URL: Joi.string().required(), + APPLE_REVOKE_URL: Joi.string().required(), + APPLE_CLIENT_ID: Joi.string().required(), + APPLE_KEY_ID: Joi.string().required(), + APPLE_TEAM_ID: Joi.string().required(), + APPLE_AUD: Joi.string().required(), + APPLE_PRIVATE_KEY: Joi.string().required(), JWT_SECRET: Joi.string().required(), JWT_VALIDITY: Joi.number().required(), REFRESH_JWT_SECRET: Joi.string().required(), diff --git a/BE/src/config/redis/index.ts b/BE/src/config/redis/index.ts index 7c04523f..e8eb42a1 100644 --- a/BE/src/config/redis/index.ts +++ b/BE/src/config/redis/index.ts @@ -1,18 +1,29 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; -import { redisStore } from 'cache-manager-redis-store'; import { memoryStore } from 'cache-manager'; +import { redisStore } from 'cache-manager-redis-store'; +import { CacheStore } from '@nestjs/cache-manager'; +import { CacheManagerOptions } from '@nestjs/cache-manager/dist/interfaces/cache-manager.interface'; export const redisModuleOptions = { imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => { - return configService.get('NODE_ENV') === 'production' - ? { - store: redisStore, + useFactory: async ( + configService: ConfigService, + ): Promise => { + if (configService.get('NODE_ENV') === 'production') { + const store = await redisStore({ + socket: { host: configService.get('REDIS_HOST'), port: configService.get('REDIS_PORT'), - ttl: configService.get('CACHE_TTL'), - } - : { store: memoryStore }; + }, + ttl: configService.get('CACHE_TTL'), + }); + return { + store: { + create: () => store as unknown as CacheStore, + }, + }; + } + return { store: memoryStore, ttl: configService.get('CACHE_TTL') }; }, }; diff --git a/BE/src/group/achievement/application/group-achievement.service.spec.ts b/BE/src/group/achievement/application/group-achievement.service.spec.ts index fe8c61d1..dfd8beee 100644 --- a/BE/src/group/achievement/application/group-achievement.service.spec.ts +++ b/BE/src/group/achievement/application/group-achievement.service.spec.ts @@ -30,6 +30,7 @@ import { UsersService } from '../../../users/application/users.service'; import { UsersModule } from '../../../users/users.module'; import { GroupAchievementRepository } from '../entities/group-achievement.repository'; import { GroupAchievementUpdateRequest } from '../dto/group-achievement-update-request'; +import { NoSuchUserGroupException } from '../../group/exception/no-such-user-group.exception'; describe('GroupAchievementService Test', () => { let groupAchievementService: GroupAchievementService; @@ -774,7 +775,40 @@ describe('GroupAchievementService Test', () => { }); }); - test('남의 달성기록을 삭제하려하면 NoSuchGroupAchievementException를 던진다.', async () => { + test('관리자나 매니저는 다른 사람이 작성한 달성기록을 삭제할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const leader = await usersFixture.getUser('ABC'); + const member = await usersFixture.getUser('DEF'); + const group = await groupFixture.createGroup('GROUP1', leader); + await groupFixture.addMember(group, member, UserGroupGrade.PARTICIPANT); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + member, + group, + null, + 'title', + ); + + // when + await groupAchievementService.delete( + leader.id, + group.id, + groupAchievement.id, + ); + const findOne = + await groupAchievementRepository.findOneByIdAndUserAndGroup( + member.id, + group.id, + groupAchievement.id, + ); + + // then + expect(findOne).toBeUndefined(); + }); + }); + + test('관리자나 매니저가 아닌 사람이 남의 달성기록을 삭제하려하면 NoSuchGroupAchievementException를 던진다.', async () => { await transactionTest(dataSource, async () => { // given const user1 = await usersFixture.getUser('ABC'); @@ -793,10 +827,10 @@ describe('GroupAchievementService Test', () => { // then await expect( groupAchievementService.delete(user2.id, group.id, groupAchievement.id), - ).rejects.toThrow(NoSuchGroupAchievementException); + ).rejects.toThrow(UnauthorizedAchievementException); }); }); - test('다른 그룹의 달성을 삭제하려하면 NoSuchGroupAchievementException를 던진다.', async () => { + test('다른 그룹의 달성을 삭제하려하면 NoSuchUserGroupException 던진다.', async () => { await transactionTest(dataSource, async () => { // given const user1 = await usersFixture.getUser('ABC'); @@ -819,7 +853,7 @@ describe('GroupAchievementService Test', () => { group1.id, groupAchievement.id, ), - ).rejects.toThrow(NoSuchGroupAchievementException); + ).rejects.toThrow(NoSuchUserGroupException); }); }); diff --git a/BE/src/group/achievement/application/group-achievement.service.ts b/BE/src/group/achievement/application/group-achievement.service.ts index 4763bacc..bdd98b31 100644 --- a/BE/src/group/achievement/application/group-achievement.service.ts +++ b/BE/src/group/achievement/application/group-achievement.service.ts @@ -18,10 +18,12 @@ import { NoSuchAchievementException } from '../../../achievement/exception/no-su import { UnauthorizedAchievementException } from '../../../achievement/exception/unauthorized-achievement.exception'; import { PaginateGroupAchievementRequest } from '../dto/paginate-group-achievement-request'; import { PaginateGroupAchievementResponse } from '../dto/paginate-group-achievement-response'; -import { GroupAchievementResponse } from '../dto/group-achievement-response'; import { GroupAchievementDeleteResponse } from '../dto/group-achievement-delete-response'; import { GroupAchievementUpdateRequest } from '../dto/group-achievement-update-request'; import { GroupAchievementUpdateResponse } from '../dto/group-achievement-update-response'; +import { UserGroupRepository } from '../../group/entities/user-group.repository'; +import { NoSuchUserGroupException } from '../../group/exception/no-such-user-group.exception'; +import { UserGroupGrade } from '../../group/domain/user-group-grade'; @Injectable() export class GroupAchievementService { @@ -31,6 +33,7 @@ export class GroupAchievementService { private readonly groupRepository: GroupRepository, private readonly imageRepository: ImageRepository, private readonly userBlockedGroupAchievementRepository: UserBlockedGroupAchievementRepository, + private readonly userGroupRepository: UserGroupRepository, ) {} @Transactional() @@ -79,7 +82,7 @@ export class GroupAchievementService { const achievement = achieveCreate.toModel(user, group, category, image); const saved = await this.groupAchievementRepository.saveAchievement(achievement); - return this.getAchievementResponse(user.id, saved.id); + return this.getAchievementResponse(user.id, groupId, saved.id); } @Transactional({ readonly: true }) @@ -155,10 +158,15 @@ export class GroupAchievementService { return group; } - private async getAchievementResponse(userId: number, achieveId: number) { + private async getAchievementResponse( + userId: number, + groupId: number, + achieveId: number, + ) { const achievement = await this.groupAchievementRepository.findAchievementDetailByIdAndUser( userId, + groupId, achieveId, ); if (!achievement) throw new NoSuchAchievementException(); @@ -166,12 +174,23 @@ export class GroupAchievementService { return achievement; } - async delete(userId: number, groupId: number, achievementId: number) { - const achievement = await this.getAchievement( - achievementId, - userId, - groupId, - ); + async delete(requesterId: number, groupId: number, achievementId: number) { + const achievement = + await this.groupAchievementRepository.findOneByIdAndGroupId( + achievementId, + groupId, + ); + if (!achievement) throw new NoSuchGroupAchievementException(); + + if (achievement.user.id != requesterId) { + const userGroup = await this.getUserGroup(requesterId, groupId); + if ( + userGroup.grade !== UserGroupGrade.LEADER && + userGroup.grade !== UserGroupGrade.MANAGER + ) + throw new UnauthorizedAchievementException(); + } + await this.groupAchievementRepository.repository.softDelete(achievement.id); return GroupAchievementDeleteResponse.from(achievement); } @@ -190,4 +209,13 @@ export class GroupAchievementService { if (!achievement) throw new NoSuchGroupAchievementException(); return achievement; } + + private async getUserGroup(userId: number, groupId: number) { + const userGroup = await this.userGroupRepository.findOneByUserIdAndGroupId( + userId, + groupId, + ); + if (!userGroup) throw new NoSuchUserGroupException(); + return userGroup; + } } diff --git a/BE/src/group/achievement/controller/group-achievement.controller.spec.ts b/BE/src/group/achievement/controller/group-achievement.controller.spec.ts index 1ae335d6..ae91aa3c 100644 --- a/BE/src/group/achievement/controller/group-achievement.controller.spec.ts +++ b/BE/src/group/achievement/controller/group-achievement.controller.spec.ts @@ -614,6 +614,30 @@ describe('GroupAchievementController', () => { }); }); + it('삭제 권한이 없는 경우에는 403을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockGroupAchievementService.delete( + anyNumber(), + anyNumber(), + anyNumber(), + ), + ).thenThrow(new UnauthorizedAchievementException()); + + // when + // then + return request(app.getHttpServer()) + .delete('/api/v1/groups/1/achievements/1') + .set('Authorization', `Bearer ${accessToken}`) + .expect(403) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('달성기록에 접근할 수 없습니다.'); + }); + }); + it('잘못된 인증시 401을 반환한다.', async () => { // given const accessToken = 'abcd.abcd.efgh'; diff --git a/BE/src/group/achievement/entities/group-achievement.entity.ts b/BE/src/group/achievement/entities/group-achievement.entity.ts index 918e1d07..62c7f3fa 100644 --- a/BE/src/group/achievement/entities/group-achievement.entity.ts +++ b/BE/src/group/achievement/entities/group-achievement.entity.ts @@ -22,7 +22,7 @@ export class GroupAchievementEntity extends BaseTimeEntity { @Column({ type: 'varchar', length: 100, nullable: false }) title: string; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/group/achievement/entities/group-achievement.repository.spec.ts b/BE/src/group/achievement/entities/group-achievement.repository.spec.ts index 270654d1..1f1f61fe 100644 --- a/BE/src/group/achievement/entities/group-achievement.repository.spec.ts +++ b/BE/src/group/achievement/entities/group-achievement.repository.spec.ts @@ -175,6 +175,7 @@ describe('GroupRepository Test', () => { const findGroupAchievement = await groupAchievementRepository.findAchievementDetailByIdAndUser( user.id, + group.id, groupAchievement.id, ); @@ -203,6 +204,7 @@ describe('GroupRepository Test', () => { const findGroupAchievement = await groupAchievementRepository.findAchievementDetailByIdAndUser( user.id, + 1, 2, ); @@ -232,6 +234,7 @@ describe('GroupRepository Test', () => { const findGroupAchievement = await groupAchievementRepository.findAchievementDetailByIdAndUser( user.id, + group.id, groupAchievements[9].id, ); @@ -240,6 +243,104 @@ describe('GroupRepository Test', () => { }); }); + test('미설정 카테고리의 그룹 달성 기록을 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const otherLeader = await usersFixture.getUser('DEF'); + const otherGroup = await groupFixture.createGroup('OTHER', otherLeader); + const otherGroupCategory = await groupCategoryFixture.createCategory( + otherLeader, + otherGroup, + ); + + await groupAchievementFixture.createGroupAchievements( + 10, + otherLeader, + otherGroup, + otherGroupCategory, + ); + + await groupAchievementFixture.createGroupAchievements( + null, + otherLeader, + otherGroup, + otherGroupCategory, + ); + + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroup('GROUP', user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievements = + await groupAchievementFixture.createGroupAchievements( + 12, + user, + group, + groupCategory, + ); + + // when + const findGroupAchievement = + await groupAchievementRepository.findAchievementDetailByIdAndUser( + user.id, + group.id, + groupAchievements[11].id, + ); + + // then + expect(findGroupAchievement.category.achieveCount).toEqual(12); + }); + }); + + test('미설정 카테고리의 그룹 달성 기록을 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const otherLeader = await usersFixture.getUser('DEF'); + const otherGroup = await groupFixture.createGroup('OTHER', otherLeader); + const otherGroupCategory = await groupCategoryFixture.createCategory( + otherLeader, + otherGroup, + ); + + await groupAchievementFixture.createGroupAchievements( + 10, + otherLeader, + otherGroup, + otherGroupCategory, + ); + + await groupAchievementFixture.createGroupAchievements( + null, + otherLeader, + otherGroup, + otherGroupCategory, + ); + + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroup('GROUP', user); + const groupAchievements = + await groupAchievementFixture.createGroupAchievements( + 12, + user, + group, + null, + ); + + // when + const findGroupAchievement = + await groupAchievementRepository.findAchievementDetailByIdAndUser( + user.id, + group.id, + groupAchievements[11].id, + ); + + // then + expect(findGroupAchievement.category.achieveCount).toEqual(12); + }); + }); + test('그룹 달성 기록 id로 조회할 수 있다.', async () => { await transactionTest(dataSource, async () => { // given diff --git a/BE/src/group/achievement/entities/group-achievement.repository.ts b/BE/src/group/achievement/entities/group-achievement.repository.ts index c261ece8..93aa0d59 100644 --- a/BE/src/group/achievement/entities/group-achievement.repository.ts +++ b/BE/src/group/achievement/entities/group-achievement.repository.ts @@ -38,7 +38,7 @@ export class GroupAchievementRepository extends TransactionalRepository(); @@ -70,7 +71,7 @@ export class GroupAchievementRepository extends TransactionalRepository UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/group/achievement/group-achievement.module.ts b/BE/src/group/achievement/group-achievement.module.ts index 88480d68..635f58bc 100644 --- a/BE/src/group/achievement/group-achievement.module.ts +++ b/BE/src/group/achievement/group-achievement.module.ts @@ -7,6 +7,7 @@ import { UserBlockedGroupAchievementRepository } from './entities/user-blocked-g import { GroupCategoryRepository } from '../category/entities/group-category.repository'; import { GroupRepository } from '../group/entities/group.repository'; import { ImageRepository } from '../../image/entities/image.repository'; +import { UserGroupRepository } from '../group/entities/user-group.repository'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { ImageRepository } from '../../image/entities/image.repository'; GroupRepository, ImageRepository, UserBlockedGroupAchievementRepository, + UserGroupRepository, ]), ], controllers: [GroupAchievementController], diff --git a/BE/src/group/category/entities/group-category.entity.ts b/BE/src/group/category/entities/group-category.entity.ts index e90ffcd8..94411b31 100644 --- a/BE/src/group/category/entities/group-category.entity.ts +++ b/BE/src/group/category/entities/group-category.entity.ts @@ -18,7 +18,7 @@ export class GroupCategoryEntity extends BaseTimeEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/group/emoji/application/group-achievement-emoji.service.spec.ts b/BE/src/group/emoji/application/group-achievement-emoji.service.spec.ts index 19616cf1..9ec7dce5 100644 --- a/BE/src/group/emoji/application/group-achievement-emoji.service.spec.ts +++ b/BE/src/group/emoji/application/group-achievement-emoji.service.spec.ts @@ -640,4 +640,248 @@ describe('GroupAchievementEmojiService Test', () => { }); }); }); + + describe('getGroupAchievementEmojiCount는 그룹 달성기록에 이모지를 조회할 수 있다.', () => { + it('그룹 달성기록에 이모지를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const leader = await usersFixture.getUser('user'); + const group = await groupFixture.createGroups(leader); + const groupCategory = await groupCategoryFixture.createCategory( + leader, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + leader, + group, + groupCategory, + ); + + const user = await usersFixture.getUser('otherUser'); + await groupFixture.addMember(group, user, UserGroupGrade.MANAGER); + + // when + const groupAchievementEmojiListElements = + await groupAchievementEmojiService.getGroupAchievementEmojiCount( + user, + group.id, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElements.LIKE.id).toBe(Emoji.LIKE); + expect(groupAchievementEmojiListElements.LIKE.count).toBe(0); + expect(groupAchievementEmojiListElements.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElements.FIRE.id).toBe(Emoji.FIRE); + expect(groupAchievementEmojiListElements.FIRE.count).toBe(0); + expect(groupAchievementEmojiListElements.FIRE.isSelected).toBe(false); + expect(groupAchievementEmojiListElements.SMILE.id).toBe(Emoji.SMILE); + expect(groupAchievementEmojiListElements.SMILE.count).toBe(0); + expect(groupAchievementEmojiListElements.SMILE.isSelected).toBe(false); + }); + }); + + it('그룹 달성기록에 이모지를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const leader = await usersFixture.getUser('user'); + const group = await groupFixture.createGroups(leader); + const groupCategory = await groupCategoryFixture.createCategory( + leader, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + leader, + group, + groupCategory, + ); + + const user = await usersFixture.getUser('otherUser'); + await groupFixture.addMember(group, user, UserGroupGrade.MANAGER); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.LIKE, + ); + + // when + const groupAchievementEmojiListElements = + await groupAchievementEmojiService.getGroupAchievementEmojiCount( + user, + group.id, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElements.LIKE.id).toBe(Emoji.LIKE); + expect(groupAchievementEmojiListElements.LIKE.count).toBe(1); + expect(groupAchievementEmojiListElements.LIKE.isSelected).toBe(true); + expect(groupAchievementEmojiListElements.FIRE.id).toBe(Emoji.FIRE); + expect(groupAchievementEmojiListElements.FIRE.count).toBe(0); + expect(groupAchievementEmojiListElements.FIRE.isSelected).toBe(false); + expect(groupAchievementEmojiListElements.SMILE.id).toBe(Emoji.SMILE); + expect(groupAchievementEmojiListElements.SMILE.count).toBe(0); + expect(groupAchievementEmojiListElements.SMILE.isSelected).toBe(false); + }); + }); + + it('그룹 달성기록에 이모지를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const leader = await usersFixture.getUser('user'); + const group = await groupFixture.createGroups(leader); + const groupCategory = await groupCategoryFixture.createCategory( + leader, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + leader, + group, + groupCategory, + ); + + const user = await usersFixture.getUser('otherUser'); + await groupFixture.addMember(group, user, UserGroupGrade.MANAGER); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.FIRE, + ); + + // when + const groupAchievementEmojiListElements = + await groupAchievementEmojiService.getGroupAchievementEmojiCount( + user, + group.id, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElements.LIKE.id).toBe(Emoji.LIKE); + expect(groupAchievementEmojiListElements.LIKE.count).toBe(0); + expect(groupAchievementEmojiListElements.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElements.FIRE.id).toBe(Emoji.FIRE); + expect(groupAchievementEmojiListElements.FIRE.count).toBe(1); + expect(groupAchievementEmojiListElements.FIRE.isSelected).toBe(true); + expect(groupAchievementEmojiListElements.SMILE.id).toBe(Emoji.SMILE); + expect(groupAchievementEmojiListElements.SMILE.count).toBe(0); + expect(groupAchievementEmojiListElements.SMILE.isSelected).toBe(false); + }); + }); + + it('그룹 달성기록에 이모지를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const leader = await usersFixture.getUser('user'); + const group = await groupFixture.createGroups(leader); + const groupCategory = await groupCategoryFixture.createCategory( + leader, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + leader, + group, + groupCategory, + ); + + const user = await usersFixture.getUser('otherUser'); + await groupFixture.addMember(group, user, UserGroupGrade.MANAGER); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.FIRE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.LIKE, + ); + + // when + const groupAchievementEmojiListElements = + await groupAchievementEmojiService.getGroupAchievementEmojiCount( + user, + group.id, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElements.LIKE.id).toBe(Emoji.LIKE); + expect(groupAchievementEmojiListElements.LIKE.count).toBe(10); + expect(groupAchievementEmojiListElements.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElements.FIRE.id).toBe(Emoji.FIRE); + expect(groupAchievementEmojiListElements.FIRE.count).toBe(1); + expect(groupAchievementEmojiListElements.FIRE.isSelected).toBe(true); + expect(groupAchievementEmojiListElements.SMILE.id).toBe(Emoji.SMILE); + expect(groupAchievementEmojiListElements.SMILE.count).toBe(0); + expect(groupAchievementEmojiListElements.SMILE.isSelected).toBe(false); + }); + }); + + it('그룹 달성기록에 이모지를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const leader = await usersFixture.getUser('user'); + const group = await groupFixture.createGroups(leader); + const groupCategory = await groupCategoryFixture.createCategory( + leader, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + leader, + group, + groupCategory, + ); + + const user = await usersFixture.getUser('otherUser'); + await groupFixture.addMember(group, user, UserGroupGrade.MANAGER); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.FIRE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 15, + groupAchievement, + Emoji.FIRE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.LIKE, + ); + + // when + const groupAchievementEmojiListElements = + await groupAchievementEmojiService.getGroupAchievementEmojiCount( + user, + group.id, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElements.LIKE.id).toBe(Emoji.LIKE); + expect(groupAchievementEmojiListElements.LIKE.count).toBe(10); + expect(groupAchievementEmojiListElements.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElements.FIRE.id).toBe(Emoji.FIRE); + expect(groupAchievementEmojiListElements.FIRE.count).toBe(16); + expect(groupAchievementEmojiListElements.FIRE.isSelected).toBe(true); + expect(groupAchievementEmojiListElements.SMILE.id).toBe(Emoji.SMILE); + expect(groupAchievementEmojiListElements.SMILE.count).toBe(0); + expect(groupAchievementEmojiListElements.SMILE.isSelected).toBe(false); + }); + }); + }); }); diff --git a/BE/src/group/emoji/application/group-achievement-emoji.service.ts b/BE/src/group/emoji/application/group-achievement-emoji.service.ts index f3116d92..b17cce8d 100644 --- a/BE/src/group/emoji/application/group-achievement-emoji.service.ts +++ b/BE/src/group/emoji/application/group-achievement-emoji.service.ts @@ -1,14 +1,15 @@ -import { Injectable } from '@nestjs/common'; -import { GroupAchievementEmojiRepository } from '../entities/group-achievement-emoji.repository'; -import { Transactional } from '../../../config/transaction-manager'; -import { User } from '../../../users/domain/user.domain'; -import { Emoji } from '../domain/emoji'; -import { GroupAchievementRepository } from '../../achievement/entities/group-achievement.repository'; -import { UnauthorizedAchievementException } from '../../../achievement/exception/unauthorized-achievement.exception'; -import { GroupAchievementEmoji } from '../domain/group-achievement-emoji.domain'; -import { UserGroupRepository } from '../../group/entities/user-group.repository'; -import { NoSuchGroupUserException } from '../../achievement/exception/no-such-group-user.exception'; -import { GroupAchievementEmojiResponse } from '../dto/group-achievement-emoji-response'; +import { Injectable } from "@nestjs/common"; +import { GroupAchievementEmojiRepository } from "../entities/group-achievement-emoji.repository"; +import { Transactional } from "../../../config/transaction-manager"; +import { User } from "../../../users/domain/user.domain"; +import { Emoji } from "../domain/emoji"; +import { GroupAchievementRepository } from "../../achievement/entities/group-achievement.repository"; +import { UnauthorizedAchievementException } from "../../../achievement/exception/unauthorized-achievement.exception"; +import { GroupAchievementEmoji } from "../domain/group-achievement-emoji.domain"; +import { UserGroupRepository } from "../../group/entities/user-group.repository"; +import { NoSuchGroupUserException } from "../../achievement/exception/no-such-group-user.exception"; +import { GroupAchievementEmojiResponse } from "../dto/group-achievement-emoji-response"; +import { CompositeGroupAchievementEmoji } from "../dto/composite-group-achievement-emoji"; @Injectable() export class GroupAchievementEmojiService { @@ -56,24 +57,17 @@ export class GroupAchievementEmojiService { } @Transactional({ readonly: true }) - async getGroupAchievementEmojiCount(user: User, groupAchievementId: number) { - return [ - await this.groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( - user, - groupAchievementId, - Emoji.LIKE, - ), - await this.groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( - user, - groupAchievementId, - Emoji.FIRE, - ), - await this.groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( - user, - groupAchievementId, - Emoji.SMILE, - ), - ]; + async getGroupAchievementEmojiCount( + user: User, + groupId: number, + groupAchievementId: number, + ): Promise { + await this.validateUserGroup(user, groupId); + await this.validateGroupAchievement(groupId, groupAchievementId); + return await this.groupAchievementEmojiRepository.findAllGroupAchievementEmojiMetaData( + user, + groupAchievementId, + ); } private async getGroupAchievement(groupId: number, achievementId: number) { @@ -86,6 +80,13 @@ export class GroupAchievementEmojiService { return grouopAchievement; } + private async validateGroupAchievement( + groupId: number, + achievementId: number, + ) { + await this.getGroupAchievement(groupId, achievementId); + } + private async validateUserGroup(user: User, groupId: number) { const userGroup = await this.userGroupRepository.findOneByUserCodeAndGroupId( diff --git a/BE/src/group/emoji/controller/group-achievement-emoji.controller.spec.ts b/BE/src/group/emoji/controller/group-achievement-emoji.controller.spec.ts index 82fbfd32..67da903e 100644 --- a/BE/src/group/emoji/controller/group-achievement-emoji.controller.spec.ts +++ b/BE/src/group/emoji/controller/group-achievement-emoji.controller.spec.ts @@ -14,6 +14,8 @@ import { User } from '../../../users/domain/user.domain'; import * as request from 'supertest'; import { UnauthorizedAchievementException } from '../../../achievement/exception/unauthorized-achievement.exception'; import { NoSuchGroupUserException } from '../../achievement/exception/no-such-group-user.exception'; +import { GroupAchievementEmojiListElement } from '../dto/group-achievement-emoji-list-element'; +import { CompositeGroupAchievementEmoji } from '../dto/composite-group-achievement-emoji'; describe('GroupAchievementEmojiController Test', () => { let app: INestApplication; @@ -218,4 +220,130 @@ describe('GroupAchievementEmojiController Test', () => { }); }); }); + + describe('그룹 도전기록에 이모지를 조회할 수 있다.', () => { + it('그룹 도전기록에 이모지를 성공적으로 조회하면 200을 반환한다.', async () => { + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + const gae = [ + GroupAchievementEmojiListElement.of({ + id: Emoji.SMILE, + count: '1', + isSelected: 1, + }), + GroupAchievementEmojiListElement.of({ + id: Emoji.LIKE, + count: '5', + isSelected: 1, + }), + GroupAchievementEmojiListElement.of({ + id: Emoji.FIRE, + count: '0', + isSelected: 0, + }), + ]; + const compositeGroupAchievementEmoji = + CompositeGroupAchievementEmoji.of(gae); + + when( + mockGroupAchievementEmojiService.getGroupAchievementEmojiCount( + anyOfClass(User), + 1004, + 1004, + ), + ).thenResolve(compositeGroupAchievementEmoji); + + // when + // then + return request(app.getHttpServer()) + .get(`/api/v1/groups/1004/achievements/1004/emojis`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .expect((res: request.Response) => { + expect(res.body.success).toEqual(true); + expect(res.body.data).toEqual( + compositeGroupAchievementEmoji.toResponse(), + ); + }); + }); + + it('그룹과 그룹 달성기록이 매칭되지 않으면 403을 반환한다.', async () => { + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockGroupAchievementEmojiService.getGroupAchievementEmojiCount( + anyOfClass(User), + 1004, + 1006, + ), + ).thenThrow(new UnauthorizedAchievementException()); + + // when + // then + return request(app.getHttpServer()) + .get(`/api/v1/groups/1004/achievements/1006/emojis`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(403) + .expect((res: request.Response) => { + expect(res.body.success).toEqual(false); + expect(res.body.message).toEqual('달성기록에 접근할 수 없습니다.'); + }); + }); + + it('그룹에 속하지 않은 사용자의 이모지 요청시 400을 반환한다.', async () => { + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockGroupAchievementEmojiService.getGroupAchievementEmojiCount( + anyOfClass(User), + 1004, + 1007, + ), + ).thenThrow(new NoSuchGroupUserException()); + + // when + // then + return request(app.getHttpServer()) + .get(`/api/v1/groups/1004/achievements/1007/emojis`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(400) + .expect((res: request.Response) => { + expect(res.body.success).toEqual(false); + expect(res.body.message).toEqual('그룹의 멤버가 아닙니다.'); + }); + }); + + it('잘못된 인증시 401을 반환한다.', async () => { + // given + const accessToken = 'abcd.abcd.efgh'; + + // when + // then + return request(app.getHttpServer()) + .get(`/api/v1/groups/1004/achievements/1007/emojis`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(401) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('잘못된 토큰입니다.'); + }); + }); + + it('잘못된 인증시 401을 반환한다.', async () => { + // given + const { accessToken } = + await authFixture.getExpiredAccessTokenUser('ABC'); + + // when + // then + return request(app.getHttpServer()) + .get(`/api/v1/groups/1004/achievements/1007/emojis`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(401) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('만료된 토큰입니다.'); + }); + }); + }); }); diff --git a/BE/src/group/emoji/controller/group-achievement-emoji.controller.ts b/BE/src/group/emoji/controller/group-achievement-emoji.controller.ts index 0b4d5987..140f42c3 100644 --- a/BE/src/group/emoji/controller/group-achievement-emoji.controller.ts +++ b/BE/src/group/emoji/controller/group-achievement-emoji.controller.ts @@ -15,6 +15,7 @@ import { ApiData } from '../../../common/api/api-data'; import { GroupAchievementEmojiResponse } from '../dto/group-achievement-emoji-response'; import { ParseEmojiPipe } from '../../../common/pipe/parse-emoji.pipe'; import { GroupAchievementEmojiListElement } from '../dto/group-achievement-emoji-list-element'; +import { CompositeGroupAchievementEmoji } from '../dto/composite-group-achievement-emoji'; @ApiTags('그룹 도전기록 이모지 API') @Controller('/api/v1/groups/:groupId/achievements/:achievementId/emojis') @@ -66,11 +67,12 @@ export class GroupAchievementEmojiController { @Param('groupId', ParseIntPipe) groupId: number, @Param('achievementId', ParseIntPipe) achievementId: number, ): Promise> { - const groupAchievementEmojiResponse: GroupAchievementEmojiListElement[] = + const groupAchievementEmojiResponse: CompositeGroupAchievementEmoji = await this.groupAchievementEmojiService.getGroupAchievementEmojiCount( user, + groupId, achievementId, ); - return ApiData.success(groupAchievementEmojiResponse); + return ApiData.success(groupAchievementEmojiResponse.toResponse()); } } diff --git a/BE/src/group/emoji/dto/composite-group-achievement-emoji.spec.ts b/BE/src/group/emoji/dto/composite-group-achievement-emoji.spec.ts new file mode 100644 index 00000000..b42a83ed --- /dev/null +++ b/BE/src/group/emoji/dto/composite-group-achievement-emoji.spec.ts @@ -0,0 +1,145 @@ +import { GroupAchievementEmojiListElement } from './group-achievement-emoji-list-element'; +import { CompositeGroupAchievementEmoji } from './composite-group-achievement-emoji'; +import { Emoji } from '../domain/emoji'; + +describe('CompositeGroupAchievementEmoji Test', () => { + it('CompositeGroupAchievementEmoji는 metadata가 없어도 생성된다.', () => { + // given + const metadata: GroupAchievementEmojiListElement[] = null; + + // when + const result = CompositeGroupAchievementEmoji.of(metadata); + + // then + expect(result).toBeInstanceOf(CompositeGroupAchievementEmoji); + expect(result.LIKE).toBeInstanceOf(GroupAchievementEmojiListElement); + expect(result.FIRE.id).toBe(Emoji.FIRE); + expect(result.FIRE.count).toBe(0); + expect(result.FIRE.isSelected).toBe(false); + expect(result.SMILE.id).toBe(Emoji.SMILE); + expect(result.SMILE.count).toBe(0); + expect(result.SMILE.isSelected).toBe(false); + expect(result.LIKE.id).toBe(Emoji.LIKE); + expect(result.LIKE.count).toBe(0); + expect(result.LIKE.isSelected).toBe(false); + }); + + it('CompositeGroupAchievementEmoji는 metadata가 없어도 생성된다.', () => { + // given + const metadata: GroupAchievementEmojiListElement[] = undefined; + + // when + const result = CompositeGroupAchievementEmoji.of(metadata); + + // then + expect(result).toBeInstanceOf(CompositeGroupAchievementEmoji); + expect(result.LIKE).toBeInstanceOf(GroupAchievementEmojiListElement); + expect(result.FIRE.id).toBe(Emoji.FIRE); + expect(result.FIRE.count).toBe(0); + expect(result.FIRE.isSelected).toBe(false); + expect(result.SMILE.id).toBe(Emoji.SMILE); + expect(result.SMILE.count).toBe(0); + expect(result.SMILE.isSelected).toBe(false); + expect(result.LIKE.id).toBe(Emoji.LIKE); + expect(result.LIKE.count).toBe(0); + expect(result.LIKE.isSelected).toBe(false); + }); + + it('CompositeGroupAchievementEmoji는 metadata가 없어도 생성된다.', () => { + // given + const metadata: GroupAchievementEmojiListElement[] = [ + new GroupAchievementEmojiListElement(), + ]; + metadata[0].id = Emoji.LIKE; + metadata[0].count = 5; + metadata[0].isSelected = true; + + // when + const result = CompositeGroupAchievementEmoji.of(metadata); + + // then + expect(result).toBeInstanceOf(CompositeGroupAchievementEmoji); + expect(result.LIKE).toBeInstanceOf(GroupAchievementEmojiListElement); + expect(result.FIRE.id).toBe(Emoji.FIRE); + expect(result.FIRE.count).toBe(0); + expect(result.FIRE.isSelected).toBe(false); + expect(result.SMILE.id).toBe(Emoji.SMILE); + expect(result.SMILE.count).toBe(0); + expect(result.SMILE.isSelected).toBe(false); + expect(result.LIKE.id).toBe(Emoji.LIKE); + expect(result.LIKE.count).toBe(5); + expect(result.LIKE.isSelected).toBe(true); + }); + + it('CompositeGroupAchievementEmoji는 metadata가 없어도 생성된다.', () => { + // given + const metadata: GroupAchievementEmojiListElement[] = [ + new GroupAchievementEmojiListElement(), + new GroupAchievementEmojiListElement(), + new GroupAchievementEmojiListElement(), + ]; + metadata[0].id = Emoji.LIKE; + metadata[0].count = 5; + metadata[0].isSelected = true; + metadata[1].id = Emoji.FIRE; + metadata[1].count = 3; + metadata[1].isSelected = false; + metadata[2].id = Emoji.SMILE; + metadata[2].count = 1; + metadata[2].isSelected = false; + + // when + const result = CompositeGroupAchievementEmoji.of(metadata); + + // then + expect(result).toBeInstanceOf(CompositeGroupAchievementEmoji); + expect(result.FIRE.id).toBe(Emoji.FIRE); + expect(result.FIRE.count).toBe(3); + expect(result.FIRE.isSelected).toBe(false); + expect(result.SMILE.id).toBe(Emoji.SMILE); + expect(result.SMILE.count).toBe(1); + expect(result.SMILE.isSelected).toBe(false); + expect(result.LIKE.id).toBe(Emoji.LIKE); + expect(result.LIKE.count).toBe(5); + expect(result.LIKE.isSelected).toBe(true); + }); + + it('toResponse는 일정한 이모지 순서로 반환한다.', () => { + // given + const metadata: GroupAchievementEmojiListElement[] = [ + new GroupAchievementEmojiListElement(), + new GroupAchievementEmojiListElement(), + new GroupAchievementEmojiListElement(), + ]; + metadata[0].id = Emoji.LIKE; + metadata[0].count = 5; + metadata[0].isSelected = true; + metadata[1].id = Emoji.FIRE; + metadata[1].count = 3; + metadata[1].isSelected = false; + metadata[2].id = Emoji.SMILE; + metadata[2].count = 1; + metadata[2].isSelected = false; + + // when + const result = CompositeGroupAchievementEmoji.of(metadata).toResponse(); + + // then + expect(result[0].id).toBe(Emoji.LIKE); + expect(result[1].id).toBe(Emoji.FIRE); + expect(result[2].id).toBe(Emoji.SMILE); + }); + + it('toResponse는 일정한 이모지 순서로 반환한다.', () => { + // given + const metadata: GroupAchievementEmojiListElement[] = null; + + // when + const result = CompositeGroupAchievementEmoji.of(metadata).toResponse(); + + // then + expect(result[0].id).toBe(Emoji.LIKE); + expect(result[1].id).toBe(Emoji.FIRE); + expect(result[2].id).toBe(Emoji.SMILE); + }); +}); diff --git a/BE/src/group/emoji/dto/composite-group-achievement-emoji.ts b/BE/src/group/emoji/dto/composite-group-achievement-emoji.ts new file mode 100644 index 00000000..d33d9f4a --- /dev/null +++ b/BE/src/group/emoji/dto/composite-group-achievement-emoji.ts @@ -0,0 +1,26 @@ +import { GroupAchievementEmojiListElement } from './group-achievement-emoji-list-element'; +import { Emoji } from '../domain/emoji'; + +export class CompositeGroupAchievementEmoji { + LIKE: GroupAchievementEmojiListElement; + FIRE: GroupAchievementEmojiListElement; + SMILE: GroupAchievementEmojiListElement; + + static of(metatdata: GroupAchievementEmojiListElement[]) { + const response = new CompositeGroupAchievementEmoji(); + response.LIKE = + metatdata?.find((meta) => meta.id === Emoji.LIKE) || + GroupAchievementEmojiListElement.noEmoji(Emoji.LIKE); + response.FIRE = + metatdata?.find((meta) => meta.id === Emoji.FIRE) || + GroupAchievementEmojiListElement.noEmoji(Emoji.FIRE); + response.SMILE = + metatdata?.find((meta) => meta.id === Emoji.SMILE) || + GroupAchievementEmojiListElement.noEmoji(Emoji.SMILE); + return response; + } + + toResponse(): GroupAchievementEmojiListElement[] { + return [this.LIKE, this.FIRE, this.SMILE]; + } +} diff --git a/BE/src/group/emoji/dto/group-achievement-emoji-list-element.ts b/BE/src/group/emoji/dto/group-achievement-emoji-list-element.ts index 89d0a30d..25bebba6 100644 --- a/BE/src/group/emoji/dto/group-achievement-emoji-list-element.ts +++ b/BE/src/group/emoji/dto/group-achievement-emoji-list-element.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IGroupAchievementEmojiMetadata } from './index'; import { Emoji } from '../domain/emoji'; export class GroupAchievementEmojiListElement { @@ -12,14 +13,22 @@ export class GroupAchievementEmojiListElement { count: number; static of( - id: Emoji, - count: number, - isSelected: boolean, + metatdata: IGroupAchievementEmojiMetadata, ): GroupAchievementEmojiListElement { const response = new GroupAchievementEmojiListElement(); - response.id = id; - response.count = count; - response.isSelected = isSelected; + response.id = metatdata.id; + response.count = isNaN(parseInt(metatdata.count)) + ? 0 + : parseInt(metatdata.count); + response.isSelected = metatdata.isSelected == 1; + return response; + } + + static noEmoji(emoji: Emoji): GroupAchievementEmojiListElement { + const response = new GroupAchievementEmojiListElement(); + response.id = emoji; + response.count = 0; + response.isSelected = false; return response; } } diff --git a/BE/src/group/emoji/dto/index.ts b/BE/src/group/emoji/dto/index.ts index 7201b052..c3a78162 100644 --- a/BE/src/group/emoji/dto/index.ts +++ b/BE/src/group/emoji/dto/index.ts @@ -1,3 +1,5 @@ export interface IGroupAchievementEmojiMetadata { + id: string; count: string; + isSelected: number | string; } diff --git a/BE/src/group/emoji/entities/group-achievement-emoji.entity.ts b/BE/src/group/emoji/entities/group-achievement-emoji.entity.ts index fdf6ccd4..7b50b6d4 100644 --- a/BE/src/group/emoji/entities/group-achievement-emoji.entity.ts +++ b/BE/src/group/emoji/entities/group-achievement-emoji.entity.ts @@ -16,11 +16,11 @@ export class GroupAchievementEmojiEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => GroupAchievementEntity) + @ManyToOne(() => GroupAchievementEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'group_achievement_id', referencedColumnName: 'id' }) groupAchievement: GroupAchievementEntity; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/group/emoji/entities/group-achievement-emoji.repository.spec.ts b/BE/src/group/emoji/entities/group-achievement-emoji.repository.spec.ts index f6ab753d..bfb40345 100644 --- a/BE/src/group/emoji/entities/group-achievement-emoji.repository.spec.ts +++ b/BE/src/group/emoji/entities/group-achievement-emoji.repository.spec.ts @@ -303,4 +303,369 @@ describe('GroupAchievementEmojiRepository Test', () => { }); }); }); + + describe('findGroupAchievementEmojiMetaData는 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', () => { + it('findGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.FIRE, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + Emoji.FIRE, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement?.count).toEqual(10); + expect(groupAchievementEmojiListElement?.isSelected).toEqual(false); + expect(groupAchievementEmojiListElement?.id).toEqual(Emoji.FIRE); + }); + }); + + it('findGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + Emoji.FIRE, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement.count).toEqual(0); + expect(groupAchievementEmojiListElement.isSelected).toEqual(false); + expect(groupAchievementEmojiListElement.id).toEqual(Emoji.FIRE); + }); + }); + + it('findGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.FIRE, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + Emoji.FIRE, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement.count).toBe(1); + expect(groupAchievementEmojiListElement.id).toBe(Emoji.FIRE); + expect(groupAchievementEmojiListElement.isSelected).toBe(true); + }); + }); + + it('findGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.FIRE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.FIRE, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + Emoji.FIRE, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement?.count).toEqual(11); + expect(groupAchievementEmojiListElement?.isSelected).toEqual(true); + expect(groupAchievementEmojiListElement?.id).toEqual(Emoji.FIRE); + }); + }); + }); + + describe('findAllGroupAchievementEmojiMetaData는 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', () => { + it('findAllGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터들을 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findAllGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement.LIKE.count).toEqual(0); + expect(groupAchievementEmojiListElement.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.LIKE.id).toEqual(Emoji.LIKE); + expect(groupAchievementEmojiListElement.SMILE.count).toEqual(0); + expect(groupAchievementEmojiListElement.SMILE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.SMILE.id).toEqual(Emoji.SMILE); + expect(groupAchievementEmojiListElement.FIRE.count).toEqual(0); + expect(groupAchievementEmojiListElement.FIRE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.FIRE.id).toEqual(Emoji.FIRE); + }); + }); + + it('findAllGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.FIRE, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findAllGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement.LIKE.count).toEqual(0); + expect(groupAchievementEmojiListElement.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.LIKE.id).toEqual(Emoji.LIKE); + expect(groupAchievementEmojiListElement.SMILE.count).toEqual(0); + expect(groupAchievementEmojiListElement.SMILE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.SMILE.id).toEqual(Emoji.SMILE); + expect(groupAchievementEmojiListElement.FIRE.count).toEqual(10); + expect(groupAchievementEmojiListElement.FIRE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.FIRE.id).toEqual(Emoji.FIRE); + }); + }); + + it('findAllGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.LIKE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 100, + groupAchievement, + Emoji.SMILE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 250, + groupAchievement, + Emoji.FIRE, + ); + + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findAllGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement.LIKE.count).toEqual(10); + expect(groupAchievementEmojiListElement.LIKE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.LIKE.id).toEqual(Emoji.LIKE); + expect(groupAchievementEmojiListElement.SMILE.count).toEqual(100); + expect(groupAchievementEmojiListElement.SMILE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.SMILE.id).toEqual(Emoji.SMILE); + expect(groupAchievementEmojiListElement.FIRE.count).toEqual(250); + expect(groupAchievementEmojiListElement.FIRE.isSelected).toBe(false); + expect(groupAchievementEmojiListElement.FIRE.id).toEqual(Emoji.FIRE); + }); + }); + + it('findAllGroupAchievementEmojiMetaData를 실행하면 그룹 달성기록에 이모지의 메타데이터를 조회할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroups(user); + const groupCategory = await groupCategoryFixture.createCategory( + user, + group, + ); + const groupAchievement = + await groupAchievementFixture.createGroupAchievement( + user, + group, + groupCategory, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 10, + groupAchievement, + Emoji.LIKE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.LIKE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 15, + groupAchievement, + Emoji.SMILE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.SMILE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmojis( + 25, + groupAchievement, + Emoji.FIRE, + ); + + await groupAchievementEmojiFixture.createGroupAchievementEmoji( + user, + groupAchievement, + Emoji.FIRE, + ); + + console.log(user); + // when + const groupAchievementEmojiListElement = + await groupAchievementEmojiRepository.findAllGroupAchievementEmojiMetaData( + user, + groupAchievement.id, + ); + + // then + expect(groupAchievementEmojiListElement).toBeDefined(); + expect(groupAchievementEmojiListElement.SMILE.count).toEqual(16); + expect(groupAchievementEmojiListElement.SMILE.isSelected).toBe(true); + expect(groupAchievementEmojiListElement.SMILE.id).toEqual(Emoji.SMILE); + expect(groupAchievementEmojiListElement.FIRE.count).toEqual(26); + expect(groupAchievementEmojiListElement.FIRE.isSelected).toBe(true); + expect(groupAchievementEmojiListElement.FIRE.id).toEqual(Emoji.FIRE); + expect(groupAchievementEmojiListElement.LIKE.count).toEqual(11); + expect(groupAchievementEmojiListElement.LIKE.isSelected).toBe(true); + expect(groupAchievementEmojiListElement.LIKE.id).toEqual(Emoji.LIKE); + }); + }); + }); }); diff --git a/BE/src/group/emoji/entities/group-achievement-emoji.repository.ts b/BE/src/group/emoji/entities/group-achievement-emoji.repository.ts index 35c41cd3..9f424a64 100644 --- a/BE/src/group/emoji/entities/group-achievement-emoji.repository.ts +++ b/BE/src/group/emoji/entities/group-achievement-emoji.repository.ts @@ -6,6 +6,7 @@ import { User } from '../../../users/domain/user.domain'; import { Emoji } from '../domain/emoji'; import { IGroupAchievementEmojiMetadata } from '../dto'; import { GroupAchievementEmojiListElement } from '../dto/group-achievement-emoji-list-element'; +import { CompositeGroupAchievementEmoji } from '../dto/composite-group-achievement-emoji'; @CustomRepository(GroupAchievementEmojiEntity) export class GroupAchievementEmojiRepository extends TransactionalRepository { @@ -56,7 +57,22 @@ export class GroupAchievementEmojiRepository extends TransactionalRepository(); - const userEmoji = await this.findGroupAchievementEmojiByUser( - groupAchievementId, - user, - emoji, - ); - - return GroupAchievementEmojiListElement.of( - emoji, - isNaN(parseInt(metadata?.count)) ? 0 : parseInt(metadata.count), - userEmoji != null, - ); + return metadata + ? GroupAchievementEmojiListElement.of(metadata) + : GroupAchievementEmojiListElement.noEmoji(emoji); } - async findGroupAchievementEmojiByUser( - groupAchievementId: number, + async findAllGroupAchievementEmojiMetaData( user: User, - emoji: Emoji, - ) { - const groupAchievementEmojiEntity = await this.repository.findOne({ - where: { - user: { - id: user.id, - }, - groupAchievement: { - id: groupAchievementId, - }, - emoji: emoji, - }, - }); + groupAchievementId: number, + ): Promise { + const metadata = await this.repository + .createQueryBuilder('gae') + .select('gae.emoji', 'id') + .addSelect('COALESCE(COUNT(gae.id), 0)', 'count') + .addSelect( + '(SELECT CASE WHEN EXISTS (SELECT 1 FROM group_achievement_emoji ' + + ' WHERE user_id = :userId AND emoji = gae.emoji and group_achievement_id = :groupAchievementId) ' + + 'THEN 1 ELSE 0 END AS result)', + 'isSelected', + ) + .setParameters({ + groupAchievementId: groupAchievementId, + userId: user.id, + }) + .where('gae.group_achievement_id = :groupAchievementId', { + groupAchievementId, + }) + .groupBy('gae.groupAchievement') + .addGroupBy('gae.emoji') + .getRawMany(); - return groupAchievementEmojiEntity?.toModel(); + const groupAchievementEmojiListElements = metadata.map((m) => + GroupAchievementEmojiListElement.of(m), + ); + return CompositeGroupAchievementEmoji.of(groupAchievementEmojiListElements); } } diff --git a/BE/src/group/group/application/group-code-generator.spec.ts b/BE/src/group/group/application/group-code-generator.spec.ts new file mode 100644 index 00000000..7d2ba553 --- /dev/null +++ b/BE/src/group/group/application/group-code-generator.spec.ts @@ -0,0 +1,36 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { GroupRepository } from '../entities/group.repository'; +import { GroupCodeGenerator } from './group-code-generator'; +import { configServiceModuleOptions } from '../../../config/config'; +import { typeOrmModuleOptions } from '../../../config/typeorm'; +import { CustomTypeOrmModule } from '../../../config/typeorm/custom-typeorm.module'; + +describe('GroupCodeGenerator test', () => { + let groupRepository: GroupRepository; + let groupCodeGenerator: GroupCodeGenerator; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(configServiceModuleOptions), + TypeOrmModule.forRootAsync(typeOrmModuleOptions), + CustomTypeOrmModule.forCustomRepository([GroupRepository]), + ], + }).compile(); + + groupRepository = module.get(GroupRepository); + groupCodeGenerator = new GroupCodeGenerator(groupRepository); + }); + + it('사용할 모듈이 정의가 되어있어야한다.', () => { + expect(groupRepository).toBeDefined(); + expect(groupCodeGenerator).toBeDefined(); + }); + + test('영대문자, 숫자를 포함한 임의의 7글자 문자열을 생성한다.', async () => { + const groupCode = await groupCodeGenerator.generate(); + expect(groupCode.length).toEqual(7); + }); +}); diff --git a/BE/src/group/group/application/group-code-generator.ts b/BE/src/group/group/application/group-code-generator.ts new file mode 100644 index 00000000..76f85a23 --- /dev/null +++ b/BE/src/group/group/application/group-code-generator.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { GroupRepository } from '../entities/group.repository'; + +@Injectable() +export class GroupCodeGenerator { + private readonly CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + constructor(private groupRepository: GroupRepository) {} + + async generate() { + let groupCode = this.makeGroupCode(); + + while (await this.isDuplicate(groupCode)) { + groupCode = this.makeGroupCode(); + } + + return groupCode; + } + + private isDuplicate(groupCode: string): Promise { + return this.groupRepository.existByGroupCode(groupCode); + } + + private makeGroupCode() { + let groupCode = ''; + for (let i = 0; i < 7; i++) { + const randomIndex = Math.floor(Math.random() * this.CHARACTERS.length); + groupCode += this.CHARACTERS.charAt(randomIndex); + } + return groupCode; + } +} diff --git a/BE/src/group/group/application/group.service.spec.ts b/BE/src/group/group/application/group.service.spec.ts index 785898bc..d5d4698b 100644 --- a/BE/src/group/group/application/group.service.spec.ts +++ b/BE/src/group/group/application/group.service.spec.ts @@ -27,10 +27,14 @@ import { InviteGroupRequest } from '../dto/invite-group-request.dto'; import { InvitePermissionDeniedException } from '../exception/invite-permission-denied.exception'; import { AssignGradeRequest } from '../dto/assign-grade-request.dto'; import { OnlyLeaderAllowedAssignGradeException } from '../exception/only-leader-allowed-assign-grade.exception'; +import { JoinGroupRequest } from '../dto/join-group-request.dto'; +import { NoSucGroupException } from '../exception/no-such-group.exception'; +import { DuplicatedJoinException } from '../exception/duplicated-join.exception'; describe('GroupSerivce Test', () => { let groupService: GroupService; let userGroupRepository: UserGroupRepository; + let groupRepository: GroupRepository; let usersFixture: UsersFixture; let groupFixture: GroupFixture; let groupAchievementFixture: GroupAchievementFixture; @@ -57,6 +61,7 @@ describe('GroupSerivce Test', () => { groupService = module.get(GroupService); userGroupRepository = module.get(UserGroupRepository); + groupRepository = module.get(GroupRepository); usersFixture = module.get(UsersFixture); groupFixture = module.get(GroupFixture); groupAchievementFixture = module.get( @@ -82,6 +87,8 @@ describe('GroupSerivce Test', () => { ); // when const groupResponse = await groupService.create(user, createGroupRequest); + + // then const userGroup = await userGroupRepository.repository.findOne({ where: { group: { id: groupResponse.id }, user: { id: user.id } }, relations: { @@ -89,13 +96,13 @@ describe('GroupSerivce Test', () => { user: true, }, }); - - // then + const group = await groupRepository.findById(groupResponse.id); expect(groupResponse.name).toEqual('Group Name'); expect(groupResponse.avatarUrl).toEqual('avatarUrl'); expect(userGroup.group.id).toEqual(groupResponse.id); expect(userGroup.user.id).toEqual(user.id); expect(userGroup.grade).toEqual(UserGroupGrade.LEADER); + expect(group.groupCode.length).toEqual(7); }); }); @@ -477,4 +484,56 @@ describe('GroupSerivce Test', () => { ).rejects.toThrow(OnlyLeaderAllowedAssignGradeException); }); }); + + test('그룹 코드를 이용해서 그룹에 참여할 수 있디.', async () => { + // given + await transactionTest(dataSource, async () => { + // given + const user1 = await usersFixture.getUser('ABC'); + const user2 = await usersFixture.getUser('GHI'); + const group = await groupFixture.createGroup('Test Group', user1); + + // when + const joinGroupResponse = await groupService.join( + user2, + new JoinGroupRequest(group.groupCode), + ); + + // then + expect(joinGroupResponse.groupCode).toEqual(group.groupCode); + expect(joinGroupResponse.userCode).toEqual(user2.userCode); + }); + }); + + test('존해하지 않는 그룹코드에는 NoSucGroupException를 던진다.', async () => { + // given + await transactionTest(dataSource, async () => { + // given + const user1 = await usersFixture.getUser('ABC'); + const user2 = await usersFixture.getUser('GHI'); + + // when + // then + await expect( + groupService.join(user2, new JoinGroupRequest('INVALID_GROUP_CODE')), + ).rejects.toThrow(NoSucGroupException); + }); + }); + + test('이미 가입된 그룹 가입 요청에는 NoSucGroupException를 던진다.', 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.join(user2, new JoinGroupRequest(group.groupCode)), + ).rejects.toThrow(DuplicatedJoinException); + }); + }); }); diff --git a/BE/src/group/group/application/group.service.ts b/BE/src/group/group/application/group.service.ts index adf0359a..ace527c8 100644 --- a/BE/src/group/group/application/group.service.ts +++ b/BE/src/group/group/application/group.service.ts @@ -21,6 +21,11 @@ import { GroupUserListResponse } from '../dto/group-user-list-response'; import { AssignGradeRequest } from '../dto/assign-grade-request.dto'; import { AssignGradeResponse } from '../dto/assign-grade-response.dto'; import { OnlyLeaderAllowedAssignGradeException } from '../exception/only-leader-allowed-assign-grade.exception'; +import { NoSucGroupException } from '../exception/no-such-group.exception'; +import { JoinGroupRequest } from '../dto/join-group-request.dto'; +import { JoinGroupResponse } from '../dto/join-group-response.dto'; +import { DuplicatedJoinException } from '../exception/duplicated-join.exception'; +import { GroupCodeGenerator } from './group-code-generator'; @Injectable() export class GroupService { @@ -29,6 +34,7 @@ export class GroupService { private readonly userRepository: UserRepository, private readonly userGroupRepository: UserGroupRepository, private readonly groupAvatarHolder: GroupAvatarHolder, + private readonly groupCodeGenerator: GroupCodeGenerator, ) {} @Transactional() @@ -37,6 +43,8 @@ export class GroupService { group.addMember(user, UserGroupGrade.LEADER); if (!group.avatarUrl) group.assignAvatarUrl(this.groupAvatarHolder.getUrl()); + const groupCode = await this.groupCodeGenerator.generate(); + group.assignGroupCode(groupCode); return GroupResponse.from(await this.groupRepository.saveGroup(group)); } @@ -107,6 +115,20 @@ export class GroupService { await this.userGroupRepository.saveUserGroup(userGroup), ); } + @Transactional() + async join(user: User, joinGroupRequest: JoinGroupRequest) { + const group = await this.groupRepository.findByGroupCode( + joinGroupRequest.groupCode, + ); + if (!group) throw new NoSucGroupException(); + await this.checkDuplicatedJoin(user.userCode, group.id); + + const saved = await this.userGroupRepository.saveUserGroup( + new UserGroup(user, group, UserGroupGrade.PARTICIPANT), + ); + + return new JoinGroupResponse(saved.group.groupCode, saved.user.userCode); + } private async getUserGroup(userId: number, groupId: number) { const userGroup = await this.userGroupRepository.findOneByUserIdAndGroupId( @@ -126,6 +148,15 @@ export class GroupService { if (userGroup) throw new DuplicatedInviteException(); } + private async checkDuplicatedJoin(userCode: string, groupId: number) { + const userGroup = + await this.userGroupRepository.findOneByUserCodeAndGroupId( + userCode, + groupId, + ); + if (userGroup) throw new DuplicatedJoinException(); + } + private checkPermission(userGroup: UserGroup) { if ( userGroup.grade !== UserGroupGrade.LEADER && diff --git a/BE/src/group/group/controller/group.controller.spec.ts b/BE/src/group/group/controller/group.controller.spec.ts index 80ab0058..b69dacfe 100644 --- a/BE/src/group/group/controller/group.controller.spec.ts +++ b/BE/src/group/group/controller/group.controller.spec.ts @@ -33,6 +33,10 @@ import { GroupUserListResponse } from '../dto/group-user-list-response'; import { AssignGradeResponse } from '../dto/assign-grade-response.dto'; import { AssignGradeRequest } from '../dto/assign-grade-request.dto'; import { OnlyLeaderAllowedAssignGradeException } from '../exception/only-leader-allowed-assign-grade.exception'; +import { JoinGroupRequest } from '../dto/join-group-request.dto'; +import { JoinGroupResponse } from '../dto/join-group-response.dto'; +import { NoSucGroupException } from '../exception/no-such-group.exception'; +import { DuplicatedJoinException } from '../exception/duplicated-join.exception'; describe('GroupController', () => { let app: INestApplication; @@ -153,6 +157,7 @@ describe('GroupController', () => { id: 1, name: 'Test Group1', avatarUrl: 'avatarUrl1', + groupCode: 'GABACD1', continued: 2, lastChallenged: '2023-11-29T21:58:402Z', grade: UserGroupGrade.LEADER, @@ -161,6 +166,7 @@ describe('GroupController', () => { id: 2, name: 'Test Group2', avatarUrl: 'avatarUrl2', + groupCode: 'GABACD2', continued: 3, lastChallenged: null, grade: UserGroupGrade.PARTICIPANT, @@ -183,6 +189,7 @@ describe('GroupController', () => { data: [ { avatarUrl: 'avatarUrl1', + groupCode: 'GABACD1', continued: 2, id: 1, lastChallenged: '2023-11-29T21:58:402Z', @@ -191,6 +198,7 @@ describe('GroupController', () => { }, { avatarUrl: 'avatarUrl2', + groupCode: 'GABACD2', continued: 3, id: 2, lastChallenged: null, @@ -694,4 +702,105 @@ describe('GroupController', () => { }); }); }); + describe('그룹 코드를 통해서 그룹에 참여할 수 있다.', () => { + it('성공 시 200을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + const joinGroupResponse = new JoinGroupResponse('GWEGAQ1', 'ABCDEF2'); + + when( + mockGroupService.join(anyOfClass(User), anyOfClass(JoinGroupRequest)), + ).thenResolve(joinGroupResponse); + + // when + // then + return request(app.getHttpServer()) + .post('/api/v1/groups/participation') + .set('Authorization', `Bearer ${accessToken}`) + .send(new JoinGroupRequest('GWEGAQ1')) + .expect(200) + .expect((res: request.Response) => { + expect(res.body.success).toEqual(true); + expect(res.body.data).toEqual({ + groupCode: 'GWEGAQ1', + userCode: 'ABCDEF2', + }); + }); + }); + it('존재 하지 않는 그룹인 경우 404을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockGroupService.join(anyOfClass(User), anyOfClass(JoinGroupRequest)), + ).thenThrow(new NoSucGroupException()); + + // when + // then + return request(app.getHttpServer()) + .post('/api/v1/groups/participation') + .set('Authorization', `Bearer ${accessToken}`) + .send(new JoinGroupRequest('GWEGAQ1')) + .expect(404) + .expect((res: request.Response) => { + expect(res.body.success).toEqual(false); + expect(res.body.message).toEqual('존재하지 않는 그룹 입니다.'); + }); + }); + it('이미 가입된 그룹인 경우 400을 반환한다.', async () => { + // given + const { accessToken } = await authFixture.getAuthenticatedUser('ABC'); + + when( + mockGroupService.join(anyOfClass(User), anyOfClass(JoinGroupRequest)), + ).thenThrow(new DuplicatedJoinException()); + + // when + // then + return request(app.getHttpServer()) + .post('/api/v1/groups/participation') + .set('Authorization', `Bearer ${accessToken}`) + .send(new JoinGroupRequest('GWEGAQ1')) + .expect(400) + .expect((res: request.Response) => { + expect(res.body.success).toEqual(false); + expect(res.body.message).toEqual('이미 그룹의 그룹원 입니다.'); + }); + }); + + it('잘못된 인증시 401을 반환한다.', async () => { + // given + const accessToken = 'abcd.abcd.efgh'; + + // when + // then + return request(app.getHttpServer()) + .post('/api/v1/groups/participation') + .set('Authorization', `Bearer ${accessToken}`) + .send(new JoinGroupRequest('GWEGAQ1')) + .expect(401) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('잘못된 토큰입니다.'); + }); + }); + it('만료된 인증정보에 401을 반환한다.', async () => { + // given + const { accessToken } = + await authFixture.getExpiredAccessTokenUser('ABC'); + + // when + // then + return request(app.getHttpServer()) + .post('/api/v1/groups/participation') + .set('Authorization', `Bearer ${accessToken}`) + .send(new JoinGroupRequest('GWEGAQ1')) + .expect(401) + .expect((res: request.Response) => { + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('만료된 토큰입니다.'); + }); + }); + }); }); diff --git a/BE/src/group/group/controller/group.controller.ts b/BE/src/group/group/controller/group.controller.ts index 3e806b9a..9f454e86 100644 --- a/BE/src/group/group/controller/group.controller.ts +++ b/BE/src/group/group/controller/group.controller.ts @@ -31,6 +31,8 @@ import { InviteGroupResponse } from '../dto/invite-group-response'; import { GroupUserListResponse } from '../dto/group-user-list-response'; import { AssignGradeRequest } from '../dto/assign-grade-request.dto'; import { AssignGradeResponse } from '../dto/assign-grade-response.dto'; +import { JoinGroupRequest } from '../dto/join-group-request.dto'; +import { JoinGroupResponse } from '../dto/join-group-response.dto'; @Controller('/api/v1/groups') @ApiTags('그룹 API') @@ -95,6 +97,27 @@ export class GroupController { ); } + @ApiOperation({ + summary: '그룹 가입 API', + description: '그룹 코드를 입력해서 그룹에 가입한다.', + }) + @ApiOkResponse({ + description: '그룹 가입', + type: JoinGroupResponse, + }) + @ApiBearerAuth('accessToken') + @Post('participation') + @UseGuards(AccessTokenGuard) + @HttpCode(HttpStatus.OK) + async join( + @AuthenticatedUser() user: User, + @Body() joinGroupRequest: JoinGroupRequest, + ) { + return ApiData.success( + await this.groupService.join(user, joinGroupRequest), + ); + } + @ApiOperation({ summary: '그룹원 초대 API', description: '그룹에 사용자를 초대한다.', diff --git a/BE/src/group/group/domain/group.domain.ts b/BE/src/group/group/domain/group.domain.ts index 09aeaf42..257208b2 100644 --- a/BE/src/group/group/domain/group.domain.ts +++ b/BE/src/group/group/domain/group.domain.ts @@ -6,6 +6,7 @@ export class Group { id: number; name: string; avatarUrl: string; + groupCode: string; userGroups: UserGroup[]; constructor(name: string, avatarUrl: string) { @@ -21,4 +22,8 @@ export class Group { assignAvatarUrl(url: string) { this.avatarUrl = url; } + + assignGroupCode(groupCode: string) { + this.groupCode = groupCode; + } } diff --git a/BE/src/group/group/dto/group-preview.dto.ts b/BE/src/group/group/dto/group-preview.dto.ts index ab75ed6a..255d8c3f 100644 --- a/BE/src/group/group/dto/group-preview.dto.ts +++ b/BE/src/group/group/dto/group-preview.dto.ts @@ -10,6 +10,8 @@ export class GroupPreview { name: string; @ApiProperty({ description: '그룹 로고 Url' }) avatarUrl: string; + @ApiProperty({ description: '그룹 그룹코드' }) + groupCode: string; @ApiProperty({ description: '그룹 달성기록 총 회수' }) continued: number; @ApiProperty({ description: '그룹 달성기록 최근 등록 일자' }) @@ -25,6 +27,7 @@ export class GroupPreview { this.id = groupPreview.id; this.name = groupPreview.name; this.avatarUrl = groupPreview.avatarUrl; + this.groupCode = groupPreview.groupCode; this.continued = parseInt(groupPreview.continued); this.lastChallenged = dateFormat(groupPreview.lastChallenged); this.grade = UserGroupGrade[groupPreview.grade]; diff --git a/BE/src/group/group/dto/join-group-request.dto.ts b/BE/src/group/group/dto/join-group-request.dto.ts new file mode 100644 index 00000000..505f3f26 --- /dev/null +++ b/BE/src/group/group/dto/join-group-request.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmptyString } from '../../../config/config/validation-decorator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class JoinGroupRequest { + @IsNotEmptyString({ message: '잘못된 유저 코드입니다.' }) + @ApiProperty({ description: '가입할 그룹의 그룹코드' }) + groupCode: string; + + constructor(groupCode: string) { + this.groupCode = groupCode; + } +} diff --git a/BE/src/group/group/dto/join-group-response.dto.ts b/BE/src/group/group/dto/join-group-response.dto.ts new file mode 100644 index 00000000..90c8bd52 --- /dev/null +++ b/BE/src/group/group/dto/join-group-response.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class JoinGroupResponse { + @ApiProperty({ description: '그룹 코드' }) + groupCode: string; + @ApiProperty({ description: '초대된 유저의 유저 코드' }) + userCode: string; + + constructor(groupCode: string, userCode: string) { + this.groupCode = groupCode; + this.userCode = userCode; + } +} diff --git a/BE/src/group/group/entities/group.entity.spec.ts b/BE/src/group/group/entities/group.entity.spec.ts index 8526195e..1643322c 100644 --- a/BE/src/group/group/entities/group.entity.spec.ts +++ b/BE/src/group/group/entities/group.entity.spec.ts @@ -10,6 +10,7 @@ describe('GroupEntity Test', () => { it('userGroups를 가지고 있지 않은 경우에 변환이 가능하다.', () => { // given const group = new Group('group', 'avatarUrl'); + group.assignGroupCode('ABCDEF1'); // when const groupEntity = GroupEntity.from(group); @@ -18,6 +19,7 @@ describe('GroupEntity Test', () => { expect(groupEntity).toBeInstanceOf(GroupEntity); expect(groupEntity.name).toEqual('group'); expect(groupEntity.avatarUrl).toEqual('avatarUrl'); + expect(groupEntity.groupCode).toEqual('ABCDEF1'); expect(groupEntity.userGroups).toEqual(undefined); }); @@ -45,16 +47,19 @@ describe('GroupEntity Test', () => { describe('toModel으로 GroupEntity를 Group 도메인 객체로 변환할 수 있다.', () => { it('userGroups를 가지고 있지 않은 경우에 변환이 가능하다.', () => { // given - const groupEntity = GroupEntity.from(new Group('group', 'avatarUrl')); + const group = new Group('group', 'avatarUrl'); + group.assignGroupCode('ABCDEF1'); + const groupEntity = GroupEntity.from(group); // when - const group = groupEntity.toModel(); + const domain = groupEntity.toModel(); // then - expect(group).toBeInstanceOf(Group); - expect(group.name).toEqual('group'); - expect(group.avatarUrl).toEqual('avatarUrl'); - expect(group.userGroups.length).toEqual(0); + expect(domain).toBeInstanceOf(Group); + expect(domain.name).toEqual('group'); + expect(domain.groupCode).toEqual('ABCDEF1'); + expect(domain.avatarUrl).toEqual('avatarUrl'); + expect(domain.userGroups.length).toEqual(0); }); it('userGroups를 가지고 있는 경우에 변환이 가능하다.', () => { diff --git a/BE/src/group/group/entities/group.entity.ts b/BE/src/group/group/entities/group.entity.ts index caff741c..2e36ce0b 100644 --- a/BE/src/group/group/entities/group.entity.ts +++ b/BE/src/group/group/entities/group.entity.ts @@ -16,6 +16,9 @@ export class GroupEntity extends BaseTimeEntity { @Column({ type: 'varchar', length: 100, nullable: true }) avatarUrl: string; + @Column({ type: 'varchar', length: 7, nullable: true }) + groupCode: string; + @OneToMany(() => UserGroupEntity, (userGroup) => userGroup.group, { cascade: true, }) @@ -30,6 +33,7 @@ export class GroupEntity extends BaseTimeEntity { toModel(): Group { const group = new Group(this.name, this.avatarUrl); group.id = this.id; + group.groupCode = this.groupCode; group.userGroups = this.userGroups ? this.userGroups.map((ug) => ug.toModel()) : []; @@ -41,6 +45,7 @@ export class GroupEntity extends BaseTimeEntity { const groupEntity = new GroupEntity(); groupEntity.id = group.id; groupEntity.name = group.name; + groupEntity.groupCode = group.groupCode; groupEntity.userGroups = group.userGroups.length ? group.userGroups.map((ug) => UserGroupEntity.strictFrom(ug)) : undefined; @@ -53,6 +58,7 @@ export class GroupEntity extends BaseTimeEntity { const groupEntity = new GroupEntity(); groupEntity.id = group.id; groupEntity.name = group.name; + groupEntity.groupCode = group.groupCode; groupEntity.avatarUrl = group.avatarUrl; return groupEntity; } diff --git a/BE/src/group/group/entities/group.repository.spec.ts b/BE/src/group/group/entities/group.repository.spec.ts index 912b94c7..05fd7fc2 100644 --- a/BE/src/group/group/entities/group.repository.spec.ts +++ b/BE/src/group/group/entities/group.repository.spec.ts @@ -54,7 +54,7 @@ describe('GroupRepository Test', () => { }); }); - test('그룹 단건 조회를 할 수 있다.', async () => { + test('id로 그룹 단건 조회를 할 수 있다.', async () => { await transactionTest(dataSource, async () => { // given const user = await usersFixture.getUser('ABC'); @@ -69,6 +69,40 @@ describe('GroupRepository Test', () => { }); }); + test('groupCode로 그룹 단건 조회를 할 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroup('Test Group', user); + + // when + const savedGroup = await groupRepository.findByGroupCode(group.groupCode); + + // then + expect(savedGroup.name).toEqual('Test Group'); + expect(savedGroup.id).toEqual(group.id); + }); + }); + + test('groupCode로 group 존재 유뮤를 알 수 있다.', async () => { + await transactionTest(dataSource, async () => { + // given + const user = await usersFixture.getUser('ABC'); + const group = await groupFixture.createGroup('Test Group', user); + + // when + const existByGroupCode = await groupRepository.existByGroupCode( + group.groupCode, + ); + const nonExistByGroupCode = + await groupRepository.existByGroupCode('INVALID'); + + // then + expect(existByGroupCode).toBe(true); + expect(nonExistByGroupCode).toBe(false); + }); + }); + test('그룹을 생성하면 생성한 유저가 리더가 된다.', async () => { await transactionTest(dataSource, async () => { // given @@ -97,9 +131,9 @@ describe('GroupRepository Test', () => { 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); + const group1 = await groupFixture.createGroup('Test Group1', user); + const group2 = await groupFixture.createGroup('Test Group2', user); + const group3 = await groupFixture.createGroup('Test Group3', user); // when const groups = await groupRepository.findByUserId(user.id); @@ -108,10 +142,13 @@ describe('GroupRepository Test', () => { expect(groups.length).toEqual(3); expect(groups[0].name).toEqual('Test Group1'); expect(groups[0].grade).toEqual(UserGroupGrade.LEADER); + expect(groups[0].groupCode).toEqual(group1.groupCode); expect(groups[1].name).toEqual('Test Group2'); expect(groups[1].grade).toEqual(UserGroupGrade.LEADER); + expect(groups[1].groupCode).toEqual(group2.groupCode); expect(groups[2].name).toEqual('Test Group3'); expect(groups[2].grade).toEqual(UserGroupGrade.LEADER); + expect(groups[2].groupCode).toEqual(group3.groupCode); }); }); diff --git a/BE/src/group/group/entities/group.repository.ts b/BE/src/group/group/entities/group.repository.ts index 70e8fd2a..9a65b008 100644 --- a/BE/src/group/group/entities/group.repository.ts +++ b/BE/src/group/group/entities/group.repository.ts @@ -24,6 +24,7 @@ export class GroupRepository extends TransactionalRepository { 'group.id as id', 'group.name as name', 'group.avatarUrl as avatarUrl', + 'group.groupCode as groupCode', 'user_group.grade as grade', ]) .addSelect('COUNT(achievements.id)', 'continued') @@ -75,4 +76,17 @@ export class GroupRepository extends TransactionalRepository { }); return group?.toModel(); } + + async findByGroupCode(groupCode: string) { + const group = await this.repository.findOne({ + where: { + groupCode: groupCode, + }, + }); + return group?.toModel(); + } + + async existByGroupCode(groupCode: string) { + return await this.repository.exist({ where: { groupCode: groupCode } }); + } } diff --git a/BE/src/group/group/entities/user-group.entity.ts b/BE/src/group/group/entities/user-group.entity.ts index 7979d9e9..6dee9eee 100644 --- a/BE/src/group/group/entities/user-group.entity.ts +++ b/BE/src/group/group/entities/user-group.entity.ts @@ -17,7 +17,7 @@ export class UserGroupEntity extends BaseTimeEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/group/group/exception/duplicated-join.exception.ts b/BE/src/group/group/exception/duplicated-join.exception.ts new file mode 100644 index 00000000..1dd729be --- /dev/null +++ b/BE/src/group/group/exception/duplicated-join.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../../common/exception/error-code'; + +export class DuplicatedJoinException extends MotimateException { + constructor() { + super(ERROR_INFO.DUPLICATED_JOIN); + } +} diff --git a/BE/src/group/group/exception/no-such-group.exception.ts b/BE/src/group/group/exception/no-such-group.exception.ts new file mode 100644 index 00000000..039ac8c0 --- /dev/null +++ b/BE/src/group/group/exception/no-such-group.exception.ts @@ -0,0 +1,8 @@ +import { MotimateException } from '../../../common/exception/motimate.excpetion'; +import { ERROR_INFO } from '../../../common/exception/error-code'; + +export class NoSucGroupException extends MotimateException { + constructor() { + super(ERROR_INFO.NO_SUCH_GROUP); + } +} diff --git a/BE/src/group/group/group.module.ts b/BE/src/group/group/group.module.ts index f89a69df..1b361345 100644 --- a/BE/src/group/group/group.module.ts +++ b/BE/src/group/group/group.module.ts @@ -6,6 +6,7 @@ import { GroupService } from './application/group.service'; import { UserGroupRepository } from './entities/user-group.repository'; import { GroupAvatarHolder } from './application/group-avatar.holder'; import { UserRepository } from '../../users/entities/user.repository'; +import { GroupCodeGenerator } from './application/group-code-generator'; @Module({ imports: [ @@ -16,6 +17,6 @@ import { UserRepository } from '../../users/entities/user.repository'; ]), ], controllers: [GroupController], - providers: [GroupService, GroupAvatarHolder], + providers: [GroupService, GroupAvatarHolder, GroupCodeGenerator], }) export class GroupModule {} diff --git a/BE/src/group/group/index.ts b/BE/src/group/group/index.ts index d4a06378..9f8d588a 100644 --- a/BE/src/group/group/index.ts +++ b/BE/src/group/group/index.ts @@ -2,6 +2,7 @@ export interface IGroupPreview { id: number; name: string; avatarUrl: string; + groupCode: string; continued: string; lastChallenged: Date; grade: string; diff --git a/BE/src/image/entities/image.entity.ts b/BE/src/image/entities/image.entity.ts index 8ce9355e..b0a16c86 100644 --- a/BE/src/image/entities/image.entity.ts +++ b/BE/src/image/entities/image.entity.ts @@ -18,7 +18,7 @@ export class ImageEntity { @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; @@ -35,12 +35,15 @@ export class ImageEntity { imageKey: string; @Index() - @OneToOne(() => AchievementEntity, { nullable: true }) + @OneToOne(() => AchievementEntity, { nullable: true, onDelete: 'SET NULL' }) @JoinColumn({ name: 'achievement_id', referencedColumnName: 'id' }) achievement: AchievementEntity; @Index() - @OneToOne(() => GroupAchievementEntity, { nullable: true }) + @OneToOne(() => GroupAchievementEntity, { + nullable: true, + onDelete: 'SET NULL', + }) @JoinColumn({ name: 'group_achievement_id', referencedColumnName: 'id' }) groupAchievement: GroupAchievementEntity; diff --git a/BE/src/users/entities/user-blocked-user.entity.ts b/BE/src/users/entities/user-blocked-user.entity.ts index 55748dee..33ae243b 100644 --- a/BE/src/users/entities/user-blocked-user.entity.ts +++ b/BE/src/users/entities/user-blocked-user.entity.ts @@ -9,7 +9,7 @@ export class UserBlockedUserEntity extends BaseTimeEntity { @PrimaryColumn({ type: 'bigint', nullable: false }) userId: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/src/users/entities/users-role.entity.ts b/BE/src/users/entities/users-role.entity.ts index 9b88db59..ccebc786 100644 --- a/BE/src/users/entities/users-role.entity.ts +++ b/BE/src/users/entities/users-role.entity.ts @@ -7,7 +7,7 @@ export class UsersRoleEntity { @PrimaryColumn({ type: 'bigint', nullable: false }) userId: number; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id', referencedColumnName: 'id' }) user: UserEntity; diff --git a/BE/test/group/emoji/group-achievement-emoji-fixture.ts b/BE/test/group/emoji/group-achievement-emoji-fixture.ts index 080bad5b..40edc10e 100644 --- a/BE/test/group/emoji/group-achievement-emoji-fixture.ts +++ b/BE/test/group/emoji/group-achievement-emoji-fixture.ts @@ -4,11 +4,13 @@ import { Emoji } from '../../../src/group/emoji/domain/emoji'; import { GroupAchievementEmoji } from '../../../src/group/emoji/domain/group-achievement-emoji.domain'; import { Injectable } from '@nestjs/common'; import { GroupAchievementEmojiRepository } from '../../../src/group/emoji/entities/group-achievement-emoji.repository'; +import { UsersFixture } from '../../user/users-fixture'; @Injectable() export class GroupAchievementEmojiFixture { constructor( private readonly groupAchievementEmojiRepository: GroupAchievementEmojiRepository, + private readonly userFixture: UsersFixture, ) {} async createGroupAchievementEmoji( @@ -27,6 +29,17 @@ export class GroupAchievementEmojiFixture { ); } + async createGroupAchievementEmojis( + count: number, + groupAchievement: GroupAchievement, + emoji: Emoji, + ) { + const users = await this.userFixture.getUsers(count); + for (const user of users) { + await this.createGroupAchievementEmoji(user, groupAchievement, emoji); + } + } + static groupAchievementEmoji( user: User, groupAchievement: GroupAchievement, diff --git a/BE/test/group/emoji/group-achievement-emoji-test.module.ts b/BE/test/group/emoji/group-achievement-emoji-test.module.ts index 71f44c7f..87141cfd 100644 --- a/BE/test/group/emoji/group-achievement-emoji-test.module.ts +++ b/BE/test/group/emoji/group-achievement-emoji-test.module.ts @@ -3,11 +3,13 @@ import { CustomTypeOrmModule } from '../../../src/config/typeorm/custom-typeorm. import { GroupAchievementEmojiRepository } from '../../../src/group/emoji/entities/group-achievement-emoji.repository'; import { GroupAchievementEmojiFixture } from './group-achievement-emoji-fixture'; import { GroupAchievementEmojiModule } from '../../../src/group/emoji/group-achievement-emoji.module'; +import { UsersTestModule } from '../../user/users-test.module'; @Module({ imports: [ CustomTypeOrmModule.forCustomRepository([GroupAchievementEmojiRepository]), GroupAchievementEmojiModule, + UsersTestModule, ], providers: [GroupAchievementEmojiFixture], exports: [GroupAchievementEmojiFixture], diff --git a/BE/test/group/group/group-fixture.ts b/BE/test/group/group/group-fixture.ts index 7d981eb5..a26f13d4 100644 --- a/BE/test/group/group/group-fixture.ts +++ b/BE/test/group/group/group-fixture.ts @@ -36,6 +36,11 @@ export class GroupFixture { } static group(name?: string) { + const group = new Group( + name || `group${++GroupFixture.id}`, + 'file://avatarUrl', + ); + group.assignGroupCode(`GABCDE${++GroupFixture.id}`); return new Group(name || `group${++GroupFixture.id}`, 'file://avatarUrl'); } } diff --git a/iOS/moti/moti.xcodeproj/project.pbxproj b/iOS/moti/moti.xcodeproj/project.pbxproj index fc6dd857..e39d098d 100644 --- a/iOS/moti/moti.xcodeproj/project.pbxproj +++ b/iOS/moti/moti.xcodeproj/project.pbxproj @@ -369,21 +369,22 @@ CODE_SIGN_ENTITLEMENTS = moti/moti.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 229; + CURRENT_PROJECT_VERSION = 230; DEVELOPMENT_TEAM = B3PWYBKFUK; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = moti/Resource/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "도전 기록 촬영을 위한 카메라 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.3; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.moti; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -403,21 +404,22 @@ CODE_SIGN_ENTITLEMENTS = moti/moti.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 229; + CURRENT_PROJECT_VERSION = 230; DEVELOPMENT_TEAM = B3PWYBKFUK; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = moti/Resource/Info.plist; + INFOPLIST_KEY_NSCameraUsageDescription = "도전 기록 촬영을 위한 카메라 권한이 필요합니다."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.0.3; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = kr.codesquad.boostcamp8.moti; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/iOS/moti/moti/Application/AppDelegate.swift b/iOS/moti/moti/Application/AppDelegate.swift index 7afad609..8f4f6425 100644 --- a/iOS/moti/moti/Application/AppDelegate.swift +++ b/iOS/moti/moti/Application/AppDelegate.swift @@ -29,14 +29,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } - func application( - _ application: UIApplication, - didDiscardSceneSessions sceneSessions: Set - ) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, - // this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + if UIDevice.current.userInterfaceIdiom == .phone { + return .portrait + } else { + return .all + } } - } diff --git a/iOS/moti/moti/Core/Sources/Core/Logger.swift b/iOS/moti/moti/Core/Sources/Core/Logger.swift index fb7cf53a..9860fa12 100644 --- a/iOS/moti/moti/Core/Sources/Core/Logger.swift +++ b/iOS/moti/moti/Core/Sources/Core/Logger.swift @@ -10,22 +10,30 @@ import OSLog public enum Logger { public static func debug(_ object: T?) { + #if DEBUG let message = object != nil ? "\(object!)" : "nil" os_log(.debug, "%@", message) + #endif } public static func info(_ object: T?) { + #if DEBUG let message = object != nil ? "\(object!)" : "nil" os_log(.info, "%@", message) + #endif } public static func error(_ object: T?) { + #if DEBUG let message = object != nil ? "\(object!)" : "nil" os_log(.error, "%@", message) + #endif } public static func network(_ object: T?) { + #if DEBUG let message = object != nil ? "\(object!)" : "nil" os_log(.debug, "[Network] %@", message) + #endif } } diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/FetchGroupListDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/FetchGroupListDTO.swift index e3e72e99..9d98baba 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/FetchGroupListDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/FetchGroupListDTO.swift @@ -20,6 +20,7 @@ struct FetchGroupListDataDTO: Codable { struct GroupDTO: Codable { let id: Int + let groupCode: String? let name: String? let avatarUrl: URL? let continued: Int? @@ -31,6 +32,7 @@ extension Group { init(dto: GroupDTO) { self.init( id: dto.id, + code: dto.groupCode ?? "", name: dto.name ?? "", avatarUrl: dto.avatarUrl, continued: dto.continued ?? 0, @@ -41,6 +43,7 @@ extension Group { init(dto: GroupDTO, grade: GroupGrade) { self.init( id: dto.id, + code: dto.groupCode ?? "", name: dto.name ?? "", avatarUrl: dto.avatarUrl, continued: dto.continued ?? 0, diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/JoinGroupDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/JoinGroupDTO.swift new file mode 100644 index 00000000..42852319 --- /dev/null +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/JoinGroupDTO.swift @@ -0,0 +1,19 @@ +// +// JoinGroupDTO.swift +// +// +// Created by Kihyun Lee on 12/10/23. +// + +import Foundation + +struct JoinGroupDTO: ResponseDataDTO { + let success: Bool? + let message: String? + let data: JoinGroupDataDTO? +} + +struct JoinGroupDataDTO: Codable { + let groupCode: String? + let userCode: String? +} diff --git a/iOS/moti/moti/Data/Sources/Data/Network/DTO/UpdateAchievementDTO.swift b/iOS/moti/moti/Data/Sources/Data/Network/DTO/UpdateAchievementDTO.swift index d2e8cb7d..3b812a44 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/DTO/UpdateAchievementDTO.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/DTO/UpdateAchievementDTO.swift @@ -17,4 +17,3 @@ struct UpdateAchievementResponseDTO: ResponseDTO { struct UpdateAchievementDataDTO: Codable { let id: Int } - diff --git a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift index 92f3c84f..84f8ccaa 100644 --- a/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift +++ b/iOS/moti/moti/Data/Sources/Data/Network/Endpoint/MotiAPI.swift @@ -13,6 +13,7 @@ enum MotiAPI: EndpointProtocol { case version case login(requestValue: LoginRequestValue) case autoLogin(requestValue: AutoLoginRequestValue) + case revoke(requestValue: RevokeRequestValue) case saveImage(requestValue: SaveImageRequestValue) // 개인 case fetchAchievementList(requestValue: FetchAchievementListRequestValue?) @@ -38,6 +39,7 @@ enum MotiAPI: EndpointProtocol { case updateGrade(groupId: Int, userCode: String, requestValue: UpdateGradeRequestValue) case invite(requestValue: InviteMemberRequestValue, groupId: Int) case dropGroup(groupId: Int) + case joinGroup(requestValue: JoinGroupRequestValue) // 차단 case blockingUser(userCode: String) case blockingAchievement(achievementId: Int, groupId: Int) @@ -64,6 +66,7 @@ extension MotiAPI { case .version: return "/operate/policy" case .login: return "/auth/login" case .autoLogin: return "/auth/refresh" + case .revoke: return "/auth/revoke" case .fetchAchievementList: return "/achievements" case .fetchCategory(let categoryId): return "/categories/\(categoryId)" case .fetchCategoryList: return "/categories" @@ -107,6 +110,8 @@ extension MotiAPI { return "/groups/\(groupId)/achievements/\(achievementId)/emojis/\(emojiId)" case .dropGroup(let groupId): return "/groups/\(groupId)/participation" + case .joinGroup: + return "/groups/participation" } } @@ -115,6 +120,7 @@ extension MotiAPI { case .version: return .get case .login: return .post case .autoLogin: return .post + case .revoke: return .delete case .fetchAchievementList: return .get case .fetchCategory: return .get case .fetchCategoryList: return .get @@ -142,6 +148,7 @@ extension MotiAPI { case .fetchEmojis: return .get case .toggleEmoji: return .post case .dropGroup: return .delete + case .joinGroup: return .post } } @@ -162,6 +169,8 @@ extension MotiAPI { return requestValue case .autoLogin(let requestValue): return requestValue + case .revoke(let requestValue): + return requestValue case .addCategory(let requestValue): return requestValue case .updateAchievement(let requestValue): @@ -180,6 +189,8 @@ extension MotiAPI { return requestValue case .updateGrade(_, _, let requestValue): return requestValue + case .joinGroup(let requestValue): + return requestValue default: return nil } diff --git a/iOS/moti/moti/Data/Sources/Repository/LoginRepository.swift b/iOS/moti/moti/Data/Sources/Repository/AuthRepository.swift similarity index 72% rename from iOS/moti/moti/Data/Sources/Repository/LoginRepository.swift rename to iOS/moti/moti/Data/Sources/Repository/AuthRepository.swift index fef973e5..d209598b 100644 --- a/iOS/moti/moti/Data/Sources/Repository/LoginRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/AuthRepository.swift @@ -1,5 +1,5 @@ // -// LoginRepository.swift +// AuthRepository.swift // // // Created by 유정주 on 11/14/23. @@ -9,7 +9,7 @@ import Foundation import Domain import Core -public struct LoginRepository: LoginRepositoryProtocol { +public struct AuthRepository: AuthRepositoryProtocol { private let provider: ProviderProtocol public init(provider: ProviderProtocol = Provider()) { @@ -31,4 +31,11 @@ public struct LoginRepository: LoginRepositoryProtocol { guard let userTokenDTO = responseDTO.data else { throw NetworkError.decode } return UserToken(dto: userTokenDTO) } + + public func revoke(requestValue: RevokeRequestValue) async throws -> Bool { + let endpoint = MotiAPI.revoke(requestValue: requestValue) + let responseDTO = try await provider.request(with: endpoint, type: SimpleResponseDTO.self) + + return responseDTO.success ?? false + } } diff --git a/iOS/moti/moti/Data/Sources/Repository/GroupRepository.swift b/iOS/moti/moti/Data/Sources/Repository/GroupRepository.swift index 9c55bbdf..5017735a 100644 --- a/iOS/moti/moti/Data/Sources/Repository/GroupRepository.swift +++ b/iOS/moti/moti/Data/Sources/Repository/GroupRepository.swift @@ -39,4 +39,12 @@ public struct GroupRepository: GroupRepositoryProtocol { return responseDTO.success ?? false } + + public func joinGroup(requestValue: JoinGroupRequestValue) async throws -> Bool { + let endpoint = MotiAPI.joinGroup(requestValue: requestValue) + let responseDTO = try await provider.request(with: endpoint, type: JoinGroupDTO.self) + guard let joinGroupDataDTO = responseDTO.data else { throw NetworkError.decode } + + return responseDTO.success ?? false + } } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift index e9dcba7a..15306e51 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/AchievementListRepositoryTests.swift @@ -134,7 +134,6 @@ final class AchievementListRepositoryTests: XCTestCase { XCTAssertEqual(achievements, emptyAchievementList) XCTAssertNil(next) - expectation.fulfill() } diff --git a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift index 117d80d8..b7745910 100644 --- a/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift +++ b/iOS/moti/moti/Data/Tests/DataTests/Repository/Mock/MockLoginRepository.swift @@ -10,7 +10,7 @@ import Foundation @testable import Data @testable import Core -public struct MockLoginRepository: LoginRepositoryProtocol { +public struct MockLoginRepository: AuthRepositoryProtocol { private var json = """ { "success": true, diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/EmojiButtonTitle.colorset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/EmojiButtonTitle.colorset/Contents.json new file mode 100644 index 00000000..a43b3219 --- /dev/null +++ b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/EmojiButtonTitle.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "universal", + "reference" : "labelColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/MotiBackground.colorset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/MotiBackground.colorset/Contents.json index eb81500d..4215bbd2 100644 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/MotiBackground.colorset/Contents.json +++ b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/MotiBackground.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.196", - "green" : "0.188", - "red" : "0.188" + "blue" : "0.095", + "green" : "0.091", + "red" : "0.090" } }, "idiom" : "universal" diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/PrimaryGray.colorset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/PrimaryGray.colorset/Contents.json index dde9c9c5..b06f5bd5 100644 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/PrimaryGray.colorset/Contents.json +++ b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Color/PrimaryGray.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.200", - "green" : "0.200", - "red" : "0.200" + "blue" : "0.390", + "green" : "0.390", + "red" : "0.390" } }, "idiom" : "universal" diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/Contents.json similarity index 68% rename from iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/Contents.json rename to iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/Contents.json index 2d2c1cc9..112ab0f2 100644 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/Contents.json +++ b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "IMG_8195.png", + "filename" : "small-skeleton.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "IMG_8195 1.png", + "filename" : "small-skeleton 1.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "IMG_8195 2.png", + "filename" : "small-skeleton 2.png", "idiom" : "universal", "scale" : "3x" } diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton 1.png b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton 1.png new file mode 100644 index 00000000..02a68507 Binary files /dev/null and b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton 1.png differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton 2.png b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton 2.png new file mode 100644 index 00000000..02a68507 Binary files /dev/null and b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton 2.png differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton.png b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton.png new file mode 100644 index 00000000..02a68507 Binary files /dev/null and b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Logo/SmallSkeleton.imageset/small-skeleton.png differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195 1.png b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195 1.png deleted file mode 100644 index 0a85200d..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195 1.png and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195 2.png b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195 2.png deleted file mode 100644 index 0a85200d..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195 2.png and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195.png b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195.png deleted file mode 100644 index 0a85200d..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample1.imageset/IMG_8195.png and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 1.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 1.jpeg deleted file mode 100644 index 44c68884..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 1.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 2.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 2.jpeg deleted file mode 100644 index 44c68884..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 2.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c.jpeg deleted file mode 100644 index 44c68884..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/Contents.json deleted file mode 100644 index 8e7058c9..00000000 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample2.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c.jpeg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 1.jpeg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "3AD03B58-D3B5-4464-A6CF-C11A6C812484_1_105_c 2.jpeg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 1.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 1.jpeg deleted file mode 100644 index 37cac24b..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 1.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 2.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 2.jpeg deleted file mode 100644 index 37cac24b..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 2.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c.jpeg deleted file mode 100644 index 37cac24b..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/Contents.json deleted file mode 100644 index f3dbc61c..00000000 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample3.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c.jpeg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 1.jpeg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "4D5B7796-A525-4487-B8CF-F651F72C451E_1_105_c 2.jpeg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 1.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 1.jpeg deleted file mode 100644 index 7796feb6..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 1.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 2.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 2.jpeg deleted file mode 100644 index 7796feb6..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 2.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c.jpeg deleted file mode 100644 index 7796feb6..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/Contents.json deleted file mode 100644 index c1aaabf0..00000000 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample4.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c.jpeg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 1.jpeg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "10F4D012-CD5B-450D-A249-7597C78A7890_1_105_c 2.jpeg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 1.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 1.jpeg deleted file mode 100644 index 89551478..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 1.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 2.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 2.jpeg deleted file mode 100644 index 89551478..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 2.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c.jpeg deleted file mode 100644 index 89551478..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/Contents.json deleted file mode 100644 index 3324c786..00000000 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample5.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c.jpeg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 2.jpeg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "2649A957-BD88-4635-BF95-185B9A56C2A1_1_105_c 1.jpeg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 1.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 1.jpeg deleted file mode 100644 index fcfe786c..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 1.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 2.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 2.jpeg deleted file mode 100644 index fcfe786c..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 2.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c.jpeg deleted file mode 100644 index fcfe786c..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/Contents.json deleted file mode 100644 index 4d6f74e2..00000000 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample6.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c.jpeg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 1.jpeg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "877E6B03-3AF8-44C4-9D81-FFE0F4AB2570_1_105_c 2.jpeg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 1.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 1.jpeg deleted file mode 100644 index 7c539b9c..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 1.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 2.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 2.jpeg deleted file mode 100644 index 7c539b9c..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 2.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c.jpeg b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c.jpeg deleted file mode 100644 index 7c539b9c..00000000 Binary files a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c.jpeg and /dev/null differ diff --git a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/Contents.json b/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/Contents.json deleted file mode 100644 index 9a9dcef8..00000000 --- a/iOS/moti/moti/Design/Sources/Design/Design.xcassets/Sample/Sample7.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c.jpeg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 1.jpeg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "AC393C10-3E9E-40AC-97F9-D1D6F828ADE8_1_105_c 2.jpeg", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/moti/moti/Design/Sources/Design/EmojiButton.swift b/iOS/moti/moti/Design/Sources/Design/EmojiButton.swift index 75816632..5ec3a264 100644 --- a/iOS/moti/moti/Design/Sources/Design/EmojiButton.swift +++ b/iOS/moti/moti/Design/Sources/Design/EmojiButton.swift @@ -7,7 +7,7 @@ import UIKit -public final class EmojiButton: BounceButton { +public final class EmojiButton: BounceButton, CAAnimationDelegate { public let emoji: String private var isSelectedEmoji = false { didSet { @@ -43,7 +43,7 @@ public final class EmojiButton: BounceButton { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.isUserInteractionEnabled = false - label.font = UIFont.systemFont(ofSize: 18) // 디자인시스템에 넣기..? 좀 고민 중 + label.font = UIFont.systemFont(ofSize: 18) return label }() @@ -111,13 +111,32 @@ public final class EmojiButton: BounceButton { } private func increaseCount() { - // TODO: 숫자가 위로 올라가는 애니메이션 + // 숫자가 1 이상일 때만 애니메이션 + // 0일 때는 countLabel이 나타나는 효과가 존재함 + if count > 0 { + pushAnimation(subtype: .fromTop) + } count += 1 } private func decreaseCount() { - // TODO: 숫자가 아래로 내려가는 애니메이션 count -= 1 + // 숫자가 1 이상일 때만 애니메이션 + // 0일 때는 countLabel이 사라지는 효과가 존재함 + if count > 0 { + pushAnimation(subtype: .fromBottom) + } + } + + private func pushAnimation(subtype: CATransitionSubtype) { + let animation = CATransition() + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.duration = 0.15 + animation.type = .push + animation.subtype = subtype + animation.delegate = self + + countLabel.layer.add(animation, forKey: CATransitionType.push.rawValue) } // MARK: - Setup @@ -156,6 +175,6 @@ public final class EmojiButton: BounceButton { private func applyDeselectedStyle() { applyNormalUI() - countLabel.textColor = .black + countLabel.textColor = .emojiButtonTitle } } diff --git a/iOS/moti/moti/Design/Sources/Design/MotiImage.swift b/iOS/moti/moti/Design/Sources/Design/MotiImage.swift index 9b832dc0..fe0f206b 100644 --- a/iOS/moti/moti/Design/Sources/Design/MotiImage.swift +++ b/iOS/moti/moti/Design/Sources/Design/MotiImage.swift @@ -9,17 +9,6 @@ import UIKit /// Motimate 팀이 제작한 이미지 public enum MotiImage { - /// 샘플 이미지 1 - #if DEBUG - public static let sample1 = UIImage(resource: .sample1) - public static let sample2 = UIImage(resource: .sample2) - public static let sample3 = UIImage(resource: .sample3) - public static let sample4 = UIImage(resource: .sample4) - public static let sample5 = UIImage(resource: .sample5) - public static let sample6 = UIImage(resource: .sample6) - public static let sample7 = UIImage(resource: .sample7) - #endif - /// 흰색 moti 로고 public static let logoWhite = UIImage(resource: .motiLogoWhite) @@ -29,6 +18,9 @@ public enum MotiImage { /// Cell 스켈레톤 이미지 public static let skeleton = UIImage(resource: .skeleton) + /// Cell 작은 스켈레톤 이미지 + public static let smallSkeleton = UIImage(resource: .smallSkeleton) + /// 앱 아이콘 이미지 1024x1024 public static let appIcon = UIImage(resource: .appIcon) } diff --git a/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift b/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift index 7cd784fc..c98f8137 100644 --- a/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift +++ b/iOS/moti/moti/Design/Sources/Design/UIColor+MotiColor.swift @@ -31,4 +31,7 @@ public extension UIColor { /// 탭바 아이템과 동일한 회색 색상 static var tabBarItemGray = UIColor(resource: .tabBarItemGray) + + /// 이모지 버튼 count 라벨 새상 + static var emojiButtonTitle = UIColor(resource: .emojiButtonTitle) } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift index b27c3f8e..936d3e9e 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Achievement.swift @@ -83,7 +83,8 @@ public struct Achievement: Hashable { } public extension Achievement { - static func makeSkeleton() -> Achievement { - return .init(id: -(UUID().hashValue), title: "", imageURL: nil, categoryId: 0, user: nil) + static func makeSkeleton(id: Int) -> Achievement { + let id = id < 0 ? id : -id + return .init(id: id, title: "", imageURL: nil, categoryId: 0, user: nil) } } diff --git a/iOS/moti/moti/Domain/Sources/Domain/Entity/Group.swift b/iOS/moti/moti/Domain/Sources/Domain/Entity/Group.swift index 01dd3b68..c36ed077 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/Entity/Group.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/Entity/Group.swift @@ -23,6 +23,7 @@ public enum GroupGrade: String, CustomStringConvertible { public struct Group: Hashable { public let id: Int + public let code: String public let name: String public var avatarUrl: URL? public var continued: Int @@ -31,6 +32,7 @@ public struct Group: Hashable { public init( id: Int, + code: String, name: String, avatarUrl: URL?, continued: Int, @@ -38,6 +40,7 @@ public struct Group: Hashable { grade: GroupGrade ) { self.id = id + self.code = code self.name = name self.avatarUrl = avatarUrl self.continued = continued @@ -47,11 +50,13 @@ public struct Group: Hashable { public init( id: Int, + code: String, name: String, avatarUrl: URL?, grade: GroupGrade ) { self.id = id + self.code = code self.name = name self.avatarUrl = avatarUrl self.continued = 0 diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/LoginRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AuthRepositoryProtocol.swift similarity index 61% rename from iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/LoginRepositoryProtocol.swift rename to iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AuthRepositoryProtocol.swift index e9affd11..621e2428 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/LoginRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/AuthRepositoryProtocol.swift @@ -1,5 +1,5 @@ // -// LoginRepositoryProtocol.swift +// AuthRepositoryProtocol.swift // // // Created by 유정주 on 11/14/23. @@ -7,7 +7,8 @@ import Foundation -public protocol LoginRepositoryProtocol { +public protocol AuthRepositoryProtocol { func autoLogin(requestValue: AutoLoginRequestValue) async throws -> UserToken func login(requestValue: LoginRequestValue) async throws -> UserToken + func revoke(requestValue: RevokeRequestValue) async throws -> Bool } diff --git a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/GroupRepositoryProtocol.swift b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/GroupRepositoryProtocol.swift index 3908d8f3..66a55c80 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/GroupRepositoryProtocol.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/RepositoryProtocol/GroupRepositoryProtocol.swift @@ -11,4 +11,5 @@ public protocol GroupRepositoryProtocol { func fetchGroupList() async throws -> [Group] func createGroup(requestValue: CreateGroupRequestValue) async throws -> Group func dropGroup(groupId: Int) async throws -> Bool + func joinGroup(requestValue: JoinGroupRequestValue) async throws -> Bool } diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/AutoLoginUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/AutoLoginUseCase.swift index ae90841d..14a504da 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/AutoLoginUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/AutoLoginUseCase.swift @@ -16,11 +16,11 @@ public struct AutoLoginRequestValue: RequestValue { } public struct AutoLoginUseCase { - private let repository: LoginRepositoryProtocol + private let repository: AuthRepositoryProtocol private let keychainStorage: KeychainStorageProtocol public init( - repository: LoginRepositoryProtocol, + repository: AuthRepositoryProtocol, keychainStorage: KeychainStorageProtocol ) { self.repository = repository diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift index f676e5bc..cadfd047 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/FetchDetailAchievementUseCase.swift @@ -26,4 +26,3 @@ public struct FetchDetailAchievementUseCase { return try await repository.fetchDetailAchievement(requestValue: requestValue) } } - diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/JoinGroupUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/JoinGroupUseCase.swift new file mode 100644 index 00000000..47ee04cb --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/JoinGroupUseCase.swift @@ -0,0 +1,28 @@ +// +// JoinGroupUseCase.swift +// +// +// Created by Kihyun Lee on 12/10/23. +// + +import Foundation + +public struct JoinGroupRequestValue: RequestValue { + public let groupCode: String + + public init(groupCode: String) { + self.groupCode = groupCode + } +} + +public struct JoinGroupUseCase { + private let groupRepository: GroupRepositoryProtocol + + public init(groupRepository: GroupRepositoryProtocol) { + self.groupRepository = groupRepository + } + + public func execute(requestValue: JoinGroupRequestValue) async throws -> Bool { + return try await groupRepository.joinGroup(requestValue: requestValue) + } +} diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/LoginUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/LoginUseCase.swift index 58b34cf4..be055c28 100644 --- a/iOS/moti/moti/Domain/Sources/Domain/UseCase/LoginUseCase.swift +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/LoginUseCase.swift @@ -16,11 +16,11 @@ public struct LoginRequestValue: RequestValue { } public struct LoginUseCase { - private let repository: LoginRepositoryProtocol + private let repository: AuthRepositoryProtocol private let keychainStorage: KeychainStorageProtocol public init( - repository: LoginRepositoryProtocol, + repository: AuthRepositoryProtocol, keychainStorage: KeychainStorageProtocol ) { self.repository = repository diff --git a/iOS/moti/moti/Domain/Sources/Domain/UseCase/RevokeUseCase.swift b/iOS/moti/moti/Domain/Sources/Domain/UseCase/RevokeUseCase.swift new file mode 100644 index 00000000..0ce83c6e --- /dev/null +++ b/iOS/moti/moti/Domain/Sources/Domain/UseCase/RevokeUseCase.swift @@ -0,0 +1,30 @@ +// +// RevokeUseCase.swift +// +// +// Created by 유정주 on 12/9/23. +// + +import Foundation + +public struct RevokeRequestValue: RequestValue { + public let identityToken: String + public let authorizationCode: String + + public init(identityToken: String, authorizationCode: String) { + self.identityToken = identityToken + self.authorizationCode = authorizationCode + } +} + +public struct RevokeUseCase { + private let repository: AuthRepositoryProtocol + + public init(repository: AuthRepositoryProtocol) { + self.repository = repository + } + + public func execute(requestValue: RevokeRequestValue) async throws -> Bool { + return try await repository.revoke(requestValue: requestValue) + } +} diff --git a/iOS/moti/moti/JKImageCache/Package.swift b/iOS/moti/moti/JKImageCache/Package.swift index b5670f35..a7117acf 100644 --- a/iOS/moti/moti/JKImageCache/Package.swift +++ b/iOS/moti/moti/JKImageCache/Package.swift @@ -10,7 +10,7 @@ let package = Package( // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "JKImageCache", - targets: ["JKImageCache"]), + targets: ["JKImageCache"]) ], dependencies: [ .package(url: "https://github.com/jeongju9216/Jeongfisher.git", from: "2.5.0") @@ -26,6 +26,6 @@ let package = Package( path: "Sources"), .testTarget( name: "JKImageCacheTests", - dependencies: ["JKImageCache"]), + dependencies: ["JKImageCache"]) ] ) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoCoordinator.swift index 42ddb1f4..4e8933b3 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoCoordinator.swift @@ -8,6 +8,7 @@ import UIKit import Core import Domain +import Data final class AppInfoCoordinator: Coordinator { var parentCoordinator: Coordinator? @@ -28,7 +29,9 @@ final class AppInfoCoordinator: Coordinator { let privacyPolicy = UserDefaults.standard.readString(key: .privacyPolicy) else { return } let version = Version(latest: latest, required: required, privacyPolicy: privacyPolicy) - let appInfoVC = AppInfoViewController(version: version) + let revokeUseCase = RevokeUseCase(repository: AuthRepository()) + let appInfoVM = AppInfoViewModel(revokeUseCase: revokeUseCase) + let appInfoVC = AppInfoViewController(viewModel: appInfoVM, version: version) appInfoVC.coordinator = self navigationController.present(appInfoVC, animated: true) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoView.swift index d71fc476..b9121cea 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoView.swift @@ -51,6 +51,16 @@ final class AppInfoView: UIView { button.titleLabel?.font = .medium return button }() + private(set) var revokeButton = { + let button = UIButton(type: .system) + button.clipsToBounds = true + button.layer.cornerRadius = CornerRadius.big + button.titleLabel?.font = .medium + button.setTitle("회원 탈퇴", for: .normal) + button.setTitleColor(.label, for: .normal) + button.backgroundColor = .primaryDarkGray + return button + }() // MARK: - Init override init(frame: CGRect) { @@ -92,6 +102,7 @@ private extension AppInfoView { setupTitleLabel() setupVersionLabel() + setupRevokeButton() setupUpdateButton() setupPolicyButton() } @@ -125,12 +136,21 @@ private extension AppInfoView { .top(equalTo: titleLabel.bottomAnchor, constant: 20) } + func setupRevokeButton() { + addSubview(revokeButton) + revokeButton.atl + .height(constant: 40) + .centerX(equalTo: centerXAnchor) + .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -20) + .horizontal(equalTo: safeAreaLayoutGuide, constant: 40) + } + func setupUpdateButton() { addSubview(updateButton) updateButton.atl .height(constant: 40) .centerX(equalTo: centerXAnchor) - .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -20) + .bottom(equalTo: revokeButton.topAnchor, constant: -10) .horizontal(equalTo: safeAreaLayoutGuide, constant: 40) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewController.swift index de49c67b..9860a588 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewController.swift @@ -6,16 +6,21 @@ // import UIKit +import Combine import Domain -final class AppInfoViewController: BaseViewController { +final class AppInfoViewController: BaseViewController, LoadingIndicator { // MARK: - Properties + let viewModel: AppInfoViewModel + private var cancellables: Set = [] weak var coordinator: AppInfoCoordinator? + private var appleLoginRequester: AppleLoginRequester? private let version: Version // MARK: - Init - init(version: Version) { + init(viewModel: AppInfoViewModel, version: Version) { + self.viewModel = viewModel self.version = version super.init(nibName: nil, bundle: nil) } @@ -28,18 +33,51 @@ final class AppInfoViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() layoutView.configure(with: version) + bind() addTarget() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // view.window가 필요하므로 viewDidAppear에서 setup + // viewDidLayoutSubviews부터 window가 생기지만, 한 번만 호출하기 위해 viewDidAppear에서 호출 + setupAppleLoginRequester() + } + + private func bind() { + viewModel.revokeState + .receive(on: RunLoop.main) + .sink { [weak self] state in + guard let self else { return } + switch state { + case .loading: + showLoadingIndicator() + case .finish: + hideLoadingIndicator() + case .error(let message): + hideLoadingIndicator() + showErrorAlert(message: message) + } + } + .store(in: &cancellables) + } + // MARK: - Actions + private func addTarget() { + layoutView.closeButton.addTarget(self, action: #selector(closeButtonDidClicked), for: .touchUpInside) + layoutView.updateButton.addTarget(self, action: #selector(updateButtonDidClicked), for: .touchUpInside) + layoutView.policyButton.addTarget(self, action: #selector(policyButtonDidClicked), for: .touchUpInside) + layoutView.revokeButton.addTarget(self, action: #selector(revokeButtonDidClicked), for: .touchUpInside) + } + @objc private func closeButtonDidClicked() { coordinator?.finish() } @objc private func updateButtonDidClicked() { if version.canUpdate { - let appleId = 0 - let appstoreURLString = "itms-apps://itunes.apple.com/app/apple-store/\(appleId)" + let appstoreURLString = "itms-apps://itunes.apple.com/app/apple-store/6471563249" openURL(appstoreURLString) } else { showOneButtonAlert(message: "이미 최신 버전입니다.") @@ -51,23 +89,36 @@ final class AppInfoViewController: BaseViewController { } // MARK: - Methods - private func addTarget() { - layoutView.closeButton.addTarget( - self, - action: #selector(closeButtonDidClicked), - for: .touchUpInside) - layoutView.updateButton.addTarget( - self, - action: #selector(updateButtonDidClicked), - for: .touchUpInside) - layoutView.policyButton.addTarget( - self, - action: #selector(policyButtonDidClicked), - for: .touchUpInside) - } - private func openURL(_ url: String) { guard let url = URL(string: url) else { return } UIApplication.shared.open(url) } + + @objc private func revokeButtonDidClicked() { + showTwoButtonAlert( + title: "정말 회원 탈퇴를 하시겠습니까?", + message: "회원 탈퇴 시 모든 기록이 삭제됩니다.", + okAction: { + self.appleLoginRequester?.request() + } + ) + } + + private func setupAppleLoginRequester() { + guard appleLoginRequester == nil, + let window = view.window else { return } + + appleLoginRequester = AppleLoginRequester(window: window) + appleLoginRequester?.delegate = self + } +} + +extension AppInfoViewController: AppleLoginRequesterDelegate { + func success(token: String, authorizationCode: String) { + viewModel.action(.revoke(identityToken: token, authorizationCode: authorizationCode)) + } + + func failed(message: String) { + showOneButtonAlert(title: "회원 탈퇴 실패", message: "다시 시도해 주세요.") + } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewModel.swift new file mode 100644 index 00000000..21d6ca4e --- /dev/null +++ b/iOS/moti/moti/Presentation/Sources/Presentation/AppInfo/AppInfoViewModel.swift @@ -0,0 +1,60 @@ +// +// AppInfoViewModel.swift +// +// +// Created by 유정주 on 12/9/23. +// + +import Foundation +import Domain +import Data +import Combine + +struct AppInfoViewModel { + enum AppInfoViewModelAction { + case revoke(identityToken: String, authorizationCode: String) + } + + enum RevokeState { + case loading + case finish + case error(message: String) + } + + // MARK: - Properties + private let revokeUseCase: RevokeUseCase + private(set) var revokeState = PassthroughSubject() + + // MARK: - Init + init(revokeUseCase: RevokeUseCase) { + self.revokeUseCase = revokeUseCase + } + + func action(_ actions: AppInfoViewModelAction) { + switch actions { + case .revoke(let identityToken, let authorizationCode): + revoke(identityToken: identityToken, authorizationCode: authorizationCode) + } + } +} + +private extension AppInfoViewModel { + /// 회원 탈퇴 액션 + func revoke(identityToken: String, authorizationCode: String) { + Task { + do { + revokeState.send(.loading) + + let requestValue = RevokeRequestValue(identityToken: identityToken, authorizationCode: authorizationCode) + let isSuccess = try await revokeUseCase.execute(requestValue: requestValue) + revokeState.send(.finish) + + NotificationCenter.default.post(name: .logout, object: nil) + KeychainStorage.shared.remove(key: .accessToken) + KeychainStorage.shared.remove(key: .refreshToken) + } catch { + revokeState.send(.error(message: error.localizedDescription)) + } + } + } +} diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Authorization/AppleLoginRequester.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Authorization/AppleLoginRequester.swift index 3efab733..5d27a427 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Authorization/AppleLoginRequester.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Authorization/AppleLoginRequester.swift @@ -7,10 +7,11 @@ import Foundation import AuthenticationServices +import Core protocol AppleLoginRequesterDelegate: AnyObject { - func success(token: String) - func failed(error: Error) + func success(token: String, authorizationCode: String) + func failed(message: String) } final class AppleLoginRequester: NSObject, LoginRequester { @@ -40,16 +41,27 @@ extension AppleLoginRequester: ASAuthorizationControllerDelegate { guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { return } guard let identityTokenData = appleIDCredential.identityToken, let identityToken = String(data: identityTokenData, encoding: .utf8) else { - // TODO: 에러 처리 + delegate?.failed(message: "로그인 실패") return } - delegate?.success(token: identityToken) + guard let authorizationCodeData = appleIDCredential.authorizationCode, + let authorizationCode = String(data: authorizationCodeData, encoding: .utf8) else { + delegate?.failed(message: "로그인 실패") + return + } + + #if DEBUG + Logger.debug("identityToken: \(identityToken)") + Logger.debug("authorizationCode: \(authorizationCode)") + #endif + + delegate?.success(token: identityToken, authorizationCode: authorizationCode) } // 인증 실패 func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - delegate?.failed(error: error) + delegate?.failed(message: error.localizedDescription) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift index f174a63c..d146da82 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/AutoLayout/AutoLayoutWrapper+UIView.swift @@ -58,6 +58,16 @@ extension AutoLayoutWrapper { return self } + @discardableResult + func centerY( + greaterThanOrEqualTo anchor: NSLayoutAnchor, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.centerYAnchor.constraint(greaterThanOrEqualTo: anchor, constant: constant).isActive = true + return self + } + @discardableResult func top( equalTo anchor: NSLayoutAnchor, @@ -78,6 +88,46 @@ extension AutoLayoutWrapper { return self } + @discardableResult + func top( + greaterThanOrEqualTo anchor: NSLayoutAnchor, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.topAnchor.constraint(greaterThanOrEqualTo: anchor, constant: constant).isActive = true + return self + } + + @discardableResult + func bottom( + greaterThanOrEqualTo anchor: NSLayoutAnchor, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.bottomAnchor.constraint(greaterThanOrEqualTo: anchor, constant: constant).isActive = true + return self + } + + @discardableResult + func top( + lessThanOrEqualTo anchor: NSLayoutAnchor, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.topAnchor.constraint(lessThanOrEqualTo: anchor, constant: constant).isActive = true + return self + } + + @discardableResult + func bottom( + lessThanOrEqualTo anchor: NSLayoutAnchor, + constant: CGFloat = 0 + ) -> Self { + view.translatesAutoresizingMaskIntoConstraints = false + view.bottomAnchor.constraint(lessThanOrEqualTo: anchor, constant: constant).isActive = true + return self + } + @discardableResult func left( equalTo anchor: NSLayoutAnchor, diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift index ee17b497..62d10234 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureCoordinator.swift @@ -46,14 +46,6 @@ final class CaptureCoordinator: Coordinator { let captureVC = CaptureViewController(group: group) captureVC.delegate = self captureVC.coordinator = self - - captureVC.navigationItem.leftBarButtonItem = UIBarButtonItem( - title: "취소", style: .plain, target: self, - action: #selector(cancelButtonAction) - ) - - captureVC.navigationItem.rightBarButtonItem = nil - navigationController.pushViewController(captureVC, animated: true) } @@ -63,10 +55,6 @@ final class CaptureCoordinator: Coordinator { editAchievementCoordinator.startAfterCapture(image: image, group: group) childCoordinators.append(editAchievementCoordinator) } - - @objc func cancelButtonAction() { - finish() - } } extension CaptureCoordinator: CaptureViewControllerDelegate { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift index 4075e38f..a192338c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureView.swift @@ -53,22 +53,71 @@ final class CaptureView: UIView { override func layoutSubviews() { super.layoutSubviews() - - // 프리뷰 레이어 조정 previewLayer.frame = preview.bounds } // MARK: - Methods + func showToolItem() { + albumButton.isHidden = false + captureButton.isHidden = false + cameraSwitchingButton.isHidden = false + + albumButton.transform = .identity + captureButton.transform = .identity + cameraSwitchingButton.transform = .identity + } + + func hideToolItem(translationY: CGFloat) { + let transform = CGAffineTransform(translationX: 0, y: translationY) + albumButton.transform = transform + captureButton.transform = transform + cameraSwitchingButton.transform = transform + + albumButton.isHidden = true + captureButton.isHidden = true + cameraSwitchingButton.isHidden = true + } + func updatePreviewLayer(session: AVCaptureSession) { previewLayer.session = session } func changeToBackCamera() { cameraSwitchingButton.setImage(SymbolImage.iphone, for: .normal) + UIView.transition( + with: cameraSwitchingButton, + duration: 0.2, + options: .transitionFlipFromLeft, + animations: nil, + completion: nil + ) + + UIView.transition( + with: preview, + duration: 0.2, + options: .transitionFlipFromLeft, + animations: nil, + completion: nil + ) } func changeToFrontCamera() { cameraSwitchingButton.setImage(SymbolImage.iphoneCamera, for: .normal) + UIView.transition( + with: cameraSwitchingButton, + duration: 0.2, + options: .transitionFlipFromRight, + animations: nil, + completion: nil + ) + + UIView.transition( + with: preview, + duration: 0.2, + options: .transitionFlipFromRight, + animations: nil, + completion: nil + ) } } @@ -87,7 +136,7 @@ private extension CaptureView { captureButton.atl .size(width: CaptureButton.defaultSize, height: CaptureButton.defaultSize) .centerX(equalTo: centerXAnchor) - .bottom(equalTo: bottomAnchor, constant: -36) + .bottom(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -5) } func setupPhotoButton() { @@ -111,9 +160,16 @@ private extension CaptureView { addSubview(preview) preview.atl .height(equalTo: preview.widthAnchor) - .centerY(equalTo: safeAreaLayoutGuide.centerYAnchor, constant: -50) - .horizontal(equalTo: safeAreaLayoutGuide) + .centerX(equalTo: safeAreaLayoutGuide.centerXAnchor) + .centerY(greaterThanOrEqualTo: centerYAnchor, constant: -20) + let widthConstraint = preview.widthAnchor.constraint(equalTo: safeAreaLayoutGuide.widthAnchor) + widthConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + preview.widthAnchor.constraint(lessThanOrEqualToConstant: 400), + widthConstraint + ]) + // PreviewLayer를 Preview 에 넣기 previewLayer.backgroundColor = UIColor.primaryGray.cgColor previewLayer.videoGravity = .resizeAspectFill diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift index 77d2fc99..e334ca63 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Capture/CaptureViewController.swift @@ -23,7 +23,9 @@ final class CaptureViewController: BaseViewController, VibrationVie weak var delegate: CaptureViewControllerDelegate? weak var coordinator: CaptureCoordinator? private let group: Group? - + // viewDidAppear가 처음 호출되는지 확인 + private var isFirstAppear = false + // Capture Session private var isBackCamera = true private var session: AVCaptureSession? @@ -46,14 +48,35 @@ final class CaptureViewController: BaseViewController, VibrationVie // MARK: - Life Cycles override func viewDidLoad() { super.viewDidLoad() + setupNavigationBar() addTargets() checkCameraPermissions() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if let tabBarController = tabBarController as? TabBarViewController { + tabBarController.hideTabBar() + layoutView.hideToolItem(translationY: tabBarController.tabBarHeight + 30) + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startSession() - layoutView.captureButton.isEnabled = true + + if !isFirstAppear { + isFirstAppear = true + + UIView.animate(withDuration: 0.3, animations: { + self.layoutView.showToolItem() + }, completion: { _ in + self.layoutView.captureButton.isEnabled = true + }) + } else { + layoutView.showToolItem() + layoutView.captureButton.isEnabled = true + } } override func viewWillDisappear(_ animated: Bool) { @@ -83,15 +106,33 @@ final class CaptureViewController: BaseViewController, VibrationVie delegate?.didCapture(image: downsampledImage) } + + // MARK: - Setup + private func setupNavigationBar() { + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "취소", style: .plain, target: self, + action: #selector(cancelButtonDidClicked) + ) + + navigationItem.rightBarButtonItem = nil + } + + @objc private func cancelButtonDidClicked() { + coordinator?.finish() + } } // MARK: - Camera -extension CaptureViewController { - private func checkCameraPermissions() { +private extension CaptureViewController { + func checkCameraPermissions() { switch AVCaptureDevice.authorizationStatus(for: .video) { case .notDetermined: // 첫 권한 요청 AVCaptureDevice.requestAccess(for: .video) { [weak self] isAllowed in - guard isAllowed else { return } // 사용자가 권한 거부 + // 사용자가 권한 거부 + guard isAllowed else { + self?.moveToSetting() + return + } DispatchQueue.main.async { // 사용자가 권한 허용 self?.setupCamera() @@ -99,8 +140,10 @@ extension CaptureViewController { } case .restricted: // 제한 Logger.debug("권한 제한") + moveToSetting() case .denied: // 이미 권한 거부 되어 있는 상태 Logger.debug("권한 거부") + moveToSetting() case .authorized: // 이미 권한 허용되어 있는 상태 Logger.debug("권한 허용") setupCamera() @@ -108,8 +151,19 @@ extension CaptureViewController { break } } + + func moveToSetting() { + let message = "도전 기록 촬영을 위한 카메라 권한이 필요합니다.\n설정에서 권한을 허용해 주세요." + showTwoButtonAlert(title: "카메라 권한 없음", message: message, okTitle: "설정으로 이동", okAction: { + guard let settingURL = URL(string: UIApplication.openSettingsURLString) else { return } + + if UIApplication.shared.canOpenURL(settingURL) { + UIApplication.shared.open(settingURL) + } + }) + } - private func setupCamera() { + func setupCamera() { setupBackCamera() setupFrontCamera() @@ -153,7 +207,7 @@ extension CaptureViewController { } // 후면 카메라 설정 - private func setupBackCamera() { + func setupBackCamera() { if let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), let backCameraInput = try? AVCaptureDeviceInput(device: backCamera) { self.backCameraInput = backCameraInput @@ -163,7 +217,7 @@ extension CaptureViewController { } // 전면 카메라 설정 - private func setupFrontCamera() { + func setupFrontCamera() { if let frontCamera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front), let frontCameraInput = try? AVCaptureDeviceInput(device: frontCamera) { self.frontCameraInput = frontCameraInput @@ -172,7 +226,7 @@ extension CaptureViewController { } } - private func startSession() { + func startSession() { guard let session = session else { return } layoutView.updatePreviewLayer(session: session) @@ -185,7 +239,7 @@ extension CaptureViewController { } } - private func stopSession() { + func stopSession() { guard let session = session else { return } layoutView.captureButton.isEnabled = false @@ -197,18 +251,18 @@ extension CaptureViewController { } } - @objc private func didClickedShutterButton() { + @objc func didClickedShutterButton() { vibration(.soft) layoutView.captureButton.isEnabled = false // 사진 찍기! #if targetEnvironment(simulator) // Simulator Logger.debug("시뮬레이터에선 카메라를 테스트할 수 없습니다. 실기기를 연결해 주세요.") - let randomImage = [ - MotiImage.sample1, MotiImage.sample2, MotiImage.sample3, - MotiImage.sample4, MotiImage.sample5, MotiImage.sample6, MotiImage.sample7 - ].randomElement()! - capturedPicture(image: randomImage) +// let randomImage = [ +// MotiImage.sample1, MotiImage.sample2, MotiImage.sample3, +// MotiImage.sample4, MotiImage.sample5, MotiImage.sample6, MotiImage.sample7 +// ].randomElement()! +// capturedPicture(image: randomImage) #else // - speed: 약간의 노이즈 감소만이 적용 // - balanced: speed보다 약간 더 느리지만 더 나은 품질을 얻음 diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/BaseViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/BaseViewController.swift index 2b85b296..57333793 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/BaseViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/BaseViewController.swift @@ -62,6 +62,7 @@ extension BaseViewController { message: String? = nil, okTitle: String? = "확인", okAction: (() -> Void)? = nil, + cancelTitle: String? = "취소", cancelAction: (() -> Void)? = nil ) { let alertVC = AlertFactory.makeTwoButtonAlert( @@ -69,7 +70,7 @@ extension BaseViewController { message: message, okTitle: okTitle, okAction: okAction, - cancelTitle: "취소", + cancelTitle: cancelTitle, cancelAction: cancelAction ) present(alertVC, animated: true) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift index dc356887..cc588d38 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Common/TextViewBottomSheet.swift @@ -33,7 +33,7 @@ final class TextViewBottomSheet: UIViewController { // MARK: - Init init(text: String? = nil) { super.init(nibName: nil, bundle: nil) - if let text { + if let text, !text.isEmpty { showText(text) } else { showPlaceholder() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift index 2fcbddf1..cb29e0f6 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/DetailAchievement/DetailAchievementView.swift @@ -37,7 +37,7 @@ final class DetailAchievementView: UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.backgroundColor = .primaryDarkGray - imageView.image = MotiImage.skeleton + imageView.image = MotiImage.smallSkeleton imageView.clipsToBounds = true return imageView }() @@ -144,10 +144,16 @@ private extension DetailAchievementView { private func setupImageView() { scrollView.addSubview(imageView) imageView.atl - .top(equalTo: titleLabel.bottomAnchor, constant: 10) - .left(equalTo: safeAreaLayoutGuide.leftAnchor) - .right(equalTo: safeAreaLayoutGuide.rightAnchor) .height(equalTo: imageView.widthAnchor) + .top(equalTo: titleLabel.bottomAnchor, constant: 10) + .centerX(equalTo: safeAreaLayoutGuide.centerXAnchor) + + let widthConstraint = imageView.widthAnchor.constraint(equalTo: safeAreaLayoutGuide.widthAnchor) + widthConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 400), + widthConstraint + ]) } private func setupBodyTitleLabel() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift index 079b7218..8c89f26d 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/EditAchievement/EditAchievementView.swift @@ -116,25 +116,34 @@ extension EditAchievementView { private func setupCategoryButton() { addSubview(categoryButton) categoryButton.atl + .top(greaterThanOrEqualTo: safeAreaLayoutGuide.topAnchor) + .bottom(greaterThanOrEqualTo: titleTextField.topAnchor, constant: -5) .left(equalTo: safeAreaLayoutGuide.leftAnchor, constant: 20) - .bottom(equalTo: titleTextField.topAnchor, constant: -5) } private func setupTitleTextField() { addSubview(titleTextField) titleTextField.atl .horizontal(equalTo: safeAreaLayoutGuide, constant: 20) - .bottom(equalTo: resultImageView.topAnchor, constant: -10) + .bottom(greaterThanOrEqualTo: resultImageView.topAnchor, constant: -10) + .bottom(lessThanOrEqualTo: resultImageView.topAnchor, constant: 0) } private func setupResultImageView() { addSubview(resultImageView) resultImageView.atl - .horizontal(equalTo: safeAreaLayoutGuide) .height(equalTo: resultImageView.widthAnchor) - .centerY(equalTo: safeAreaLayoutGuide.centerYAnchor, constant: -50) - } + .centerX(equalTo: safeAreaLayoutGuide.centerXAnchor) + .centerY(greaterThanOrEqualTo: centerYAnchor, constant: -20) + let widthConstraint = resultImageView.widthAnchor.constraint(equalTo: safeAreaLayoutGuide.widthAnchor) + widthConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + resultImageView.widthAnchor.constraint(lessThanOrEqualToConstant: 400), + widthConstraint + ]) + } + private func setupCategoryPickerView() { addSubview(categoryPickerView) addSubview(selectDoneButton) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementView.swift index e0f61554..75f2fe0f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementView.swift @@ -36,7 +36,7 @@ final class GroupDetailAchievementView: UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.backgroundColor = .primaryDarkGray - imageView.image = MotiImage.skeleton + imageView.image = MotiImage.smallSkeleton imageView.clipsToBounds = true return imageView }() @@ -196,10 +196,16 @@ private extension GroupDetailAchievementView { private func setupImageView() { scrollView.addSubview(imageView) imageView.atl - .top(equalTo: titleLabel.bottomAnchor, constant: 10) - .left(equalTo: safeAreaLayoutGuide.leftAnchor) - .right(equalTo: safeAreaLayoutGuide.rightAnchor) .height(equalTo: imageView.widthAnchor) + .top(equalTo: titleLabel.bottomAnchor, constant: 10) + .centerX(equalTo: safeAreaLayoutGuide.centerXAnchor) + + let widthConstraint = imageView.widthAnchor.constraint(equalTo: safeAreaLayoutGuide.widthAnchor) + widthConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(lessThanOrEqualToConstant: 400), + widthConstraint + ]) } private func setupEmojiButtons() { diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementViewController.swift index 4fd0a749..56def367 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupDetailAchievement/GroupDetailAchievementViewController.swift @@ -16,7 +16,7 @@ protocol GroupDetailAchievementViewControllerDelegate: DetailAchievementViewCont func blockingUserMenuDidClicked(userCode: String) } -final class GroupDetailAchievementViewController: BaseViewController, HiddenTabBarViewController { +final class GroupDetailAchievementViewController: BaseViewController, HiddenTabBarViewController, VibrationViewController { // MARK: - Properties weak var coordinator: GroupDetailAchievementCoordinator? @@ -85,10 +85,20 @@ final class GroupDetailAchievementViewController: BaseViewController, LoadingIndica func postedAchievement(newAchievement: Achievement) { viewModel.action(.fetchCurrentCategoryInfo) viewModel.action(.postAchievement(newAchievement: newAchievement)) + layoutView.hideEmptyGuideLabel() showCelebrate(with: newAchievement) } @@ -235,7 +236,7 @@ private extension GroupHomeViewController { showTextFieldAlert( title: "그룹원 초대", okTitle: "초대", - placeholder: "초대할 유저의 7자리 유저코드를 입력하세요.", + placeholder: "7자리 유저코드를 입력하세요.", okAction: { text in guard let text = text else { return } Logger.debug("초대할 유저코드: \(text)") @@ -268,9 +269,10 @@ extension GroupHomeViewController: UICollectionViewDelegate { // 카테고리 셀을 눌렀을 때 categoryCellDidSelected(cell: cell, row: indexPath.row) } else if let _ = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { - // 달성 기록 리스트 셀을 눌렀을 때 - // 상세 정보 화면으로 이동 + // 달성 기록 리스트 셀을 눌렀을 때 상세 정보 화면으로 이동 let achievement = viewModel.findAchievement(at: indexPath.row) + // 스켈레톤 아이템 예외 처리 + guard achievement.id >= 0 else { return } coordinator?.moveToGroupDetailAchievementViewController( achievement: achievement, group: viewModel.group @@ -440,11 +442,15 @@ private extension GroupHomeViewController { switch state { case .loading: break + case .isEmpty: + layoutView.showEmptyGuideLabel() case .finish: + layoutView.hideEmptyGuideLabel() isFetchingNextPage = false layoutView.endRefreshing() case .error(let message): Logger.error("Fetch Achievement Error: \(message)") + layoutView.hideEmptyGuideLabel() isFetchingNextPage = false layoutView.endRefreshing() } @@ -476,6 +482,7 @@ private extension GroupHomeViewController { case .loading: showLoadingIndicator() case .success: + viewModel.action(.fetchCurrentCategoryInfo) hideLoadingIndicator() case .failed: hideLoadingIndicator() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupHome/GroupHomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupHome/GroupHomeViewModel.swift index c4da7fa0..6384aaad 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupHome/GroupHomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupHome/GroupHomeViewModel.swift @@ -42,7 +42,7 @@ final class GroupHomeViewModel { achievementDataSource?.update(data: achievements) } } - private let skeletonAchievements: [Achievement] = (-20...(-1)).map { _ in Achievement.makeSkeleton() } + private let skeletonAchievements: [Achievement] = (-20...(-1)).map { Achievement.makeSkeleton(id: $0) } // Blocking private let blockingUserUseCase: BlockingUserUseCase @@ -343,6 +343,9 @@ private extension GroupHomeViewModel { nextRequestValue = achievementListItem.next achievementListState.send(.finish) + if achievements.isEmpty { + achievementListState.send(.isEmpty) + } } catch { if let nextAchievementTask, nextAchievementTask.isCancelled { Logger.debug("NextAchievementTask is Cancelled") diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/Cell/GroupInfoTableViewCell.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/Cell/GroupInfoTableViewCell.swift index 64d11a0e..a2cab2cb 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/Cell/GroupInfoTableViewCell.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/Cell/GroupInfoTableViewCell.swift @@ -35,6 +35,7 @@ final class GroupInfoTableViewCell: UITableViewCell { // MARK: - Setup private extension GroupInfoTableViewCell { func setupUI() { + backgroundColor = .motiBackground accessoryType = .disclosureIndicator addSubview(label) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/GroupInfoView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/GroupInfoView.swift index 8b25168f..cdf1b4e5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/GroupInfoView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupInfo/GroupInfoView.swift @@ -40,6 +40,12 @@ final class GroupInfoView: UIView { return label }() + private let groupCodeLabel: UILabel = { + let label = UILabel() + label.font = .mediumBold + return label + }() + private(set) var tableView: UITableView = { let tableView = UITableView() tableView.register(GroupInfoTableViewCell.self, forCellReuseIdentifier: GroupInfoTableViewCell.identifier) @@ -68,6 +74,8 @@ final class GroupInfoView: UIView { if group.grade != .leader { cameraIcon.isHidden = true } + + groupCodeLabel.text = "@\(group.code)" } func cancelDownloadImage() { @@ -92,6 +100,11 @@ extension GroupInfoView { .top(equalTo: imageView.bottomAnchor, constant: 20) .centerX(equalTo: safeAreaLayoutGuide.centerXAnchor) + addSubview(groupCodeLabel) + groupCodeLabel.atl + .top(equalTo: groupNameLabel.bottomAnchor, constant: 5) + .centerX(equalTo: safeAreaLayoutGuide.centerXAnchor) + addSubview(cameraIcon) cameraIcon.atl .size(width: cameraIconSize, height: cameraIconSize) @@ -100,7 +113,7 @@ extension GroupInfoView { addSubview(tableView) tableView.atl - .top(equalTo: groupNameLabel.bottomAnchor, constant: 40) + .top(equalTo: groupCodeLabel.bottomAnchor, constant: 30) .bottom(equalTo: self.bottomAnchor) .horizontal(equalTo: safeAreaLayoutGuide) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListCoordinator.swift index aed0124a..8a6753cf 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListCoordinator.swift @@ -29,7 +29,8 @@ final class GroupListCoordinator: Coordinator { let groupVM = GroupListViewModel( fetchGroupListUseCase: .init(groupRepository: groupRepository), createGroupUseCase: .init(groupRepository: groupRepository), - dropGroupUseCase: .init(groupRepository: groupRepository) + dropGroupUseCase: .init(groupRepository: groupRepository), + joinGroupUseCase: .init(groupRepository: groupRepository) ) let groupListVC = GroupListViewController(viewModel: groupVM) groupListVC.coordinator = self diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewController.swift index a8965612..72e64eac 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewController.swift @@ -99,6 +99,19 @@ final class GroupListViewController: BaseViewController { present(textFieldAlertVC, animated: true) } + + @objc private func showJoinGroupTextFieldAlert() { + showTextFieldAlert( + title: "그룹 참가", + okTitle: "참가", + placeholder: "7자리 그룹코드를 입력하세요.", + okAction: { [weak self] text in + guard let self, let text else { return } + Logger.debug("그룹 참가 입력: \(text)") + viewModel.action(.join(groupCode: text)) + } + ) + } } // MARK: - Setup @@ -139,13 +152,20 @@ private extension GroupListViewController { title: "생성", style: .plain, target: self, action: #selector(showCreateGroupTextFieldAlert) ) + + let joinGroupItem = UIBarButtonItem( + title: "참가", style: .plain, target: self, + action: #selector(showJoinGroupTextFieldAlert) + ) - navigationItem.rightBarButtonItems = [profileItem, createGroupItem] + navigationItem.rightBarButtonItems = [profileItem, createGroupItem, joinGroupItem] } @objc func showUserCode() { if let userCode = UserDefaults.standard.readString(key: .myUserCode) { - showOneButtonAlert(title: "유저 코드", message: userCode) + showTwoButtonAlert(title: "유저 코드", message: userCode, okTitle: "확인", cancelTitle: "클립보드 복사", cancelAction: { + UIPasteboard.general.string = userCode + }) } } } @@ -181,6 +201,11 @@ extension GroupListViewController: UITextFieldDelegate { // MARK: - Bind extension GroupListViewController: LoadingIndicator { func bind() { + bindGroupList() + bindGroup() + } + + func bindGroupList() { viewModel.$groupListState .receive(on: DispatchQueue.main) .sink { [weak self] state in @@ -200,6 +225,31 @@ extension GroupListViewController: LoadingIndicator { } .store(in: &cancellables) + viewModel.refetchGroupListState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + switch state { + case .loading: + showLoadingIndicator() + case .finishSame: + hideLoadingIndicator() + case .finishDecreased: + hideLoadingIndicator() + showOneButtonAlert(title: "그룹에서 탈퇴되었습니다.") + case .finishIncreased: + hideLoadingIndicator() + showOneButtonAlert(title: "새로운 그룹이 있습니다!") + case .error(let message): + hideLoadingIndicator() + showErrorAlert(message: message) + } + } + .store(in: &cancellables) + + } + + func bindGroup() { viewModel.createGroupState .receive(on: DispatchQueue.main) .sink { [weak self] state in @@ -232,21 +282,16 @@ extension GroupListViewController: LoadingIndicator { } .store(in: &cancellables) - viewModel.refetchGroupListState + viewModel.joinGroupState .receive(on: DispatchQueue.main) .sink { [weak self] state in guard let self else { return } switch state { case .loading: showLoadingIndicator() - case .finishSame: - hideLoadingIndicator() - case .finishDecreased: - hideLoadingIndicator() - showOneButtonAlert(title: "그룹에서 탈퇴되었습니다.") - case .finishIncreased: + case .finish: hideLoadingIndicator() - showOneButtonAlert(title: "새로운 그룹에 초대되었습니다!") + viewModel.action(.refetch) case .error(let message): hideLoadingIndicator() showErrorAlert(message: message) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewModel.swift index 8de411e9..3f5eccd5 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupList/GroupListViewModel.swift @@ -16,6 +16,7 @@ final class GroupListViewModel { case createGroup(groupName: String) case dropGroup(groupId: Int) case refetch + case join(groupCode: String) } enum GroupListState { @@ -44,6 +45,12 @@ final class GroupListViewModel { case finishIncreased case error(message: String) } + + enum JoinGroupState { + case loading + case finish + case error(message: String) + } typealias GroupDataSource = ListDiffableDataSource @@ -51,6 +58,7 @@ final class GroupListViewModel { private let fetchGroupListUseCase: FetchGroupListUseCase private let createGroupUseCase: CreateGroupUseCase private let dropGroupUseCase: DropGroupUseCase + private let joinGroupUseCase: JoinGroupUseCase private var groupDataSource: GroupDataSource? private var groups: [Group] = [] { didSet { @@ -62,16 +70,19 @@ final class GroupListViewModel { private(set) var createGroupState = PassthroughSubject() private(set) var dropGroupState = PassthroughSubject() private(set) var refetchGroupListState = PassthroughSubject() + private(set) var joinGroupState = PassthroughSubject() // MARK: - Init init( fetchGroupListUseCase: FetchGroupListUseCase, createGroupUseCase: CreateGroupUseCase, - dropGroupUseCase: DropGroupUseCase + dropGroupUseCase: DropGroupUseCase, + joinGroupUseCase: JoinGroupUseCase ) { self.fetchGroupListUseCase = fetchGroupListUseCase self.createGroupUseCase = createGroupUseCase self.dropGroupUseCase = dropGroupUseCase + self.joinGroupUseCase = joinGroupUseCase } // MARK: - Setup @@ -94,6 +105,8 @@ final class GroupListViewModel { dropGroup(groupId: groupId) case .refetch: refetchGroupList() + case .join(let groupCode): + joinGroup(groupCode: groupCode) } } } @@ -104,7 +117,8 @@ extension GroupListViewModel { Task { groupListState = .loading do { - groups = try await fetchGroupListUseCase.execute() + let groups = try await fetchGroupListUseCase.execute() + self.groups = groups.sorted { $0.lastChallenged ?? .distantPast > $1.lastChallenged ?? .distantPast } groupListState = .finish } catch { Logger.error("\(#function) error: \(error.localizedDescription)") @@ -134,7 +148,8 @@ extension GroupListViewModel { } else { refetchGroupListState.send(.finishDecreased) } - groups = newGroups + + groups = newGroups.sorted { $0.lastChallenged ?? .distantPast > $1.lastChallenged ?? .distantPast } } catch { Logger.error("\(#function) error: \(error.localizedDescription)") refetchGroupListState.send(.error(message: error.localizedDescription)) @@ -175,6 +190,24 @@ extension GroupListViewModel { } } + private func joinGroup(groupCode: String) { + Task { + joinGroupState.send(.loading) + do { + let requestValue = JoinGroupRequestValue(groupCode: groupCode) + let isSuccess = try await joinGroupUseCase.execute(requestValue: requestValue) + if isSuccess { + joinGroupState.send(.finish) + } else { + joinGroupState.send(.error(message: "\(groupCode) 그룹 참가에 실패했습니다.")) + } + } catch { + Logger.error("\(#function) error: \(error.localizedDescription)") + joinGroupState.send(.error(message: error.localizedDescription)) + } + } + } + private func deleteOfDataSource(groupId: Int) { groups = groups.filter { $0.id != groupId } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/GroupMember/GroupMemberViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/GroupMember/GroupMemberViewController.swift index 92ce6a17..4747c3ee 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/GroupMember/GroupMemberViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/GroupMember/GroupMemberViewController.swift @@ -34,8 +34,6 @@ final class GroupMemberViewController: BaseViewController, Hidd super.viewDidLoad() title = manageMode ? "그룹원 관리" : "그룹원" setupGroupMemberDataSource() - - bind() viewModel.action(.launch) } @@ -59,21 +57,6 @@ final class GroupMemberViewController: BaseViewController, Hidd let diffableDataSource = GroupMemberViewModel.GroupMemberDataSource(dataSource: dataSource) viewModel.setupDataSource(diffableDataSource) } - - private func bind() { - viewModel.groupMemberListState - .receive(on: RunLoop.main) - .sink { [weak self] state in - guard let self else { return } - switch state { - case .success: - break - case .failed(let message): - Logger.error("Fetch Group Member Error: \(message)") - } - } - .store(in: &cancellables) - } } extension GroupMemberViewController: UICollectionViewDelegate { @@ -82,9 +65,8 @@ extension GroupMemberViewController: UICollectionViewDelegate { extension GroupMemberViewController: GroupMemberCollectionViewCellDelegate { func menuDidClicked(groupMember: GroupMember, newGroupGrade: GroupGrade) { - showTwoButtonAlert(title: "\(newGroupGrade.description) 권한으로 수정하시겠습니까?", okTitle: "수정") { [weak self] in - guard let self else { return } + showTwoButtonAlert(title: "\(newGroupGrade.description) 권한으로 수정하시겠습니까?", okTitle: "수정", okAction: { self.viewModel.action(.updateGrade(groupMember: groupMember, newGroupGrade: newGroupGrade)) - } + }) } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeActionState.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeActionState.swift index 422d567e..d91af804 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeActionState.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeActionState.swift @@ -47,6 +47,7 @@ extension HomeViewModel { enum AchievementListState { case initial case loading + case isEmpty case finish case error(message: String) } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift index 004fb346..a7f48c65 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeView.swift @@ -58,6 +58,18 @@ final class HomeView: UIView { return refreshControl }() + // 아이템이 없을 때 표시하는 Label + private let emptyGuideLabel = { + let label = UILabel() + label.isHidden = true + label.text = "도전 기록이 없습니다.\n새로운 도전을 기록해 보세요." + label.numberOfLines = 2 + label.textAlignment = .center + label.font = .systemFont(ofSize: 16) + label.alpha = 0.5 + return label + }() + // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) @@ -85,6 +97,14 @@ final class HomeView: UIView { } } } + + func showEmptyGuideLabel() { + emptyGuideLabel.isHidden = false + } + + func hideEmptyGuideLabel() { + emptyGuideLabel.isHidden = true + } } // MARK: - SetUp @@ -93,6 +113,7 @@ private extension HomeView { setupCategoryAddButton() setupCategoryCollectionView() setupAchievementCollectionView() + setupEmptyGuideLabel() } private func setupCategoryAddButton() { @@ -122,17 +143,24 @@ private extension HomeView { private func setupAchievementCollectionView() { addSubview(achievementCollectionView) achievementCollectionView.atl - .width(equalTo: self.widthAnchor) + .horizontal(equalTo: safeAreaLayoutGuide) .top(equalTo: categoryCollectionView.bottomAnchor, constant: 10) .bottom(equalTo: self.bottomAnchor) } + + private func setupEmptyGuideLabel() { + addSubview(emptyGuideLabel) + emptyGuideLabel.atl + .center(of: safeAreaLayoutGuide) + } } private extension HomeView { func makeAchievementCollectionView() -> UICollectionViewLayout { + let count: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 7 : 3 let itemPadding: CGFloat = 1 let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0 / 3), + widthDimension: .fractionalWidth(1.0 / count), heightDimension: .fractionalHeight(1)) let itemInset = NSDirectionalEdgeInsets(top: itemPadding, leading: itemPadding, bottom: itemPadding, trailing: itemPadding) diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift index c9fedcb7..f60d729c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewController.swift @@ -70,6 +70,7 @@ final class HomeViewController: BaseViewController, LoadingIndicator, if let tabBarController = tabBarController as? TabBarViewController { tabBarController.showTabBar() } + layoutView.hideEmptyGuideLabel() viewModel.action(.fetchCurrentCategoryInfo) viewModel.action(.postAchievement(newAchievement: newAchievement)) showCelebrate(with: newAchievement) @@ -88,7 +89,6 @@ final class HomeViewController: BaseViewController, LoadingIndicator, if let tabBarController = tabBarController as? TabBarViewController, tabBarController.selectedIndex == 0 { coordinator?.moveToCaptureViewController() - tabBarController.hideTabBar() } } @@ -241,7 +241,9 @@ private extension HomeViewController { @objc func showUserCode() { if let userCode = UserDefaults.standard.readString(key: .myUserCode) { - showOneButtonAlert(title: "유저 코드", message: userCode) + showTwoButtonAlert(title: "유저 코드", message: userCode, okTitle: "확인", cancelTitle: "클립보드 복사", cancelAction: { + UIPasteboard.general.string = userCode + }) } } } @@ -254,9 +256,10 @@ extension HomeViewController: UICollectionViewDelegate { // 카테고리 셀을 눌렀을 때 categoryCellDidSelected(cell: cell, row: indexPath.row) } else if let _ = collectionView.cellForItem(at: indexPath) as? AchievementCollectionViewCell { - // 달성 기록 리스트 셀을 눌렀을 때 - // 상세 정보 화면으로 이동 + // 달성 기록 리스트 셀을 눌렀을 때 상세 정보 화면으로 이동 let achievement = viewModel.findAchievement(at: indexPath.row) + // 스켈레톤 아이템 예외 처리 + guard achievement.id >= 0 else { return } coordinator?.moveToDetailAchievementViewController(achievement: achievement) } } @@ -384,10 +387,14 @@ private extension HomeViewController { // state 에 따른 뷰 처리 - 스켈레톤 뷰, fetch 에러 뷰 등 Logger.debug(state) switch state { + case .isEmpty: + layoutView.showEmptyGuideLabel() case .finish: + layoutView.hideEmptyGuideLabel() isFetchingNextPage = false layoutView.endRefreshing() case .error(let message): + layoutView.hideEmptyGuideLabel() isFetchingNextPage = false layoutView.endRefreshing() Logger.error("Fetch Achievement Error: \(message)") @@ -424,6 +431,7 @@ private extension HomeViewController { case .loading: showLoadingIndicator() case .success: + viewModel.action(.fetchCurrentCategoryInfo) hideLoadingIndicator() case .failed: hideLoadingIndicator() diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift index 4a48c771..9b1cc7a9 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Home/HomeViewModel.swift @@ -35,7 +35,7 @@ final class HomeViewModel { private let deleteAchievementUseCase: DeleteAchievementUseCase private let fetchDetailAchievementUseCase: FetchDetailAchievementUseCase - private let skeletonAchievements: [Achievement] = (-20...(-1)).map { _ in Achievement.makeSkeleton() } + private let skeletonAchievements: [Achievement] = (-20...(-1)).map { Achievement.makeSkeleton(id: $0) } private var achievements: [Achievement] = [] { didSet { achievementDataSource?.update(data: achievements) @@ -285,6 +285,9 @@ private extension HomeViewModel { nextRequestValue = achievementListItem.next achievementListState = .finish + if achievements.isEmpty { + achievementListState = .isEmpty + } } catch { if let nextAchievementTask, nextAchievementTask.isCancelled { Logger.debug("NextAchievementTask is Cancelled") diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchCoodinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchCoodinator.swift index 2ad17927..39d9e591 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchCoodinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Launch/LaunchCoodinator.swift @@ -31,7 +31,7 @@ public final class LaunchCoodinator: Coordinator { public func start() { let autoLoginUseCase = AutoLoginUseCase( - repository: LoginRepository(), + repository: AuthRepository(), keychainStorage: KeychainStorage.shared ) let launchVM = LaunchViewModel( diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginCoordinator.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginCoordinator.swift index b5f917c5..2b1fb876 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginCoordinator.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginCoordinator.swift @@ -30,7 +30,7 @@ public final class LoginCoordinator: Coordinator { } public func start() { - let loginUseCase = LoginUseCase(repository: LoginRepository(), keychainStorage: KeychainStorage.shared) + let loginUseCase = LoginUseCase(repository: AuthRepository(), keychainStorage: KeychainStorage.shared) let loginVM = LoginViewModel(loginUseCase: loginUseCase) let loginVC = LoginViewController(viewModel: loginVM) loginVC.coordinator = self @@ -40,7 +40,7 @@ public final class LoginCoordinator: Coordinator { } public func startWithAlert(message: String) { - let loginUseCase = LoginUseCase(repository: LoginRepository(), keychainStorage: KeychainStorage.shared) + let loginUseCase = LoginUseCase(repository: AuthRepository(), keychainStorage: KeychainStorage.shared) let loginVM = LoginViewModel(loginUseCase: loginUseCase) let loginVC = LoginViewController(viewModel: loginVM, alertMessage: message) loginVC.coordinator = self diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginViewController.swift index 7c15f8ad..39dbc65c 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/Login/LoginViewController.swift @@ -101,12 +101,11 @@ final class LoginViewController: BaseViewController { } extension LoginViewController: AppleLoginRequesterDelegate { - func success(token: String) { - Logger.debug("애플에서 전달된 token: \(token)") + func success(token: String, authorizationCode: String) { viewModel.action(.login(identityToken: token)) } - func failed(error: Error) { + func failed(message: String) { showOneButtonAlert(title: "로그인 실패", message: "다시 시도해 주세요.") } } diff --git a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarViewController.swift b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarViewController.swift index 4a02f386..8ec97d0f 100644 --- a/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarViewController.swift +++ b/iOS/moti/moti/Presentation/Sources/Presentation/TabBar/TabBarViewController.swift @@ -15,7 +15,7 @@ final class TabBarViewController: UITabBarController, VibrationViewController { private let borderView = UIView() // MARK: - Properties - private var tabBarHeight: CGFloat { + var tabBarHeight: CGFloat { return tabBar.frame.height } private var isShowing = true @@ -148,6 +148,6 @@ private extension TabBarViewController { captureButton.atl .size(width: CaptureButton.defaultSize, height: CaptureButton.defaultSize) .centerX(equalTo: view.centerXAnchor) - .bottom(equalTo: view.bottomAnchor, constant: -36) + .bottom(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -5) } } diff --git a/iOS/moti/moti/Resource/Info.plist b/iOS/moti/moti/Resource/Info.plist index c278ca20..02aa487e 100644 --- a/iOS/moti/moti/Resource/Info.plist +++ b/iOS/moti/moti/Resource/Info.plist @@ -4,6 +4,8 @@ BASE_URL $(BASE_URL) + ITSAppUsesNonExemptEncryption + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -21,7 +23,5 @@ - NSCameraUsageDescription - 목표 기록에 넣을 사진을 찍기 위한 카메라 권한이 필요합니다.