├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── BE-CD.yml │ ├── BE-CI.yml │ └── swift.yml ├── .gitignore ├── BE ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .gitkeep ├── .prettierrc ├── Dockerfile ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── chat │ │ ├── chat.controller.ts │ │ ├── chat.module.ts │ │ ├── chat.service.ts │ │ ├── chats.gateway.ts │ │ └── dto │ │ │ ├── chat.dto.ts │ │ │ └── createRoom.dto.ts │ ├── common │ │ ├── base.repository.ts │ │ ├── decorator │ │ │ ├── auth.decorator.ts │ │ │ ├── multiPartBody.decorator.ts │ │ │ └── price.decorator.ts │ │ ├── fcmHandler.ts │ │ ├── files.validator.ts │ │ ├── greenEyeHandler.ts │ │ ├── guard │ │ │ └── auth.guard.ts │ │ ├── hashMaker.ts │ │ └── interceptor │ │ │ ├── httpLogger.interceptor.ts │ │ │ └── transaction.interceptor.ts │ ├── config │ │ ├── jwt.config.ts │ │ ├── mysql.config.ts │ │ ├── redis.config.ts │ │ ├── s3.config.ts │ │ ├── swagger.config.ts │ │ └── winston.config.ts │ ├── entities │ │ ├── blockPost.entity.ts │ │ ├── blockUser.entity.ts │ │ ├── chat.entity.ts │ │ ├── chatRoom.entity.ts │ │ ├── post.entity.ts │ │ ├── postImage.entity.ts │ │ ├── registrationToken.entity.ts │ │ ├── report.entity.ts │ │ └── user.entity.ts │ ├── image │ │ ├── image.module.ts │ │ ├── image.service.spec.ts │ │ ├── image.service.ts │ │ └── postImage.repository.ts │ ├── login │ │ ├── dto │ │ │ └── appleLogin.dto.ts │ │ ├── login.controller.ts │ │ ├── login.module.ts │ │ └── login.service.ts │ ├── main.ts │ ├── notification │ │ ├── notification.module.ts │ │ ├── notification.service.spec.ts │ │ ├── notification.service.ts │ │ └── registrationToken.repository.ts │ ├── post │ │ ├── dto │ │ │ ├── postCreate.dto.ts │ │ │ ├── postList.dto.ts │ │ │ └── postUpdate.dto.ts │ │ ├── post.controller.ts │ │ ├── post.module.ts │ │ ├── post.repository.spec.ts │ │ ├── post.repository.ts │ │ ├── post.service.spec.ts │ │ └── post.service.ts │ ├── posts-block │ │ ├── posts-block.controller.ts │ │ ├── posts-block.module.ts │ │ ├── posts-block.service.spec.ts │ │ └── posts-block.service.ts │ ├── report │ │ ├── dto │ │ │ └── createReport.dto.ts │ │ ├── report.controller.ts │ │ ├── report.module.ts │ │ ├── report.service.spec.ts │ │ └── report.service.ts │ ├── users-block │ │ ├── users-block.controller.ts │ │ ├── users-block.module.ts │ │ ├── users-block.service.spec.ts │ │ └── users-block.service.ts │ └── users │ │ ├── dto │ │ ├── createUser.dto.ts │ │ └── usersUpdate.dto.ts │ │ ├── user.repository.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ ├── users.service.spec.ts │ │ └── users.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── README.md └── iOS ├── .gitattributes ├── .gitignore ├── .gitkeep └── Village ├── .swiftlint.yml ├── Village.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Village.xcscheme ├── Village ├── Application │ ├── AppDelegate.swift │ ├── AppTabBarController.swift │ ├── GoogleService-Info.plist │ ├── LaunchScreen.storyboard │ └── SceneDelegate.swift ├── Cache │ └── ImageCache.swift ├── Common │ ├── Constants.swift │ ├── Encodable+.swift │ ├── Int+.swift │ ├── NotificationName+.swift │ ├── PostNotificationPublisher.swift │ └── PostType.swift ├── Data │ ├── Dummy │ │ ├── ChatList.json │ │ ├── ChatRoom.json │ │ ├── Post.json │ │ ├── PostMute.json │ │ ├── Posts.json │ │ └── Users.json │ ├── Network │ │ └── APIEndPoints.swift │ └── PersistentStorage │ │ ├── FCMManager.swift │ │ ├── JWTManager.swift │ │ ├── KeychainError.swift │ │ └── KeychainManager.swift ├── Domain │ ├── Entity │ │ └── AuthenticationToken.swift │ └── Network │ │ ├── AppleOAuthDTO.swift │ │ ├── BlockedUserDTO.swift │ │ ├── ChatRoomRequestDTO.swift │ │ ├── GetAllReadResponseDTO .swift │ │ ├── GetChatListResponseDTO.swift │ │ ├── GetRoomResponseDTO.swift │ │ ├── PatchUserDTO.swift │ │ ├── PostCreateDTO.swift │ │ ├── PostListRequestDTO.swift │ │ ├── PostModifyRequestDTO.swift │ │ ├── PostMuteResponseDTO.swift │ │ ├── PostResponseDTO.swift │ │ ├── PostRoomRequestDTO.swift │ │ ├── PostRoomResponseDTO.swift │ │ ├── Protocol │ │ └── MultipartFormData.swift │ │ ├── ReportDTO.swift │ │ ├── RequestFilterDTO.swift │ │ └── UserResponseDTO.swift ├── Network │ ├── APIProvider │ │ ├── APIProvider.swift │ │ └── AuthInterceptor.swift │ ├── EndPoint │ │ ├── EndPoint.swift │ │ ├── HTTPMethod.swift │ │ ├── Protocol │ │ │ ├── Requestable.swift │ │ │ └── Responsable.swift │ │ ├── Reponsable.swift │ │ └── Requestable.swift │ ├── NetworkError.swift │ └── NetworkService.swift ├── Presentation │ ├── BlockedUser │ │ ├── View │ │ │ ├── BlockedUserTableViewCell.swift │ │ │ └── BlockedUserViewController.swift │ │ └── ViewModel │ │ │ └── BlockedUsersViewModel.swift │ ├── ChatList │ │ ├── View │ │ │ ├── ChatListTableViewCell.swift │ │ │ └── ChatListViewController.swift │ │ └── ViewModel │ │ │ └── ChatListViewModel.swift │ ├── ChatRoom │ │ ├── View │ │ │ ├── ChatRoomTableViewCell.swift │ │ │ ├── ChatRoomViewController.swift │ │ │ ├── CustomView │ │ │ │ └── PostView.swift │ │ │ └── OpponentChatTableViewCell.swift │ │ └── ViewModel │ │ │ └── ChatRoomViewModel.swift │ ├── Commons │ │ ├── Cell │ │ │ ├── RentPostTableViewCell.swift │ │ │ └── RequstPostTableViewCell.swift │ │ ├── PostSegmentedControl.swift │ │ ├── RentPostSummaryView.swift │ │ └── RequestPostSummaryView.swift │ ├── EditProfile │ │ ├── View │ │ │ ├── Custom │ │ │ │ ├── NicknameTextField.swift │ │ │ │ └── ProfileImageView.swift │ │ │ └── EditProfileViewController.swift │ │ └── ViewModel │ │ │ └── EditProfileViewModel.swift │ ├── Home │ │ ├── Custom │ │ │ ├── FloatingButton.swift │ │ │ └── MenuView.swift │ │ ├── View │ │ │ └── HomeViewController.swift │ │ └── ViewModel │ │ │ └── HomeViewModel.swift │ ├── Login │ │ ├── LoginViewController.swift │ │ └── ViewModel │ │ │ └── LoginViewModel.swift │ ├── MyHiddenPosts │ │ ├── View │ │ │ ├── HiddenRentPostTableViewCell.swift │ │ │ ├── HiddenRequestPostTableViewCell.swift │ │ │ └── MyHiddenPostsViewController.swift │ │ └── ViewModel │ │ │ └── MyHiddenPostsViewModel.swift │ ├── MyPage │ │ ├── View │ │ │ ├── MyPageTableViewCell.swift │ │ │ ├── MyPageViewController.swift │ │ │ └── ProfileTableViewCell.swift │ │ └── ViewModel │ │ │ └── MyPageViewModel.swift │ ├── MyPosts │ │ ├── View │ │ │ └── MyPostsViewController.swift │ │ └── ViewModel │ │ │ └── MyPostsViewModel.swift │ ├── PostCreate │ │ ├── Model │ │ │ └── ImageItem.swift │ │ ├── View │ │ │ ├── Custom │ │ │ │ ├── Cell │ │ │ │ │ ├── CameraButtonCell.swift │ │ │ │ │ └── ImageViewCell.swift │ │ │ │ ├── ImageUploadView.swift │ │ │ │ ├── PostCreateDetailView.swift │ │ │ │ ├── PostCreatePriceView.swift │ │ │ │ ├── PostCreateTimeView.swift │ │ │ │ └── PostCreateTitleView.swift │ │ │ └── PostCreateViewController.swift │ │ └── ViewModel │ │ │ ├── PostCreateRepository.swift │ │ │ ├── PostCreateUseCase.swift │ │ │ └── PostCreateViewModel.swift │ ├── PostDetail │ │ ├── Custom │ │ │ ├── DateView.swift │ │ │ ├── DurationView.swift │ │ │ ├── ImageDetailView.swift │ │ │ ├── ImagePageView.swift │ │ │ ├── PostInfoView.swift │ │ │ ├── PriceLabel.swift │ │ │ └── UserInfoView.swift │ │ ├── PostDetailViewController.swift │ │ └── ViewModel │ │ │ └── PostDetailViewModel.swift │ ├── Report │ │ ├── View │ │ │ └── ReportViewController.swift │ │ └── ViewModel │ │ │ └── ReportViewModel.swift │ ├── Search │ │ └── SearchViewController.swift │ ├── SearchResult │ │ ├── View │ │ │ ├── SearchRentTableViewCell.swift │ │ │ ├── SearchRequstTableViewCell.swift │ │ │ └── SearchResultViewController.swift │ │ └── ViewModel │ │ │ └── SearchResultViewModel.swift │ └── Utils │ │ └── Extensions │ │ ├── NSItemProvider+.swift │ │ ├── UICollectionViewCell+Identifier.swift │ │ ├── UIImage+Resize.swift │ │ ├── UILabel+SetTitle.swift │ │ ├── UINavigationItem+MakeSFSymbolButton.swift │ │ ├── UIStackView+.swift │ │ ├── UITableViewCell+Identifier.swift │ │ ├── UITextField+.swift │ │ ├── UIView+Divider.swift │ │ └── UIView+Layer.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AlertColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 1024.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 180.png │ │ │ ├── 29.png │ │ │ ├── 40.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Grey-100.colorset │ │ │ └── Contents.json │ │ ├── Grey-800.colorset │ │ │ └── Contents.json │ │ ├── KeyboardTextFieldColor.colorset │ │ │ └── Contents.json │ │ ├── LoginLogo.imageset │ │ │ ├── Contents.json │ │ │ ├── LoginLogo 1.png │ │ │ ├── LoginLogo 2.png │ │ │ └── LoginLogo.png │ │ ├── Logo.imageset │ │ │ ├── Contents.json │ │ │ ├── Logo.png │ │ │ ├── Logo@2x.png │ │ │ └── Logo@3x.png │ │ ├── LogoLabel.imageset │ │ │ ├── Contents.json │ │ │ ├── LogoLabel.png │ │ │ ├── LogoLabel@2x.png │ │ │ └── LogoLabel@3x.png │ │ ├── MyChatMessage.colorset │ │ │ └── Contents.json │ │ ├── Negative-400.colorset │ │ │ └── Contents.json │ │ ├── Primary-100.colorset │ │ │ └── Contents.json │ │ ├── Primary-500.colorset │ │ │ └── Contents.json │ │ ├── UserChatMessage.colorset │ │ │ └── Contents.json │ │ └── WhiteLogo.imageset │ │ │ ├── Contents.json │ │ │ ├── WhiteLogo.png │ │ │ ├── WhiteLogo@2x.png │ │ │ └── WhiteLogo@3x.png │ └── Info.plist ├── Socket │ ├── WebSocket.swift │ ├── WebSocketError.swift │ └── chat-server.ts ├── Village.entitlements └── VillageRelease.entitlements └── VillageTests └── .gitkeep /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 설명 11 | - 12 | 13 | ## 화면 14 | - 15 | 16 | ## 스토리 17 | - 18 | 19 | ## 요구사항 20 | - A 구현 21 | - B 구현 22 | 23 | ## 기타사항 (optional) 24 | - 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 이슈 2 | - #issue_number 3 | 4 | ## 체크리스트 5 | - [x] A 구현 6 | - [x] B 구현 7 | 8 | ## 고민한 내용 9 | - 무슨 이유로 어떻게 코드를 변경했는지 10 | - 어떤 부분에 리뷰어가 집중해야 하는지 11 | - 기술적 고민 12 | 13 | ## 스크린샷 14 | -------------------------------------------------------------------------------- /.github/workflows/BE-CD.yml: -------------------------------------------------------------------------------- 1 | name: Backend CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - BE 7 | jobs: 8 | deploy: 9 | runs-on: self-hosted 10 | 11 | steps: 12 | # 해당 레포의 소스코드가 작업 환경에 복제된다 , checkout 의 2번째 버젼 사용 13 | - name: Checkout repository 14 | uses: actions/checkout@v2 15 | # node.js 를 설치한다. 16 | - name: run npm 17 | run: | 18 | cd BE 19 | npm install 20 | pm2 start npm -- run start:prod 21 | -------------------------------------------------------------------------------- /.github/workflows/BE-CI.yml: -------------------------------------------------------------------------------- 1 | name: Backend CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - BE 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-20.04 11 | 12 | steps: 13 | # 해당 레포의 소스코드가 작업 환경에 복제된다 , checkout 의 2번째 버젼 사용 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | # node.js 를 설치한다. 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: 20.9.x 21 | # npm install 실행 22 | - name: Install dependencies 23 | run: | 24 | cd BE 25 | npm install 26 | # npm test 실행 27 | - name: Build and test 28 | run: | 29 | cd BE 30 | npm run build 31 | npm test 32 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "iOS" ] 9 | pull_request: 10 | branches: [ "iOS" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-13 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: xcodebuild clean build -project iOS/Village/Village.xcodeproj -scheme Village -destination 'platform=iOS Simulator,name=iPhone 13' 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /BE/.dockerignore: -------------------------------------------------------------------------------- 1 | /logs 2 | /node_modules 3 | /dist 4 | /coverage 5 | Dockerfile 6 | -------------------------------------------------------------------------------- /BE/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /BE/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/BE/.gitkeep -------------------------------------------------------------------------------- /BE/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": true, 5 | "tabwidth": 4, 6 | "arrowParens": "always", 7 | "bracketLine": "false", 8 | "printWidth": 80 9 | } 10 | -------------------------------------------------------------------------------- /BE/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | LABEL authors="koomin" 3 | RUN mkdir /app 4 | WORKDIR /app 5 | COPY . . 6 | RUN npm install 7 | RUN npm run build 8 | EXPOSE 3000 9 | CMD ["npm", "run", "start:prod"] -------------------------------------------------------------------------------- /BE/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /BE/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | 4 | describe('AppController', () => { 5 | let appController: AppController; 6 | 7 | beforeEach(async () => { 8 | const app: TestingModule = await Test.createTestingModule({ 9 | controllers: [AppController], 10 | }).compile(); 11 | 12 | appController = app.get(AppController); 13 | }); 14 | 15 | describe('root', () => { 16 | it('should return "Village API Server"', () => { 17 | expect(3).toBe(3); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /BE/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Redirect } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | constructor() {} 6 | 7 | @Get('API') 8 | @Redirect( 9 | 'https://app.swaggerhub.com/apis/koomin1227/Village/1.0.0#/posts/get_posts', 10 | 301, 11 | ) 12 | getApiDocs() {} 13 | } 14 | -------------------------------------------------------------------------------- /BE/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Logger, ValidationPipe } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { WinstonModule } from 'nest-winston'; 5 | import * as process from 'process'; 6 | import { AppController } from './app.controller'; 7 | import { winstonTransportsOption } from './config/winston.config'; 8 | import { MysqlConfigProvider } from './config/mysql.config'; 9 | import { PostModule } from './post/post.module'; 10 | import { APP_PIPE } from '@nestjs/core'; 11 | import { UsersModule } from './users/users.module'; 12 | import { PostsBlockModule } from './posts-block/posts-block.module'; 13 | import { UsersBlockModule } from './users-block/users-block.module'; 14 | import { LoginModule } from './login/login.module'; 15 | import { ChatModule } from './chat/chat.module'; 16 | import { CacheModule } from '@nestjs/cache-manager'; 17 | import { RedisConfigProvider } from './config/redis.config'; 18 | import { ReportModule } from './report/report.module'; 19 | import { ImageModule } from './image/image.module'; 20 | import { NotificationModule } from './notification/notification.module'; 21 | 22 | @Module({ 23 | imports: [ 24 | ConfigModule.forRoot({ 25 | isGlobal: true, 26 | envFilePath: `${process.cwd()}/envs/${process.env.NODE_ENV}.env`, 27 | }), 28 | WinstonModule.forRoot({ 29 | transports: winstonTransportsOption, 30 | }), 31 | TypeOrmModule.forRootAsync({ 32 | useClass: MysqlConfigProvider, 33 | }), 34 | CacheModule.registerAsync({ 35 | isGlobal: true, 36 | useClass: RedisConfigProvider, 37 | }), 38 | PostsBlockModule, 39 | UsersBlockModule, 40 | PostModule, 41 | UsersModule, 42 | LoginModule, 43 | ChatModule, 44 | ReportModule, 45 | ImageModule, 46 | NotificationModule, 47 | ], 48 | controllers: [AppController], 49 | providers: [ 50 | Logger, 51 | { 52 | provide: APP_PIPE, 53 | useClass: ValidationPipe, 54 | }, 55 | ], 56 | }) 57 | export class AppModule {} 58 | -------------------------------------------------------------------------------- /BE/src/chat/chat.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpException, 6 | Param, 7 | Patch, 8 | Post, 9 | Put, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { ChatService } from './chat.service'; 13 | import { AuthGuard } from '../common/guard/auth.guard'; 14 | import { UserHash } from '../common/decorator/auth.decorator'; 15 | import { CreateRoomDto } from './dto/createRoom.dto'; 16 | import { FcmHandler } from '../common/fcmHandler'; 17 | 18 | @Controller('chat') 19 | export class ChatController { 20 | constructor( 21 | private readonly chatService: ChatService, 22 | private readonly fcmHandler: FcmHandler, 23 | ) {} 24 | 25 | @Get('room') 26 | @UseGuards(AuthGuard) 27 | async roomList(@UserHash() userId: string) { 28 | return await this.chatService.findRoomList(userId); 29 | } 30 | 31 | @Get('room/:id') 32 | @UseGuards(AuthGuard) 33 | async roomDetail(@Param('id') id: number, @UserHash() userId: string) { 34 | return await this.chatService.findRoomById(id, userId); 35 | } 36 | 37 | // 게시글에서 채팅하기 버튼 누르면 채팅방 만드는 API (테스트는 안해봄, 좀더 수정 필요) 38 | @Post('room') 39 | @UseGuards(AuthGuard) 40 | async roomCreate(@Body() body: CreateRoomDto, @UserHash() userId: string) { 41 | const isUserPostExist = await this.chatService.isUserPostExist( 42 | body.post_id, 43 | body.writer, 44 | ); 45 | if (!isUserPostExist) { 46 | throw new HttpException('해당 게시글 또는 유저가 없습니다', 404); 47 | } 48 | return await this.chatService.createRoom(body.post_id, userId, body.writer); 49 | } 50 | 51 | @Get('unread') 52 | @UseGuards(AuthGuard) 53 | async unreadChat(@UserHash() userId: string) { 54 | return await this.chatService.unreadChat(userId); 55 | } 56 | 57 | @Patch('leave/:id') 58 | @UseGuards(AuthGuard) 59 | async leaveChatRoom(@Param('id') id: number, @UserHash() userId: string) { 60 | return await this.chatService.leaveChatRoom(id, userId); 61 | } 62 | 63 | @Get() 64 | async testPush(@Body() body) { 65 | await this.fcmHandler.sendPush(body.user, { 66 | title: 'test', 67 | body: 'hello!', 68 | data: { room_id: body.room }, 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /BE/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ChatService } from './chat.service'; 3 | import { ChatController } from './chat.controller'; 4 | import { ChatsGateway } from './chats.gateway'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { ChatRoomEntity } from '../entities/chatRoom.entity'; 7 | import { PostEntity } from '../entities/post.entity'; 8 | import { FcmHandler } from '../common/fcmHandler'; 9 | import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; 10 | import { ChatEntity } from 'src/entities/chat.entity'; 11 | import { UserEntity } from 'src/entities/user.entity'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([ 16 | ChatRoomEntity, 17 | PostEntity, 18 | ChatEntity, 19 | UserEntity, 20 | RegistrationTokenEntity, 21 | ]), 22 | ], 23 | controllers: [ChatController], 24 | providers: [ChatService, ChatsGateway, FcmHandler], 25 | }) 26 | export class ChatModule {} 27 | -------------------------------------------------------------------------------- /BE/src/chat/dto/chat.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class ChatDto { 4 | @IsString() 5 | sender: string; 6 | 7 | @IsString() 8 | message: string; 9 | 10 | @IsString() 11 | room_id: number; 12 | 13 | @IsNumber() 14 | count: number; 15 | } 16 | -------------------------------------------------------------------------------- /BE/src/chat/dto/createRoom.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class CreateRoomDto { 4 | @IsNumber() 5 | post_id: number; 6 | 7 | @IsString() 8 | writer: string; 9 | } 10 | -------------------------------------------------------------------------------- /BE/src/common/base.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, EntityManager, Repository } from 'typeorm'; 2 | import { ENTITY_MANAGER_KEY } from './interceptor/transaction.interceptor'; 3 | 4 | export class BaseRepository { 5 | constructor( 6 | private dataSource: DataSource, 7 | private request: Request, 8 | ) {} 9 | 10 | getRepository(entityCls: new () => T): Repository { 11 | const entityManager: EntityManager = 12 | this.request[ENTITY_MANAGER_KEY] ?? this.dataSource.manager; 13 | return entityManager.getRepository(entityCls); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BE/src/common/decorator/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const UserHash = createParamDecorator( 4 | (data: unknown, ctx: ExecutionContext) => { 5 | // Guard 이후에 실행되므로 jwtToken의 유효성은 보장 6 | const request = ctx.switchToHttp().getRequest(); 7 | return request.userId; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /BE/src/common/decorator/multiPartBody.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const MultiPartBody = createParamDecorator( 4 | (data: string, ctx: ExecutionContext) => { 5 | const request: Request = ctx.switchToHttp().getRequest(); 6 | const body = request.body; 7 | const parsedBody = body?.[data]; 8 | if (parsedBody === undefined) { 9 | return undefined; 10 | } else { 11 | return JSON.parse(parsedBody); 12 | } 13 | // return data ? JSON.parse(body?.[data]) : body; 14 | }, 15 | ); 16 | -------------------------------------------------------------------------------- /BE/src/common/decorator/price.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | } from 'class-validator'; 6 | 7 | export function IsPriceCorrect( 8 | property: string, 9 | validationOptions?: ValidationOptions, 10 | ) { 11 | return (object, propertyName: string) => { 12 | registerDecorator({ 13 | name: 'IsPriceCorrect', 14 | target: object.constructor, 15 | propertyName, 16 | options: { message: 'price is something wrong.', ...validationOptions }, 17 | constraints: [property], 18 | validator: { 19 | validate( 20 | value: any, 21 | validationArguments?: ValidationArguments, 22 | ): Promise | boolean { 23 | const [relatedPropertyName] = validationArguments.constraints; 24 | const relatedValue = (validationArguments.object as any)[ 25 | relatedPropertyName 26 | ]; 27 | if ( 28 | (relatedValue === true && value !== null && value !== undefined) || 29 | (relatedValue === false && (value === undefined || value === null)) 30 | ) { 31 | return false; 32 | } else { 33 | if (relatedValue === true) { 34 | return true; 35 | } else { 36 | return typeof value === 'number'; 37 | } 38 | } 39 | }, 40 | }, 41 | }); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /BE/src/common/files.validator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | BadRequestException, 4 | Injectable, 5 | PipeTransform, 6 | } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class FilesSizeValidator implements PipeTransform { 10 | transform(value: any, metadata: ArgumentMetadata): any { 11 | if (value.length === 0) { 12 | return value; 13 | } 14 | const maxSize = 1024 * 1024 * 20; 15 | const files: Express.Multer.File[] = value; 16 | for (const file of files) { 17 | if (file.size > maxSize) { 18 | throw new BadRequestException( 19 | `File ${file.originalname} exceeds the allowed size limit`, 20 | ); 21 | } 22 | } 23 | return value; 24 | } 25 | } 26 | 27 | export class FileSizeValidator implements PipeTransform { 28 | transform(value: any, metadata: ArgumentMetadata): any { 29 | if (value === undefined) { 30 | return value; 31 | } 32 | const maxSize = 1024 * 1024 * 20; 33 | const file: Express.Multer.File = value; 34 | if (file.size > maxSize) { 35 | throw new BadRequestException( 36 | `File ${file.originalname} exceeds the allowed size limit`, 37 | ); 38 | } 39 | return value; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BE/src/common/greenEyeHandler.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { v4 as uuid } from 'uuid'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { Injectable } from '@nestjs/common'; 5 | 6 | @Injectable() 7 | export class GreenEyeHandler { 8 | constructor(private configService: ConfigService) {} 9 | async isHarmful(fileLocation: string): Promise { 10 | const response = await this.sendGreenEyeRequest(fileLocation); 11 | const normalResult = response.data.images[0].result.normal.confidence; 12 | return normalResult < 0.8; 13 | } 14 | 15 | async sendGreenEyeRequest(url: string) { 16 | return await axios.post( 17 | this.configService.get('CLOVA_URL'), 18 | { 19 | images: [ 20 | { 21 | name: 'file', 22 | url: url, 23 | }, 24 | ], 25 | requestId: uuid(), 26 | timestamp: 0, 27 | version: 'V1', 28 | }, 29 | { 30 | headers: { 31 | 'X-GREEN-EYE-SECRET': this.configService.get('CLOVA_SECRET'), 32 | }, 33 | }, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /BE/src/common/guard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | HttpException, 5 | Inject, 6 | Injectable, 7 | } from '@nestjs/common'; 8 | import * as jwt from 'jsonwebtoken'; 9 | import { CACHE_MANAGER } from '@nestjs/cache-manager'; 10 | import { Cache } from 'cache-manager'; 11 | import { JwtPayload } from 'jsonwebtoken'; 12 | 13 | @Injectable() 14 | export class AuthGuard implements CanActivate { 15 | constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} 16 | async canActivate(context: ExecutionContext): Promise { 17 | const request = context.switchToHttp().getRequest(); 18 | const authorizationHeader = request.headers.authorization; 19 | 20 | if (!authorizationHeader) throw new HttpException('토큰이 없습니다.', 401); 21 | 22 | const accessToken = authorizationHeader.split(' ')[1]; 23 | const isBlackList = await this.cacheManager.get(accessToken); 24 | if (isBlackList) { 25 | throw new HttpException('로그아웃된 토큰입니다.', 401); 26 | } 27 | try { 28 | const payload: JwtPayload = ( 29 | jwt.verify(accessToken, process.env.JWT_SECRET) 30 | ); 31 | request.userId = payload.userId; 32 | return true; 33 | } catch (err) { 34 | throw new HttpException('토큰이 유효하지 않습니다.', 401); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BE/src/common/hashMaker.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export const hashMaker = (nickname: string) => { 4 | return crypto 5 | .createHash('sha256') 6 | .update(nickname + new Date().getTime()) 7 | .digest('base64url'); 8 | }; 9 | -------------------------------------------------------------------------------- /BE/src/common/interceptor/httpLogger.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | NestInterceptor, 5 | Injectable, 6 | Logger, 7 | } from '@nestjs/common'; 8 | import { Observable, throwError } from 'rxjs'; 9 | import { tap, catchError } from 'rxjs/operators'; 10 | import { Request } from 'express'; 11 | 12 | @Injectable() 13 | export class HttpLoggerInterceptor implements NestInterceptor { 14 | private readonly logger = new Logger('HTTP'); 15 | intercept( 16 | context: ExecutionContext, 17 | next: CallHandler, 18 | ): Observable | Promise> { 19 | const request: Request = context.switchToHttp().getRequest(); 20 | this.logger.debug(request.body, 'HTTP'); 21 | 22 | return next.handle().pipe( 23 | tap((data) => { 24 | const request = context.switchToHttp().getRequest(); 25 | const response = context.switchToHttp().getResponse(); 26 | this.logger.debug( 27 | `<${request.userId}> ${request.method} ${request.url} ${response.statusCode} ${response.statusMessage}`, 28 | 'HTTP', 29 | ); 30 | }), 31 | catchError((err) => { 32 | this.logger.error( 33 | `<${request['userId']}> ${request.method} ${request.url} [Error]${err}`, 34 | 'HTTP ERROR', 35 | ); 36 | return throwError(err); 37 | }), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /BE/src/common/interceptor/transaction.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { concatMap, finalize, Observable } from 'rxjs'; 8 | import { Request } from 'express'; 9 | import { DataSource } from 'typeorm'; 10 | import { catchError } from 'rxjs/operators'; 11 | 12 | export const ENTITY_MANAGER_KEY = 'ENTITY_MANAGER'; 13 | 14 | @Injectable() 15 | export class TransactionInterceptor implements NestInterceptor { 16 | constructor(private dataSource: DataSource) {} 17 | async intercept( 18 | context: ExecutionContext, 19 | next: CallHandler, 20 | ): Promise> { 21 | const req = context.switchToHttp().getRequest(); 22 | 23 | const queryRunner = this.dataSource.createQueryRunner(); 24 | await queryRunner.connect(); 25 | await queryRunner.startTransaction(); 26 | req[ENTITY_MANAGER_KEY] = queryRunner.manager; 27 | 28 | return next.handle().pipe( 29 | concatMap(async (data) => { 30 | await queryRunner.commitTransaction(); 31 | return data; 32 | }), 33 | catchError(async (e) => { 34 | await queryRunner.rollbackTransaction(); 35 | throw e; 36 | }), 37 | finalize(async () => { 38 | await queryRunner.release(); 39 | }), 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /BE/src/config/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { JwtModuleOptions, JwtOptionsFactory } from '@nestjs/jwt'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | @Injectable() 6 | export class JwtConfig implements JwtOptionsFactory { 7 | constructor(private configService: ConfigService) {} 8 | 9 | createJwtOptions(): JwtModuleOptions { 10 | const jwtModuleOptions: JwtModuleOptions = { 11 | secret: this.configService.get('JWT_SECRET'), 12 | signOptions: { 13 | expiresIn: this.configService.get('JWT_EXPIRES_IN'), 14 | }, 15 | }; 16 | return jwtModuleOptions; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BE/src/config/mysql.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { UserEntity } from '../entities/user.entity'; 5 | import { PostEntity } from '../entities/post.entity'; 6 | import { BlockUserEntity } from '../entities/blockUser.entity'; 7 | import { PostImageEntity } from '../entities/postImage.entity'; 8 | import { BlockPostEntity } from '../entities/blockPost.entity'; 9 | import { ChatRoomEntity } from '../entities/chatRoom.entity'; 10 | import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; 11 | import { ChatEntity } from '../entities/chat.entity'; 12 | import { ReportEntity } from '../entities/report.entity'; 13 | 14 | @Injectable() 15 | export class MysqlConfigProvider implements TypeOrmOptionsFactory { 16 | constructor(private configService: ConfigService) {} 17 | 18 | createTypeOrmOptions(): TypeOrmModuleOptions { 19 | return { 20 | type: 'mysql', 21 | host: this.configService.get('DB_HOST'), 22 | port: this.configService.get('DB_PORT'), 23 | username: this.configService.get('DB_USERNAME'), 24 | password: this.configService.get('DB_PASSWORD'), 25 | database: this.configService.get('DB_DATABASE'), 26 | entities: [ 27 | UserEntity, 28 | PostEntity, 29 | PostImageEntity, 30 | BlockUserEntity, 31 | BlockPostEntity, 32 | ChatRoomEntity, 33 | RegistrationTokenEntity, 34 | ChatEntity, 35 | ReportEntity, 36 | ], 37 | synchronize: false, 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /BE/src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | import { CacheModuleOptions, CacheOptionsFactory } from '@nestjs/common/cache'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import * as redisStore from 'cache-manager-ioredis'; 4 | export class RedisConfigProvider implements CacheOptionsFactory { 5 | configService = new ConfigService(); 6 | createCacheOptions(): CacheModuleOptions { 7 | return { 8 | store: redisStore, 9 | host: this.configService.get('REDIS_HOST'), 10 | port: this.configService.get('REDIS_PORT'), 11 | password: this.configService.get('REDIS_PASSWORD'), 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /BE/src/config/s3.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigService } from '@nestjs/config'; 2 | import { S3Client } from '@aws-sdk/client-s3'; 3 | 4 | export const S3Provider = [ 5 | { 6 | provide: 'S3_CLIENT', 7 | import: [ConfigModule], 8 | inject: [ConfigService], 9 | useFactory: (configService: ConfigService) => { 10 | return new S3Client({ 11 | endpoint: configService.get('S3_ENDPOINT'), 12 | region: configService.get('S3_REGION'), 13 | credentials: { 14 | accessKeyId: configService.get('S3_ACCESS_KEY'), 15 | secretAccessKey: configService.get('S3_SECRET_KEY'), 16 | }, 17 | }); 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /BE/src/config/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | 4 | export function setupSwagger(app: INestApplication): void { 5 | const options = new DocumentBuilder() 6 | .setTitle('Village API Docs') 7 | .setDescription('Village API description') 8 | .setVersion('1.0.0') 9 | .build(); 10 | 11 | const document = SwaggerModule.createDocument(app, options); 12 | SwaggerModule.setup('api-swagger', app, document); 13 | } 14 | -------------------------------------------------------------------------------- /BE/src/config/winston.config.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | import { 3 | utilities as nestWinstonModuleUtilities, 4 | utilities, 5 | WinstonModule, 6 | } from 'nest-winston'; 7 | import * as winstonDaily from 'winston-daily-rotate-file'; 8 | 9 | export const winstonOptions = new winston.transports.Console({ 10 | level: process.env.NODE_ENV === 'prod' ? 'http' : 'silly', 11 | format: winston.format.combine( 12 | winston.format.timestamp(), 13 | winston.format.colorize({ all: true }), 14 | nestWinstonModuleUtilities.format.nestLike('Village', { 15 | prettyPrint: true, 16 | }), 17 | ), 18 | }); 19 | 20 | export const dailyOption = (level: string) => { 21 | return { 22 | level, 23 | datePattern: 'YYYY-MM-DD', 24 | dirname: `./logs/${level}`, 25 | filename: `%DATE%.${level}.log`, 26 | maxFiles: 30, 27 | zippedArchive: true, 28 | format: winston.format.combine( 29 | winston.format.timestamp(), 30 | utilities.format.nestLike(process.env.NODE_ENV, { 31 | colors: false, 32 | prettyPrint: true, 33 | }), 34 | ), 35 | }; 36 | }; 37 | 38 | export const winstonTransportsOption = [ 39 | winstonOptions, 40 | new winstonDaily(dailyOption('error')), 41 | new winstonDaily(dailyOption('warn')), 42 | new winstonDaily(dailyOption('info')), 43 | new winstonDaily(dailyOption('debug')), 44 | ]; 45 | 46 | export const winstonLogger = WinstonModule.createLogger({ 47 | transports: winstonTransportsOption, 48 | }); 49 | -------------------------------------------------------------------------------- /BE/src/entities/blockPost.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | ManyToOne, 4 | JoinColumn, 5 | PrimaryColumn, 6 | DeleteDateColumn, 7 | } from 'typeorm'; 8 | import { UserEntity } from './user.entity'; 9 | import { PostEntity } from './post.entity'; 10 | 11 | @Entity('block_post') 12 | export class BlockPostEntity { 13 | @PrimaryColumn() 14 | blocker: string; 15 | 16 | @PrimaryColumn() 17 | blocked_post: number; 18 | 19 | @DeleteDateColumn() 20 | delete_date: Date; 21 | 22 | @ManyToOne(() => UserEntity, (blocker) => blocker.user_hash) 23 | @JoinColumn({ name: 'blocker', referencedColumnName: 'user_hash' }) 24 | blockerUser: UserEntity; 25 | 26 | @ManyToOne(() => PostEntity, (blocked_post) => blocked_post.id) 27 | @JoinColumn({ name: 'blocked_post' }) 28 | blockedPost: PostEntity; 29 | } 30 | -------------------------------------------------------------------------------- /BE/src/entities/blockUser.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | ManyToOne, 4 | JoinColumn, 5 | PrimaryColumn, 6 | DeleteDateColumn, 7 | } from 'typeorm'; 8 | import { UserEntity } from './user.entity'; 9 | import { PostEntity } from './post.entity'; 10 | 11 | @Entity('block_user') 12 | export class BlockUserEntity { 13 | @PrimaryColumn() 14 | blocker: string; 15 | 16 | @PrimaryColumn() 17 | blocked_user: string; 18 | 19 | @DeleteDateColumn() 20 | delete_date: Date; 21 | 22 | @ManyToOne(() => UserEntity, (blocker) => blocker.user_hash) 23 | @JoinColumn({ name: 'blocker', referencedColumnName: 'user_hash' }) 24 | blockerUser: UserEntity; 25 | 26 | @ManyToOne(() => UserEntity, (blocked) => blocked.user_hash) 27 | @JoinColumn({ name: 'blocked_user', referencedColumnName: 'user_hash' }) 28 | blockedUser: UserEntity; 29 | 30 | @ManyToOne(() => PostEntity, (blocked) => blocked.user_hash) 31 | @JoinColumn({ name: 'blocked_user', referencedColumnName: 'user_hash' }) 32 | blockedUserPost: PostEntity; 33 | } 34 | -------------------------------------------------------------------------------- /BE/src/entities/chat.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | } from 'typeorm'; 10 | import { ChatRoomEntity } from './chatRoom.entity'; 11 | import { UserEntity } from './user.entity'; 12 | 13 | @Entity('chat') 14 | export class ChatEntity { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | sender: string; 20 | 21 | @Column({ type: 'text', nullable: false, charset: 'utf8' }) 22 | message: string; 23 | 24 | @Column() 25 | chat_room: number; 26 | 27 | @Column({ type: 'boolean', default: false }) 28 | is_read: boolean; 29 | 30 | @Column({ type: 'int' }) 31 | count: number; 32 | 33 | @CreateDateColumn({ type: 'timestamp', nullable: false }) 34 | create_date: Date; 35 | 36 | @DeleteDateColumn() 37 | delete_date: Date; 38 | 39 | @ManyToOne(() => ChatRoomEntity, (chatRoom) => chatRoom.id) 40 | @JoinColumn({ name: 'chat_room' }) 41 | chatRoom: ChatRoomEntity; 42 | 43 | @ManyToOne(() => UserEntity, (user) => user.user_hash) 44 | @JoinColumn({ name: 'sender' }) 45 | senderUser: UserEntity; 46 | } 47 | -------------------------------------------------------------------------------- /BE/src/entities/chatRoom.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | UpdateDateColumn, 5 | DeleteDateColumn, 6 | Entity, 7 | JoinColumn, 8 | ManyToOne, 9 | OneToMany, 10 | PrimaryGeneratedColumn, 11 | OneToOne, 12 | } from 'typeorm'; 13 | import { PostEntity } from './post.entity'; 14 | import { ChatEntity } from './chat.entity'; 15 | import { UserEntity } from './user.entity'; 16 | 17 | @Entity('chat_room') 18 | export class ChatRoomEntity { 19 | @PrimaryGeneratedColumn() 20 | id: number; 21 | 22 | @Column({ type: 'int' }) 23 | post_id: number; 24 | 25 | @Column({ nullable: false, charset: 'utf8', unique: true }) 26 | writer: string; 27 | 28 | @Column({ nullable: false, charset: 'utf8', unique: true }) 29 | user: string; 30 | 31 | @CreateDateColumn({ 32 | type: 'timestamp', 33 | nullable: false, 34 | }) 35 | create_date: Date; 36 | 37 | @UpdateDateColumn({ 38 | type: 'timestamp', 39 | nullable: true, 40 | }) 41 | update_date: Date; 42 | 43 | @DeleteDateColumn() 44 | delete_date: Date; 45 | 46 | @Column() 47 | last_chat_id: number; 48 | 49 | @Column({ default: false }) 50 | writer_hide: boolean; 51 | 52 | @Column({ default: false }) 53 | user_hide: boolean; 54 | 55 | @Column({ default: null, type: 'timestamp' }) 56 | writer_left_time: Date; 57 | 58 | @Column({ default: null, type: 'timestamp' }) 59 | user_left_time: Date; 60 | 61 | @OneToMany(() => ChatEntity, (chat) => chat.chatRoom) 62 | chats: ChatEntity[]; 63 | 64 | @OneToOne(() => ChatEntity, (chat) => chat.id) 65 | @JoinColumn({ name: 'last_chat_id' }) 66 | lastChat: ChatEntity; 67 | 68 | @ManyToOne(() => PostEntity, (post) => post.id) 69 | @JoinColumn({ name: 'post_id' }) 70 | post: PostEntity; 71 | 72 | @ManyToOne(() => UserEntity, (writer) => writer.user_hash) 73 | @JoinColumn({ name: 'writer', referencedColumnName: 'user_hash' }) 74 | writerUser: UserEntity; 75 | 76 | @ManyToOne(() => UserEntity, (user) => user.user_hash) 77 | @JoinColumn({ name: 'user', referencedColumnName: 'user_hash' }) 78 | userUser: UserEntity; 79 | } 80 | -------------------------------------------------------------------------------- /BE/src/entities/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | JoinColumn, 9 | OneToMany, 10 | DeleteDateColumn, 11 | } from 'typeorm'; 12 | import { UserEntity } from './user.entity'; 13 | import { PostImageEntity } from './postImage.entity'; 14 | import { BlockPostEntity } from './blockPost.entity'; 15 | import { ReportEntity } from './report.entity'; 16 | import { BlockUserEntity } from './blockUser.entity'; 17 | 18 | @Entity('post') 19 | export class PostEntity { 20 | @PrimaryGeneratedColumn() 21 | id: number; 22 | 23 | @Column({ length: 100, nullable: false, charset: 'utf8' }) 24 | title: string; 25 | 26 | @Column({ nullable: true }) 27 | price: number; 28 | 29 | @Column({ type: 'text', nullable: false, charset: 'utf8' }) 30 | description: string; 31 | 32 | @Column({ nullable: false }) 33 | user_hash: string; 34 | 35 | @ManyToOne(() => UserEntity, (user) => user.user_hash) 36 | @JoinColumn({ name: 'user_hash', referencedColumnName: 'user_hash' }) 37 | user: UserEntity; 38 | 39 | @Column({ type: 'datetime', nullable: false }) 40 | start_date: Date; 41 | 42 | @Column({ type: 'datetime', nullable: false }) 43 | end_date: Date; 44 | 45 | @Column({ type: 'boolean', nullable: false }) 46 | is_request: boolean; 47 | 48 | @CreateDateColumn({ 49 | type: 'timestamp', 50 | nullable: false, 51 | }) 52 | create_date: Date; 53 | 54 | @UpdateDateColumn({ 55 | type: 'timestamp', 56 | nullable: true, 57 | }) 58 | update_date: Date; 59 | 60 | @DeleteDateColumn() 61 | delete_date: Date; 62 | 63 | @Column({ length: 2048, nullable: true, charset: 'utf8' }) 64 | thumbnail: string; 65 | 66 | @OneToMany(() => PostImageEntity, (post_image) => post_image.post, { 67 | cascade: ['soft-remove'], 68 | }) 69 | post_images: PostImageEntity[]; 70 | 71 | @OneToMany(() => BlockPostEntity, (block_post) => block_post.blockedPost, { 72 | cascade: ['soft-remove'], 73 | }) 74 | blocked_posts: BlockPostEntity[]; 75 | 76 | @OneToMany(() => BlockUserEntity, (block_user) => block_user.blockedUserPost) 77 | blocked_users: BlockUserEntity[]; 78 | } 79 | -------------------------------------------------------------------------------- /BE/src/entities/postImage.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | JoinColumn, 7 | DeleteDateColumn, 8 | } from 'typeorm'; 9 | import { PostEntity } from './post.entity'; 10 | 11 | @Entity('post_image') 12 | export class PostImageEntity { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @Column({ length: 2048, charset: 'utf8' }) 17 | image_url: string; 18 | 19 | @Column({ nullable: false }) 20 | post_id: number; 21 | 22 | @DeleteDateColumn() 23 | delete_date: Date; 24 | 25 | @ManyToOne(() => PostEntity, (post) => post.id) 26 | @JoinColumn({ name: 'post_id' }) 27 | post: PostEntity; 28 | } 29 | -------------------------------------------------------------------------------- /BE/src/entities/registrationToken.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { UserEntity } from './user.entity'; 11 | 12 | @Entity('registration_token') 13 | export class RegistrationTokenEntity { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column({ length: 45, nullable: false, charset: 'utf8', unique: true }) 18 | user_hash: string; 19 | 20 | @Column({ length: 4096, nullable: false, charset: 'utf8' }) 21 | registration_token: string; 22 | 23 | @CreateDateColumn({ 24 | type: 'timestamp', 25 | nullable: false, 26 | }) 27 | create_date: Date; 28 | 29 | @UpdateDateColumn({ 30 | type: 'timestamp', 31 | nullable: true, 32 | }) 33 | update_date: Date; 34 | 35 | @OneToOne(() => UserEntity, (user) => user.user_hash) 36 | @JoinColumn({ name: 'user_hash', referencedColumnName: 'user_hash' }) 37 | user: UserEntity; 38 | } 39 | -------------------------------------------------------------------------------- /BE/src/entities/report.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('report') 4 | export class ReportEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column({ nullable: false, charset: 'utf8' }) 9 | user_hash: string; 10 | 11 | @Column({ nullable: false }) 12 | post_id: number; 13 | 14 | @Column({ charset: 'utf8' }) 15 | description: string; 16 | 17 | @Column({ nullable: false, charset: 'utf8' }) 18 | reporter: string; 19 | } 20 | -------------------------------------------------------------------------------- /BE/src/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | OneToMany, 7 | OneToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { PostEntity } from './post.entity'; 12 | import { BlockUserEntity } from './blockUser.entity'; 13 | import { BlockPostEntity } from './blockPost.entity'; 14 | import { RegistrationTokenEntity } from './registrationToken.entity'; 15 | import { ChatEntity } from './chat.entity'; 16 | import { ChatRoomEntity } from './chatRoom.entity'; 17 | import { ReportEntity } from './report.entity'; 18 | 19 | @Entity('user') 20 | export class UserEntity { 21 | @OneToMany(() => PostEntity, (post) => post.user) 22 | posts: PostEntity[]; 23 | 24 | @OneToMany(() => BlockUserEntity, (blockUser) => blockUser.blockerUser, { 25 | cascade: ['soft-remove'], 26 | }) 27 | blocker: BlockUserEntity[]; 28 | 29 | @OneToMany(() => BlockUserEntity, (blockUser) => blockUser.blocked_user) 30 | blocked: BlockUserEntity[]; 31 | 32 | @OneToMany(() => BlockPostEntity, (blockUser) => blockUser.blockerUser, { 33 | cascade: ['soft-remove'], 34 | }) 35 | blocker_post: BlockPostEntity[]; 36 | 37 | @OneToOne( 38 | () => RegistrationTokenEntity, 39 | (registrationToken) => registrationToken.user_hash, 40 | ) 41 | registration_token: RegistrationTokenEntity; 42 | @OneToMany(() => ChatEntity, (chat) => chat.senderUser) 43 | chats: ChatEntity[]; 44 | 45 | @OneToMany(() => ChatRoomEntity, (chatRoom) => chatRoom.writerUser) 46 | chatRooms: ChatRoomEntity[]; 47 | 48 | @OneToMany(() => ChatRoomEntity, (chatRoom) => chatRoom.userUser) 49 | chatRooms2: ChatRoomEntity[]; 50 | 51 | @PrimaryGeneratedColumn() 52 | id: number; 53 | 54 | @Column({ length: 20, nullable: false, charset: 'utf8' }) 55 | nickname: string; 56 | 57 | @Column({ length: 320, nullable: false, charset: 'utf8' }) 58 | social_id: string; 59 | 60 | @Column({ length: 15, nullable: false, charset: 'utf8' }) 61 | OAuth_domain: string; 62 | 63 | @CreateDateColumn({ 64 | type: 'timestamp', 65 | nullable: false, 66 | }) 67 | create_date: Date; 68 | 69 | @UpdateDateColumn({ 70 | type: 'timestamp', 71 | nullable: true, 72 | }) 73 | update_date: Date; 74 | 75 | @Column({ length: 2048, nullable: true, charset: 'utf8' }) 76 | profile_img: string; 77 | 78 | @Column({ length: 45, nullable: false, charset: 'utf8', unique: true }) 79 | user_hash: string; 80 | 81 | @DeleteDateColumn() 82 | delete_date: Date; 83 | } 84 | -------------------------------------------------------------------------------- /BE/src/image/image.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ImageService } from './image.service'; 3 | import { S3Provider } from '../config/s3.config'; 4 | import { PostImageRepository } from './postImage.repository'; 5 | 6 | @Module({ 7 | providers: [ImageService, ...S3Provider, PostImageRepository], 8 | exports: [ImageService], 9 | }) 10 | export class ImageModule {} 11 | -------------------------------------------------------------------------------- /BE/src/image/postImage.repository.ts: -------------------------------------------------------------------------------- 1 | import { BaseRepository } from '../common/base.repository'; 2 | import { DataSource } from 'typeorm'; 3 | import { Inject, Injectable, Scope } from '@nestjs/common'; 4 | import { REQUEST } from '@nestjs/core'; 5 | 6 | @Injectable({ scope: Scope.REQUEST }) 7 | export class PostImageRepository extends BaseRepository { 8 | constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { 9 | super(dataSource, req); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /BE/src/login/dto/appleLogin.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class AppleLoginDto { 4 | @IsString() 5 | authorization_code: string; 6 | 7 | @IsString() 8 | identity_token: string; 9 | } 10 | -------------------------------------------------------------------------------- /BE/src/login/login.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Headers, 6 | HttpException, 7 | Post, 8 | Query, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { LoginService, SocialProperties } from './login.service'; 12 | import { AppleLoginDto } from './dto/appleLogin.dto'; 13 | import { AuthGuard } from '../common/guard/auth.guard'; 14 | 15 | @Controller() 16 | export class LoginController { 17 | constructor(private readonly loginService: LoginService) {} 18 | 19 | @Post('login/appleOAuth') // 임시 20 | async signInWithApple(@Body() body: AppleLoginDto) { 21 | const socialProperties: SocialProperties = 22 | await this.loginService.appleOAuth(body); 23 | if (!socialProperties) { 24 | throw new HttpException('토큰이 유효하지 않음', 401); 25 | } 26 | return await this.loginService.login(socialProperties); 27 | } 28 | 29 | @Post('login/refresh') 30 | async refreshToken(@Body('refresh_token') refreshToken) { 31 | try { 32 | const payload = this.loginService.validateToken(refreshToken, 'refresh'); 33 | return await this.loginService.refreshToken(refreshToken, payload); 34 | } catch (e) { 35 | throw new HttpException('refresh token이 유효하지 않음', 401); 36 | } 37 | } 38 | 39 | @Post('login/admin') 40 | loginAdmin(@Query('user') user) { 41 | return this.loginService.loginAdmin(user); 42 | } 43 | 44 | @Get('login/expire') 45 | @UseGuards(AuthGuard) 46 | checkAccessToken() {} 47 | 48 | @Post('logout') 49 | @UseGuards(AuthGuard) 50 | async logout(@Headers('Authorization') token) { 51 | const accessToken = token.split(' ')[1]; 52 | await this.loginService.logout(accessToken); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /BE/src/login/login.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LoginService } from './login.service'; 3 | import { LoginController } from './login.controller'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import { JwtConfig } from '../config/jwt.config'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { UserEntity } from '../entities/user.entity'; 8 | import { AuthGuard } from 'src/common/guard/auth.guard'; 9 | import { FcmHandler } from '../common/fcmHandler'; 10 | import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; 11 | 12 | @Module({ 13 | imports: [ 14 | JwtModule.registerAsync({ useClass: JwtConfig }), 15 | TypeOrmModule.forFeature([UserEntity, RegistrationTokenEntity]), 16 | ], 17 | controllers: [LoginController], 18 | providers: [LoginService, AuthGuard, FcmHandler], 19 | }) 20 | export class LoginModule {} 21 | -------------------------------------------------------------------------------- /BE/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { setupSwagger } from './config/swagger.config'; 4 | import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; 5 | import { winstonLogger } from './config/winston.config'; 6 | import { ValidationPipe } from '@nestjs/common'; 7 | import { HttpLoggerInterceptor } from './common/interceptor/httpLogger.interceptor'; 8 | import { WsAdapter } from '@nestjs/platform-ws'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule, { 12 | logger: winstonLogger, 13 | }); 14 | app.useGlobalInterceptors(new HttpLoggerInterceptor()); 15 | app.useLogger(app.get(WINSTON_MODULE_NEST_PROVIDER)); 16 | app.useWebSocketAdapter(new WsAdapter(app)); 17 | app.useGlobalPipes( 18 | new ValidationPipe({ 19 | transform: true, 20 | }), 21 | ); 22 | setupSwagger(app); 23 | 24 | await app.listen(3000); 25 | } 26 | bootstrap(); 27 | -------------------------------------------------------------------------------- /BE/src/notification/notification.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { NotificationService } from './notification.service'; 3 | import { RegistrationTokenRepository } from './registrationToken.repository'; 4 | 5 | @Module({ 6 | exports: [NotificationService], 7 | providers: [NotificationService, RegistrationTokenRepository], 8 | }) 9 | export class NotificationModule {} 10 | -------------------------------------------------------------------------------- /BE/src/notification/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import admin from 'firebase-admin'; 4 | import { PushMessage } from '../common/fcmHandler'; 5 | import { RegistrationTokenRepository } from './registrationToken.repository'; 6 | 7 | @Injectable() 8 | export class NotificationService { 9 | private readonly logger = new Logger('ChatsGateway'); 10 | constructor( 11 | private configService: ConfigService, 12 | private registrationTokenRepository: RegistrationTokenRepository, 13 | ) { 14 | if (admin.apps.length === 0) { 15 | admin.initializeApp({ 16 | credential: admin.credential.cert( 17 | this.configService.get('GOOGLE_APPLICATION_CREDENTIALS'), 18 | ), 19 | }); 20 | this.logger.log('Firebase Admin initialized'); 21 | } 22 | } 23 | 24 | async sendChatNotification(userId: string, pushMessage: PushMessage) { 25 | const registrationToken = await this.getRegistrationToken(userId); 26 | if (!registrationToken) { 27 | throw new Error('no registration token'); 28 | } 29 | const message = this.createChatNotificationMessage( 30 | registrationToken, 31 | pushMessage, 32 | ); 33 | try { 34 | const response = await admin.messaging().send(message); 35 | this.logger.debug( 36 | `Push Notification Success : ${response} `, 37 | 'FcmHandler', 38 | ); 39 | } catch (e) { 40 | throw new Error('fail to send chat notification'); 41 | } 42 | } 43 | 44 | createChatNotificationMessage( 45 | registrationToken: string, 46 | pushMessage: PushMessage, 47 | ) { 48 | return { 49 | token: registrationToken, 50 | notification: { 51 | title: pushMessage.title, 52 | body: pushMessage.body, 53 | }, 54 | apns: { 55 | payload: { 56 | aps: { 57 | sound: 'default', 58 | }, 59 | }, 60 | }, 61 | data: { 62 | ...pushMessage.data, 63 | }, 64 | }; 65 | } 66 | 67 | async getRegistrationToken(userId: string): Promise { 68 | const registrationToken = 69 | await this.registrationTokenRepository.findOne(userId); 70 | if (registrationToken === null) { 71 | this.logger.error('토큰이 없습니다.', 'FcmHandler'); 72 | return null; 73 | } 74 | return registrationToken.registration_token; 75 | } 76 | 77 | async registerToken(userId, registrationToken) { 78 | const registrationTokenEntity = 79 | await this.registrationTokenRepository.findOne(userId); 80 | if (registrationTokenEntity === null) { 81 | await this.registrationTokenRepository.save(userId, registrationToken); 82 | } else { 83 | await this.registrationTokenRepository.update(userId, registrationToken); 84 | } 85 | } 86 | 87 | async removeRegistrationToken(userId: string) { 88 | await this.registrationTokenRepository.delete(userId); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /BE/src/notification/registrationToken.repository.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Scope } from '@nestjs/common'; 2 | import { BaseRepository } from '../common/base.repository'; 3 | import { DataSource } from 'typeorm'; 4 | import { REQUEST } from '@nestjs/core'; 5 | import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; 6 | 7 | @Injectable({ scope: Scope.REQUEST }) 8 | export class RegistrationTokenRepository extends BaseRepository { 9 | constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { 10 | super(dataSource, req); 11 | } 12 | async findOne(userId: string): Promise { 13 | return await this.getRepository(RegistrationTokenEntity).findOne({ 14 | where: { user_hash: userId }, 15 | }); 16 | } 17 | 18 | async save(userId: string, registrationToken: string) { 19 | await this.getRepository(RegistrationTokenEntity).save({ 20 | user_hash: userId, 21 | registration_token: registrationToken, 22 | }); 23 | } 24 | 25 | async update(userId: string, registrationToken: string) { 26 | await this.getRepository(RegistrationTokenEntity).update( 27 | { 28 | user_hash: userId, 29 | }, 30 | { registration_token: registrationToken }, 31 | ); 32 | } 33 | 34 | async delete(userId: string) { 35 | await this.getRepository(RegistrationTokenEntity).delete({ 36 | user_hash: userId, 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BE/src/post/dto/postCreate.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsString, MaxLength } from 'class-validator'; 2 | import { IsPriceCorrect } from '../../common/decorator/price.decorator'; 3 | 4 | export class PostCreateDto { 5 | @IsString() 6 | @MaxLength(100) 7 | title: string; 8 | 9 | @IsString() 10 | description: string; 11 | 12 | @IsPriceCorrect('is_request') 13 | price: number; 14 | 15 | @IsBoolean() 16 | is_request: boolean; 17 | 18 | @IsString() 19 | start_date: string; 20 | 21 | @IsString() 22 | end_date: string; 23 | } 24 | -------------------------------------------------------------------------------- /BE/src/post/dto/postList.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | 4 | export class PostListDto { 5 | @IsNumber() 6 | @IsOptional() 7 | @Type(() => Number) 8 | cursorId: number; 9 | 10 | @IsNumber() 11 | @IsOptional() 12 | @Type(() => Number) 13 | requestFilter: number; 14 | 15 | @IsString() 16 | @IsOptional() 17 | searchKeyword: string; 18 | 19 | @IsString() 20 | @IsOptional() 21 | writer: string; 22 | } 23 | -------------------------------------------------------------------------------- /BE/src/post/dto/postUpdate.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdatePostDto { 4 | @IsOptional() // 이 필드는 선택적으로 업데이트할 수 있도록 설정 5 | @IsString() 6 | title?: string; 7 | 8 | @IsOptional() 9 | @IsString() 10 | description?: string; 11 | 12 | @IsOptional() 13 | @IsNumber() // 전화번호 형식 검증 14 | price?: number; 15 | 16 | @IsOptional() 17 | @IsString() 18 | start_date?: string; 19 | 20 | @IsOptional() 21 | @IsString() 22 | end_date?: string; 23 | 24 | @IsOptional() 25 | @IsString({ each: true }) 26 | deleted_images: string[]; 27 | 28 | @IsBoolean() 29 | is_request: boolean; 30 | } 31 | -------------------------------------------------------------------------------- /BE/src/post/post.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PostController } from './post.controller'; 3 | import { PostService } from './post.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { PostEntity } from '../entities/post.entity'; 6 | import { PostImageEntity } from '../entities/postImage.entity'; 7 | import { BlockUserEntity } from '../entities/blockUser.entity'; 8 | import { BlockPostEntity } from '../entities/blockPost.entity'; 9 | import { AuthGuard } from 'src/common/guard/auth.guard'; 10 | import { PostRepository } from './post.repository'; 11 | import { ImageModule } from '../image/image.module'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([ 16 | PostEntity, 17 | PostImageEntity, 18 | BlockUserEntity, 19 | BlockPostEntity, 20 | ]), 21 | ImageModule, 22 | ], 23 | controllers: [PostController], 24 | providers: [PostService, AuthGuard, PostRepository], 25 | }) 26 | export class PostModule {} 27 | -------------------------------------------------------------------------------- /BE/src/post/post.repository.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PostRepository } from './post.repository'; 3 | import { DataSource } from 'typeorm'; 4 | import { REQUEST } from '@nestjs/core'; 5 | import { Request } from 'express'; 6 | 7 | const createMockRequest = (overrides: Partial = {}): Request => { 8 | return { ...overrides } as Request; 9 | }; 10 | 11 | const mockDataSource = { 12 | createEntityManager: jest.fn(), 13 | }; 14 | 15 | const mockRequest = createMockRequest({ 16 | method: 'POST', 17 | url: '/api', 18 | headers: { 'Content-Type': 'application/json' }, 19 | }); 20 | 21 | describe('', () => { 22 | let repository: PostRepository; 23 | beforeEach(async () => { 24 | const module: TestingModule = await Test.createTestingModule({ 25 | providers: [ 26 | PostRepository, 27 | { provide: DataSource, useValue: mockDataSource }, 28 | { provide: REQUEST, useValue: mockRequest }, 29 | ], 30 | }).compile(); 31 | repository = await module.resolve(PostRepository); 32 | }); 33 | 34 | it('should be defined', () => { 35 | expect(repository).toBeDefined(); 36 | }); 37 | 38 | describe('createPost()', () => { 39 | it('should success (nothing)', async function () { 40 | const res = repository.createOption({ 41 | cursorId: undefined, 42 | requestFilter: undefined, 43 | writer: undefined, 44 | searchKeyword: undefined, 45 | }); 46 | expect(res).toBe('post.id > -1'); 47 | }); 48 | it('should success (page)', async function () { 49 | const res = repository.createOption({ 50 | cursorId: 1, 51 | requestFilter: undefined, 52 | writer: undefined, 53 | searchKeyword: undefined, 54 | }); 55 | expect(res).toBe('post.id < 1'); 56 | }); 57 | it('should success (more than two options)', async function () { 58 | const res = repository.createOption({ 59 | cursorId: 1, 60 | requestFilter: undefined, 61 | writer: 'user', 62 | searchKeyword: undefined, 63 | }); 64 | expect(res).toBe("post.id < 1 AND post.user_hash = 'user'"); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /BE/src/post/post.repository.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { PostEntity } from '../entities/post.entity'; 3 | import { Inject, Injectable, Scope } from '@nestjs/common'; 4 | import { PostListDto } from './dto/postList.dto'; 5 | import { BaseRepository } from '../common/base.repository'; 6 | import { REQUEST } from '@nestjs/core'; 7 | 8 | @Injectable({ scope: Scope.REQUEST }) 9 | export class PostRepository extends BaseRepository { 10 | constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { 11 | super(dataSource, req); 12 | } 13 | 14 | async findExceptBlock( 15 | blocker: string, 16 | options: PostListDto, 17 | ): Promise> { 18 | const limit = 20; 19 | return await this.getRepository(PostEntity) 20 | .createQueryBuilder('post') 21 | .leftJoin( 22 | 'post.blocked_posts', 23 | 'bp', 24 | 'bp.blocker = :blocker AND bp.blocked_post = post.id', 25 | { blocker: blocker }, 26 | ) 27 | .leftJoin( 28 | 'post.blocked_users', 29 | 'bu', 30 | 'bu.blocker = :blocker AND bu.blocked_user = post.user_hash', 31 | ) 32 | .where('bp.blocked_post IS NULL') 33 | .andWhere('bu.blocked_user IS NULL') 34 | .andWhere(this.createOption(options)) 35 | .orderBy('post.id', 'DESC') 36 | .limit(limit) 37 | .getMany(); 38 | } 39 | 40 | async findOneWithBlock(blocker: string, postId: number) { 41 | return await this.getRepository(PostEntity) 42 | .createQueryBuilder('post') 43 | .leftJoinAndSelect( 44 | 'post.blocked_posts', 45 | 'bp', 46 | 'bp.blocker = :blocker AND bp.blocked_post = post.id', 47 | { blocker: blocker }, 48 | ) 49 | .leftJoinAndSelect( 50 | 'post.blocked_users', 51 | 'bu', 52 | 'bu.blocker = :blocker AND bu.blocked_user = post.user_hash', 53 | ) 54 | .leftJoinAndSelect('post.post_images', 'pi', 'pi.post_id = post.id') 55 | .where('post.id = :postId', { postId: postId }) 56 | .getOne(); 57 | } 58 | 59 | async softDeleteCascade(postId: number) { 60 | const post = await this.getRepository(PostEntity).findOne({ 61 | where: { id: postId }, 62 | relations: ['blocked_posts', 'post_images'], 63 | }); 64 | await this.getRepository(PostEntity).softRemove(post); 65 | } 66 | 67 | createOption(options: PostListDto) { 68 | let option = 69 | options.cursorId === undefined 70 | ? 'post.id > -1 AND ' 71 | : `post.id < ${options.cursorId} AND `; 72 | if (options.requestFilter !== undefined) { 73 | option += `post.is_request = ${options.requestFilter} AND `; 74 | } 75 | if (options.writer !== undefined) { 76 | option += `post.user_hash = '${options.writer}' AND `; 77 | } 78 | if (options.searchKeyword !== undefined) { 79 | option += `post.title LIKE '%${options.searchKeyword}%' AND `; 80 | } 81 | return option.replace(/\s*AND\s*$/, '').trim(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /BE/src/posts-block/posts-block.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Delete, 4 | Get, 5 | Param, 6 | Post, 7 | Query, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { PostsBlockService } from './posts-block.service'; 11 | import { AuthGuard } from 'src/common/guard/auth.guard'; 12 | import { UserHash } from 'src/common/decorator/auth.decorator'; 13 | 14 | @Controller('posts/block') 15 | @UseGuards(AuthGuard) 16 | export class PostsBlockController { 17 | constructor(private readonly postsBlockService: PostsBlockService) {} 18 | @Post(':id') 19 | async postsBlockCreate( 20 | @Param('id') postId: number, 21 | @UserHash() blockerId: string, 22 | ) { 23 | await this.postsBlockService.createPostsBlock(blockerId, postId); 24 | } 25 | 26 | @Get() 27 | async postsBlockList( 28 | @UserHash() blockerId: string, 29 | @Query('requestFilter') requestFilter, 30 | ) { 31 | return await this.postsBlockService.findBlockedPosts( 32 | blockerId, 33 | parseInt(requestFilter), 34 | ); 35 | } 36 | 37 | @Delete(':id') 38 | async blockUserRemove(@Param('id') id: number, @UserHash() userId: string) { 39 | await this.postsBlockService.removeBlockPosts(id, userId); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BE/src/posts-block/posts-block.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PostsBlockController } from './posts-block.controller'; 3 | import { PostsBlockService } from './posts-block.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { BlockPostEntity } from '../entities/blockPost.entity'; 6 | import { PostEntity } from '../entities/post.entity'; 7 | import { AuthGuard } from 'src/common/guard/auth.guard'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([BlockPostEntity, PostEntity])], 11 | controllers: [PostsBlockController], 12 | providers: [PostsBlockService, AuthGuard], 13 | }) 14 | export class PostsBlockModule {} 15 | -------------------------------------------------------------------------------- /BE/src/posts-block/posts-block.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { PostEntity } from '../entities/post.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { BlockPostEntity } from '../entities/blockPost.entity'; 6 | 7 | @Injectable() 8 | export class PostsBlockService { 9 | constructor( 10 | @InjectRepository(PostEntity) 11 | private postRepository: Repository, 12 | @InjectRepository(BlockPostEntity) 13 | private blockPostRepository: Repository, 14 | ) {} 15 | async createPostsBlock(blockerId: string, postId: number) { 16 | const blockedPost = await this.postRepository.findOne({ 17 | where: { id: postId }, 18 | }); 19 | if (!blockedPost) { 20 | throw new HttpException('없는 게시물입니다.', 404); 21 | } 22 | 23 | const isExist = await this.blockPostRepository.findOne({ 24 | where: { 25 | blocker: blockerId, 26 | blocked_post: postId, 27 | }, 28 | withDeleted: true, 29 | }); 30 | 31 | if (isExist !== null && isExist.delete_date === null) { 32 | throw new HttpException('이미 차단 되었습니다.', 400); 33 | } 34 | 35 | const blockPostEntity = new BlockPostEntity(); 36 | blockPostEntity.blocked_post = postId; 37 | blockPostEntity.blocker = blockerId; 38 | blockPostEntity.delete_date = null; 39 | await this.blockPostRepository.save(blockPostEntity); 40 | } 41 | 42 | async findBlockedPosts(blockerId: string, requestFilter: number) { 43 | const blockLists = await this.blockPostRepository.find({ 44 | where: { 45 | blocker: blockerId, 46 | blockedPost: this.getRequestFilter(requestFilter), 47 | }, 48 | relations: ['blockedPost'], 49 | }); 50 | return blockLists.map((blockList) => { 51 | const blockedPost = blockList.blockedPost; 52 | return { 53 | title: blockedPost.title, 54 | post_image: blockedPost.thumbnail, 55 | post_id: blockedPost.id, 56 | start_date: blockedPost.start_date, 57 | end_date: blockedPost.end_date, 58 | is_request: blockedPost.is_request, 59 | price: blockedPost.price, 60 | }; 61 | }); 62 | } 63 | 64 | getRequestFilter(requestFilter: number) { 65 | if (requestFilter === undefined) { 66 | return undefined; 67 | } 68 | return { is_request: requestFilter === 1 }; 69 | } 70 | 71 | async removeBlockPosts(blockedPostId: number, userId: string) { 72 | const blockedPost = await this.blockPostRepository.findOne({ 73 | where: { blocked_post: blockedPostId, blocker: userId }, 74 | }); 75 | if (!blockedPost) { 76 | throw new HttpException('게시글이 존재하지 않습니다.', 404); 77 | } 78 | await this.blockPostRepository.softDelete({ 79 | blocked_post: blockedPostId, 80 | blocker: userId, 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /BE/src/report/dto/createReport.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class CreateReportDto { 4 | @IsString() 5 | description: string; 6 | 7 | @IsNumber() 8 | post_id: number; 9 | 10 | @IsString() 11 | user_id: string; 12 | } 13 | -------------------------------------------------------------------------------- /BE/src/report/report.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, UseGuards } from '@nestjs/common'; 2 | import { CreateReportDto } from './dto/createReport.dto'; 3 | import { ReportService } from './report.service'; 4 | import { AuthGuard } from '../common/guard/auth.guard'; 5 | import { UserHash } from '../common/decorator/auth.decorator'; 6 | 7 | @Controller('report') 8 | @UseGuards(AuthGuard) 9 | export class ReportController { 10 | constructor(private readonly reportService: ReportService) {} 11 | @Post() 12 | async createReport( 13 | @Body() body: CreateReportDto, 14 | @UserHash() userId: string, 15 | ) { 16 | await this.reportService.createReport(body, userId); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /BE/src/report/report.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ReportController } from './report.controller'; 3 | import { ReportService } from './report.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { ReportEntity } from '../entities/report.entity'; 6 | import { PostEntity } from '../entities/post.entity'; 7 | import { UserEntity } from '../entities/user.entity'; 8 | import { AuthGuard } from '../common/guard/auth.guard'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([ReportEntity, PostEntity, UserEntity])], 12 | controllers: [ReportController], 13 | providers: [ReportService, AuthGuard], 14 | }) 15 | export class ReportModule {} 16 | -------------------------------------------------------------------------------- /BE/src/report/report.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from 'typeorm'; 2 | import { ReportService } from './report.service'; 3 | import { ReportEntity } from '../entities/report.entity'; 4 | import { PostEntity } from '../entities/post.entity'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | import { getRepositoryToken } from '@nestjs/typeorm'; 7 | import { CreateReportDto } from './dto/createReport.dto'; 8 | import { HttpException } from '@nestjs/common'; 9 | 10 | const mockReportRepository = () => ({ 11 | save: jest.fn(), 12 | find: jest.fn(), 13 | findOne: jest.fn(), 14 | softDelete: jest.fn(), 15 | }); 16 | 17 | const mockPostRepository = () => ({ 18 | findOne: jest.fn(), 19 | exist: jest.fn(), 20 | }); 21 | 22 | type MockRepository = Partial, jest.Mock>>; 23 | 24 | describe('ReportService', function () { 25 | let service: ReportService; 26 | let reportRepository: MockRepository; 27 | let postRepository: MockRepository; 28 | beforeEach(async () => { 29 | const module: TestingModule = await Test.createTestingModule({ 30 | providers: [ 31 | ReportService, 32 | { 33 | provide: getRepositoryToken(ReportEntity), 34 | useValue: mockReportRepository(), 35 | }, 36 | { 37 | provide: getRepositoryToken(PostEntity), 38 | useValue: mockPostRepository(), 39 | }, 40 | ], 41 | }).compile(); 42 | 43 | service = module.get(ReportService); 44 | reportRepository = module.get>( 45 | getRepositoryToken(ReportEntity), 46 | ); 47 | postRepository = module.get>( 48 | getRepositoryToken(PostEntity), 49 | ); 50 | }); 51 | it('should be defined', () => { 52 | expect(service).toBeDefined(); 53 | }); 54 | 55 | describe('createReport()', function () { 56 | const body = new CreateReportDto(); 57 | body.post_id = 123; 58 | body.user_id = 'user'; 59 | body.description = 'test'; 60 | it('should bad request', function () { 61 | expect(async () => { 62 | await service.createReport(body, 'user'); 63 | }).rejects.toThrowError( 64 | new HttpException('자신의 게시글은 신고 할 수 없습니다.', 400), 65 | ); 66 | }); 67 | 68 | it('should not found', function () { 69 | postRepository.exist.mockResolvedValue(false); 70 | expect(async () => { 71 | await service.createReport(body, 'user1'); 72 | }).rejects.toThrowError( 73 | new HttpException('신고할 대상이 존재 하지 않습니다.', 404), 74 | ); 75 | }); 76 | 77 | it('should save', async function () { 78 | postRepository.exist.mockResolvedValue(true); 79 | await service.createReport(body, 'user1'); 80 | expect(reportRepository.save).toHaveBeenCalledTimes(1); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /BE/src/report/report.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Injectable } from '@nestjs/common'; 2 | import { CreateReportDto } from './dto/createReport.dto'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { ReportEntity } from '../entities/report.entity'; 6 | import { PostEntity } from '../entities/post.entity'; 7 | @Injectable() 8 | export class ReportService { 9 | constructor( 10 | @InjectRepository(ReportEntity) 11 | private reportRepository: Repository, 12 | @InjectRepository(PostEntity) 13 | private postRepository: Repository, 14 | ) {} 15 | async createReport(body: CreateReportDto, userId: string) { 16 | if (body.user_id === userId) { 17 | throw new HttpException('자신의 게시글은 신고 할 수 없습니다.', 400); 18 | } 19 | const isExist = await this.postRepository.exist({ 20 | where: { id: body.post_id }, 21 | }); 22 | if (!isExist) { 23 | throw new HttpException('신고할 대상이 존재 하지 않습니다.', 404); 24 | } 25 | const reportEntity = new ReportEntity(); 26 | reportEntity.post_id = body.post_id; 27 | reportEntity.user_hash = body.user_id; 28 | reportEntity.description = body.description; 29 | reportEntity.reporter = userId; 30 | await this.reportRepository.save(reportEntity); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /BE/src/users-block/users-block.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Param, 6 | Delete, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { UsersBlockService } from './users-block.service'; 10 | import { AuthGuard } from 'src/common/guard/auth.guard'; 11 | import { UserHash } from 'src/common/decorator/auth.decorator'; 12 | 13 | @Controller('users/block') 14 | @UseGuards(AuthGuard) 15 | export class UsersBlockController { 16 | constructor(private readonly usersBlockService: UsersBlockService) {} 17 | 18 | @Get() 19 | async blockUserList(@UserHash() userId: string) { 20 | return this.usersBlockService.getBlockUser(userId); 21 | } 22 | 23 | @Get('/:id') 24 | async blockUserCheck(@Param('id') id: string, @UserHash() userId: string) { 25 | return await this.usersBlockService.checkBlockUser(id, userId); 26 | } 27 | 28 | @Post('/:id') 29 | async blockUserAdd(@Param('id') id: string, @UserHash() userId: string) { 30 | await this.usersBlockService.addBlockUser(id, userId); 31 | } 32 | 33 | @Delete(':id') 34 | async blockUserRemove(@Param('id') id: string, @UserHash() userId: string) { 35 | await this.usersBlockService.removeBlockUser(id, userId); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BE/src/users-block/users-block.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersBlockService } from './users-block.service'; 3 | import { UsersBlockController } from './users-block.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { BlockUserEntity } from 'src/entities/blockUser.entity'; 6 | import { UserEntity } from 'src/entities/user.entity'; 7 | import { AuthGuard } from 'src/common/guard/auth.guard'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([BlockUserEntity, UserEntity])], 11 | controllers: [UsersBlockController], 12 | providers: [UsersBlockService, AuthGuard], 13 | }) 14 | export class UsersBlockModule {} 15 | -------------------------------------------------------------------------------- /BE/src/users/dto/createUser.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class CreateUserDto { 4 | @IsString() 5 | nickname: string; 6 | 7 | @IsString() 8 | social_email: string; 9 | 10 | @IsString() 11 | OAuth_domain: string; 12 | } 13 | -------------------------------------------------------------------------------- /BE/src/users/dto/usersUpdate.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdateUsersDto { 4 | @IsOptional() // 이 필드는 선택적으로 업데이트할 수 있도록 설정 5 | @IsString() 6 | nickname?: string; 7 | 8 | // @IsOptional() // 이 필드는 선택적으로 업데이트할 수 있도록 설정 9 | // @IsBoolean() 10 | // is_image_changed?: string; 11 | } 12 | -------------------------------------------------------------------------------- /BE/src/users/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Scope } from '@nestjs/common'; 2 | import { BaseRepository } from '../common/base.repository'; 3 | import { DataSource } from 'typeorm'; 4 | import { REQUEST } from '@nestjs/core'; 5 | import { UserEntity } from '../entities/user.entity'; 6 | 7 | @Injectable({ scope: Scope.REQUEST }) 8 | export class UserRepository extends BaseRepository { 9 | constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { 10 | super(dataSource, req); 11 | } 12 | 13 | async softDeleteCascade(userId: string) { 14 | const user = await this.getRepository(UserEntity).findOne({ 15 | where: { user_hash: userId }, 16 | relations: ['blocker_post', 'blocker'], 17 | }); 18 | await this.getRepository(UserEntity).softRemove(user); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /BE/src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Patch, 6 | Param, 7 | Delete, 8 | UseInterceptors, 9 | UploadedFile, 10 | HttpException, 11 | UseGuards, 12 | Body, 13 | Headers, 14 | } from '@nestjs/common'; 15 | import { UsersService } from './users.service'; 16 | import { FileInterceptor } from '@nestjs/platform-express'; 17 | import { MultiPartBody } from 'src/common/decorator/multiPartBody.decorator'; 18 | import { UpdateUsersDto } from './dto/usersUpdate.dto'; 19 | import { AuthGuard } from 'src/common/guard/auth.guard'; 20 | import { UserHash } from '../common/decorator/auth.decorator'; 21 | import { FileSizeValidator } from '../common/files.validator'; 22 | import { ImageService } from '../image/image.service'; 23 | import { NotificationService } from '../notification/notification.service'; 24 | import { TransactionInterceptor } from '../common/interceptor/transaction.interceptor'; 25 | 26 | @Controller('users') 27 | @UseGuards(AuthGuard) 28 | export class UsersController { 29 | constructor( 30 | private readonly usersService: UsersService, 31 | private readonly imageService: ImageService, 32 | private readonly notificationService: NotificationService, 33 | ) {} 34 | 35 | @Get(':id') 36 | async usersDetails(@Param('id') userId) { 37 | const user = await this.usersService.findUserById(userId); 38 | if (user === null) { 39 | throw new HttpException('유저가 존재하지않습니다.', 404); 40 | } else { 41 | return user; 42 | } 43 | } 44 | 45 | @Delete(':id') 46 | @UseInterceptors(TransactionInterceptor) 47 | async usersRemove( 48 | @Param('id') id: string, 49 | @UserHash() userId: string, 50 | @Headers('authorization') token: string, 51 | ) { 52 | await this.usersService.checkAuth(id, userId); 53 | await this.usersService.removeUser(userId, token); 54 | await this.notificationService.removeRegistrationToken(userId); 55 | } 56 | 57 | @Patch(':id') 58 | @UseInterceptors(FileInterceptor('image')) 59 | async usersModify( 60 | @Param('id') id: string, 61 | @MultiPartBody('profile') body: UpdateUsersDto, 62 | @UploadedFile(new FileSizeValidator()) file: Express.Multer.File, 63 | @UserHash() userId, 64 | ) { 65 | await this.usersService.checkAuth(id, userId); 66 | const imageLocation = file 67 | ? await this.imageService.uploadImage(file) 68 | : null; 69 | const nickname = body ? body.nickname : null; 70 | await this.usersService.updateUserById(nickname, imageLocation, userId); 71 | } 72 | 73 | @Post('registration-token') 74 | async registrationTokenSave( 75 | @Body('registration_token') registrationToken: string, 76 | @UserHash() userId: string, 77 | ) { 78 | await this.notificationService.registerToken(userId, registrationToken); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /BE/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { UserEntity } from '../entities/user.entity'; 6 | import { BlockUserEntity } from '../entities/blockUser.entity'; 7 | import { BlockPostEntity } from '../entities/blockPost.entity'; 8 | import { AuthGuard } from 'src/common/guard/auth.guard'; 9 | import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; 10 | import { UserRepository } from './user.repository'; 11 | import { ImageModule } from '../image/image.module'; 12 | import { NotificationModule } from '../notification/notification.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([ 17 | UserEntity, 18 | BlockUserEntity, 19 | BlockPostEntity, 20 | RegistrationTokenEntity, 21 | ]), 22 | ImageModule, 23 | NotificationModule, 24 | ], 25 | controllers: [UsersController], 26 | providers: [UsersService, AuthGuard, UserRepository], 27 | }) 28 | export class UsersModule {} 29 | -------------------------------------------------------------------------------- /BE/src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, Inject, Injectable } from '@nestjs/common'; 2 | import { CreateUserDto } from './dto/createUser.dto'; 3 | import { UserEntity } from '../entities/user.entity'; 4 | import { hashMaker } from '../common/hashMaker'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import * as jwt from 'jsonwebtoken'; 7 | import { CACHE_MANAGER, CacheStore } from '@nestjs/cache-manager'; 8 | import { UserRepository } from './user.repository'; 9 | 10 | @Injectable() 11 | export class UsersService { 12 | constructor( 13 | private userRepository: UserRepository, 14 | @Inject(CACHE_MANAGER) private cacheManager: CacheStore, 15 | private configService: ConfigService, 16 | ) {} 17 | 18 | async createUser(imageLocation: string, createUserDto: CreateUserDto) { 19 | const userEntity = new UserEntity(); 20 | userEntity.nickname = createUserDto.nickname; 21 | userEntity.social_id = createUserDto.social_email; 22 | userEntity.OAuth_domain = createUserDto.OAuth_domain; 23 | userEntity.profile_img = imageLocation; 24 | userEntity.user_hash = hashMaker(createUserDto.nickname).slice(0, 8); 25 | return await this.userRepository.getRepository(UserEntity).save(userEntity); 26 | } 27 | 28 | async findUserById(userId: string) { 29 | const user: UserEntity = await this.userRepository 30 | .getRepository(UserEntity) 31 | .findOne({ 32 | where: { user_hash: userId }, 33 | }); 34 | if (user) { 35 | user.profile_img = 36 | user.profile_img ?? this.configService.get('DEFAULT_PROFILE_IMAGE'); 37 | return { nickname: user.nickname, profile_img: user.profile_img }; 38 | } else { 39 | return null; 40 | } 41 | } 42 | 43 | async removeUser(userId: string, accessToken: string) { 44 | const decodedToken: any = jwt.decode(accessToken); 45 | if (decodedToken && decodedToken.exp) { 46 | const ttl: number = decodedToken.exp - Math.floor(Date.now() / 1000); 47 | await this.cacheManager.set(accessToken, 'logout', { ttl }); 48 | } 49 | await this.userRepository.softDeleteCascade(userId); 50 | } 51 | 52 | async checkAuth(id, userId) { 53 | const isDataExists = await this.userRepository 54 | .getRepository(UserEntity) 55 | .findOne({ 56 | where: { user_hash: id }, 57 | }); 58 | if (!isDataExists) { 59 | throw new HttpException('유저가 존재하지 않습니다.', 404); 60 | } 61 | if (id !== userId) { 62 | throw new HttpException('수정 권한이 없습니다.', 403); 63 | } 64 | } 65 | 66 | async updateUserById( 67 | nickname: string, 68 | imageLocation: string, 69 | userId: string, 70 | ) { 71 | const user = new UserEntity(); 72 | user.nickname = nickname ?? undefined; 73 | user.profile_img = imageLocation ?? undefined; 74 | 75 | await this.userRepository 76 | .getRepository(UserEntity) 77 | .update({ user_hash: userId }, user); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /BE/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /BE/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /BE/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /BE/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /iOS/.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj binary merge=union 2 | -------------------------------------------------------------------------------- /iOS/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/.gitkeep -------------------------------------------------------------------------------- /iOS/Village/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # 기본 적용되는 룰에서 사용하지 않는 룰 2 | disabled_rules: 3 | - trailing_whitespace # 후행 공백 4 | - file_length 5 | 6 | # 식별자 네이밍 커스텀 정의 7 | identifier_name: 8 | min_length: 2 9 | 10 | line_length: 130 11 | 12 | # 룰 적용 제외할 파일 13 | excluded: 14 | - Village/Application/AppDelegate.swift 15 | - Village/Application/SceneDelegate.swift 16 | -------------------------------------------------------------------------------- /iOS/Village/Village.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iOS/Village/Village.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iOS/Village/Village/Application/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_KEY 6 | AIzaSyD75ap_x5PKr4Q0elhwo3ZkP10VVLOeZoY 7 | GCM_SENDER_ID 8 | 667924926012 9 | PLIST_VERSION 10 | 1 11 | BUNDLE_ID 12 | kr.codesquad.boostcamp8.Village 13 | PROJECT_ID 14 | village-2ed97 15 | STORAGE_BUCKET 16 | village-2ed97.appspot.com 17 | IS_ADS_ENABLED 18 | 19 | IS_ANALYTICS_ENABLED 20 | 21 | IS_APPINVITE_ENABLED 22 | 23 | IS_GCM_ENABLED 24 | 25 | IS_SIGNIN_ENABLED 26 | 27 | GOOGLE_APP_ID 28 | 1:667924926012:ios:450e9e2456b3a7f78a7d39 29 | 30 | -------------------------------------------------------------------------------- /iOS/Village/Village/Application/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/8/23. 6 | // 7 | 8 | import UIKit 9 | 10 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 11 | 12 | var window: UIWindow? 13 | 14 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 15 | guard let windowScene = (scene as? UIWindowScene) else { return } 16 | 17 | addObservers() 18 | 19 | window = UIWindow(windowScene: windowScene) 20 | autoLogin() 21 | window?.makeKeyAndVisible() 22 | } 23 | 24 | private func addObservers() { 25 | observeLoginSucceedEvent() 26 | observeShouldLoginEvent() 27 | } 28 | 29 | private func observeLoginSucceedEvent() { 30 | NotificationCenter.default.addObserver(self, 31 | selector: #selector(rootViewControllerToTabBarController), 32 | name: .loginSucceed, 33 | object: nil 34 | ) 35 | } 36 | 37 | private func autoLogin() { 38 | guard let accessToken = JWTManager.shared.get()?.accessToken else { 39 | window?.rootViewController = LoginViewController() 40 | return 41 | } 42 | let endpoint = APIEndPoints.tokenExpire(accessToken: accessToken) 43 | Task { 44 | do { 45 | try await APIProvider.shared.request(with: endpoint) 46 | 47 | window?.rootViewController = AppTabBarController() 48 | } catch { 49 | window?.rootViewController = LoginViewController() 50 | } 51 | } 52 | } 53 | 54 | private func observeShouldLoginEvent() { 55 | NotificationCenter.default.addObserver(self, 56 | selector: #selector(rootViewControllerToLoginViewController), 57 | name: .shouldLogin, 58 | object: nil) 59 | } 60 | 61 | @objc 62 | private func rootViewControllerToTabBarController() { 63 | DispatchQueue.main.async { [weak self] in 64 | self?.window?.rootViewController = AppTabBarController() 65 | } 66 | } 67 | 68 | @objc 69 | private func rootViewControllerToLoginViewController() { 70 | DispatchQueue.main.async { [weak self] in 71 | self?.window?.rootViewController = LoginViewController() 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /iOS/Village/Village/Cache/ImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCache.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 12/13/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ImageCache { 11 | 12 | static let shared = ImageCache() 13 | private let cache = NSCache() 14 | 15 | private init() { 16 | cache.totalCostLimit = 100 17 | } 18 | 19 | func getImageData(for key: NSURL) -> NSData? { 20 | if let cachedImageData = cache.object(forKey: key as NSURL) { 21 | return cachedImageData 22 | } 23 | 24 | return nil 25 | } 26 | 27 | func setImageData(_ data: NSData, for key: NSURL) { 28 | cache.setObject(data, forKey: key as NSURL) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /iOS/Village/Village/Common/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/15/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ImageSystemName: String { 11 | 12 | case plus 13 | case photo 14 | case magnifyingGlass = "magnifyingglass" 15 | case chevronRight = "chevron.right" 16 | case xmark 17 | case house = "house" 18 | case houseFill = "house.fill" 19 | case message = "message" 20 | case messageFill = "message.fill" 21 | case person = "person" 22 | case personFill = "person.fill" 23 | case arrowLeft = "arrow.left" 24 | case ellipsis 25 | case paperplane 26 | case checkmark 27 | case cameraCircleFill = "camera.circle.fill" 28 | case cameraFill = "camera.fill" 29 | 30 | } 31 | 32 | enum Constant { 33 | 34 | static let maxImageCount = 12 35 | 36 | } 37 | -------------------------------------------------------------------------------- /iOS/Village/Village/Common/Encodable+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Encodable+.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/28/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Encodable { 11 | 12 | func toDictionary() throws -> [String: Any]? { 13 | let data = try JSONEncoder().encode(self) 14 | let jsonData = try JSONSerialization.jsonObject(with: data) 15 | return jsonData as? [String: Any] 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /iOS/Village/Village/Common/Int+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Int+.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/22/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Int { 11 | 12 | func priceText() -> String { 13 | let formatter = NumberFormatter() 14 | formatter.numberStyle = .decimal 15 | return formatter.string(for: self)! 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /iOS/Village/Village/Common/NotificationName+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationName+.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/29/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | 12 | static let openChatRoom = Notification.Name("OpenChatRoom") 13 | static let fcmToken = Notification.Name("FCMToken") 14 | static let loginSucceed = Notification.Name("LoginSucceed") 15 | static let shouldLogin = Notification.Name("ShouldLogin") 16 | static let postEdited = Notification.Name("PostEdited") 17 | static let postDeleted = Notification.Name("PostDeleted") 18 | static let postCreated = Notification.Name("PostCreated") 19 | static let postRefreshAll = Notification.Name("PostRefreshAll") 20 | 21 | } 22 | -------------------------------------------------------------------------------- /iOS/Village/Village/Common/PostNotificationPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostNotificationPublisher.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/12/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class PostNotificationPublisher { 11 | 12 | static let shared = PostNotificationPublisher() 13 | 14 | private let center: NotificationCenter 15 | 16 | init(center: NotificationCenter = .default) { 17 | self.center = center 18 | } 19 | 20 | func publishPostCreated(isRequest: Bool) { 21 | let info = ["type": PostType(isRequest: isRequest)] 22 | center.post(name: .postCreated, object: nil, userInfo: info) 23 | } 24 | 25 | func publishPostEdited(postID: Int) { 26 | let info = ["postID": postID] 27 | NotificationCenter.default.post(name: .postEdited, object: nil, userInfo: info) 28 | } 29 | 30 | func publishPostDeleted(postID: Int) { 31 | let info = ["postID": postID] 32 | NotificationCenter.default.post(name: .postDeleted, object: nil, userInfo: info) 33 | } 34 | 35 | func publishPostRefreshAll() { 36 | NotificationCenter.default.post(name: .postRefreshAll, object: nil) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /iOS/Village/Village/Common/PostType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostType.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum PostType { 11 | case rent 12 | case request 13 | 14 | init(isRequest: Bool) { 15 | self = isRequest ? .request : .rent 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/Dummy/ChatRoom.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "user": "dlwlrma", 4 | "chat_log": [{"sender":"user", "body":"안녕하ss세요.", "count":1}, {"sender":"me", "body":"안녕하세요.", "count":2}, {"sender":"user", "body":"네고 가능?안녕녕하ss세요.네고가능?안녕하ss세요.안녕하ss세요.", "count":3}], 5 | "post_image": "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 6 | "post_name": "Sony DSLR", 7 | "post_price": "10,000원", 8 | "post_is_request": false, 9 | "post_id": 101 10 | } 11 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/Dummy/Post.json: -------------------------------------------------------------------------------- 1 | { 2 | "body": [ 3 | { 4 | "title": "닌텐도 스위치", 5 | "price": 10000, 6 | "contents": "빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n빌려드립니다.\n", 7 | "post_id": 1, 8 | "user_id": "undefined", 9 | "is_request": false, 10 | "images": [ 11 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 12 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 13 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png" 14 | ], 15 | "start_date": "2023-11-21T12:34:56", 16 | "end_date": "2023-12-21T12:34:56" 17 | }, 18 | { 19 | "title": "플레이스테이션 5", 20 | "price": null, 21 | "contents": "빌려주세요.빌려주세요.빌려주세요.빌려주세요.빌려주세요.빌려주세요.빌려주세요.빌려주세요.", 22 | "post_id": 2, 23 | "user_id": "undefined", 24 | "is_request": true, 25 | "images": [], 26 | "start_date": "2023-11-21T12:34:56", 27 | "end_date": "2023-12-21T12:34:56" 28 | }, 29 | { 30 | "title": "에어팟 맥스", 31 | "contents": "빌려드립니다.빌려드립니다.빌려드립니다.빌려드립니다.빌려드립니다.빌려드립니다.빌려드립니다.빌려드립니다.", 32 | "price": 10000, 33 | "post_id": 3, 34 | "user_id": "undefined", 35 | "is_request": false, 36 | "images": [ 37 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 38 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 39 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png" 40 | ], 41 | "start_date": "2023-11-21T12:34:56", 42 | "end_date": "2023-12-21T12:34:56" 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/Dummy/PostMute.json: -------------------------------------------------------------------------------- 1 | 2 | [ 3 | { 4 | "title": "닌텐도 스위치", 5 | "post_id": 1, 6 | "user_id": "undefined", 7 | "is_request": false, 8 | "images": [ 9 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 10 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 11 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png" 12 | ] 13 | }, 14 | { 15 | "title": "플레이스테이션 5", 16 | "price": null, 17 | "post_id": 2, 18 | "user_id": "undefined", 19 | "is_request": true, 20 | "images": [], 21 | }, 22 | { 23 | "title": "에어팟 맥스", 24 | "post_id": 3, 25 | "user_id": "undefined", 26 | "is_request": false, 27 | "images": [ 28 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 29 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png", 30 | "https://cdn-icons-png.flaticon.com/512/12719/12719172.png" 31 | ] 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/Dummy/Posts.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"title":"Perphenazine","post_image":"http://dummyimage.com/165x100.png/cc0000/ffffff","price":"92281","post_id":1}, 3 | {"title":"Anefrin Nasal","post_image":"http://dummyimage.com/174x100.png/cc0000/ffffff","price":"77743","post_id":2}, 4 | {"title":"X-PEL PURE Anti-Lice","post_image":"http://dummyimage.com/187x100.png/ff4444/ffffff","price":"33","post_id":3}, 5 | {"title":"5-OAK MIX, BLACKJACK/BUR/POST/RED/WHITE POLLEN","post_image":"http://dummyimage.com/181x100.png/dddddd/000000","price":"3098","post_id":4}, 6 | {"title":"Amoxicillin","post_image":"http://dummyimage.com/205x100.png/cc0000/ffffff","price":"5","post_id":5}, 7 | {"title":"Mirtazapine","post_image":"http://dummyimage.com/205x100.png/cc0000/ffffff","price":"74334","post_id":6}, 8 | {"title":"flormar REBORN BB SUNSCREEN BROAD SPECTRUM SPF 30 CP13 Nude Ivory","post_image":"http://dummyimage.com/233x100.png/cc0000/ffffff","price":"47","post_id":7}, 9 | {"title":"Anti-Bacterial Hand","post_image":"http://dummyimage.com/122x100.png/cc0000/ffffff","price":"42","post_id":8}, 10 | {"title":"Simvastatin","post_image":"http://dummyimage.com/116x100.png/dddddd/000000","price":"08","post_id":9}, 11 | {"title":"Healthy Makeup","post_image":"http://dummyimage.com/242x100.png/dddddd/000000","price":"1777","post_id":10} 12 | ] 13 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/Dummy/Users.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": "koomin", 3 | "profile_img": "https://dummyimage.com/165x100.png/cc0000/ffffff" 4 | } 5 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/PersistentStorage/FCMManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FCMManager.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/3/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class FCMManager { 11 | 12 | static let shared = FCMManager() 13 | 14 | var fcmToken: String? 15 | 16 | init() { 17 | setNotification() 18 | } 19 | 20 | private func setNotification() { 21 | NotificationCenter.default.addObserver(self, selector: #selector(fcmTokenCalled), name: .fcmToken, object: nil) 22 | } 23 | 24 | @objc private func fcmTokenCalled() { 25 | sendFCMToken() 26 | } 27 | 28 | func sendFCMToken() { 29 | if JWTManager.shared.get() != nil { 30 | guard let token = fcmToken else { return } 31 | let endPoint = APIEndPoints.fcmTokenSend(fcmToken: token) 32 | Task { 33 | do { 34 | try await APIProvider.shared.request(with: endPoint) 35 | } 36 | } 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/PersistentStorage/KeychainError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainError.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/29/23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum KeychainError: LocalizedError { 11 | 12 | case duplicate 13 | case notFound 14 | case unknown(OSStatus) 15 | 16 | var errorDescription: String { 17 | switch self { 18 | case .duplicate: 19 | "토큰이 이미 키체인에 존재합니다." 20 | case .notFound: 21 | "키체인에서 토큰을 찾지 못했습니다." 22 | case .unknown(let status): 23 | "알 수 없는 에러입니다: \(status)" 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /iOS/Village/Village/Data/PersistentStorage/KeychainManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainManager.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/29/23. 6 | // 7 | 8 | import Foundation 9 | import Security 10 | 11 | struct KeychainManager { 12 | 13 | func write(key: String, value: T) throws { 14 | do { 15 | let data = try JSONEncoder().encode(value) 16 | let query: [CFString: Any] = [ 17 | kSecClass: kSecClassGenericPassword, 18 | kSecAttrAccount: key, 19 | kSecValueData: data 20 | ] 21 | 22 | let status = SecItemAdd(query as CFDictionary, nil) 23 | guard status != errSecDuplicateItem else { 24 | throw KeychainError.duplicate 25 | } 26 | 27 | guard status == errSecSuccess else { 28 | throw KeychainError.unknown(status) 29 | } 30 | } 31 | } 32 | 33 | func read(key: String) -> T? { 34 | let query: [CFString: Any] = [ 35 | kSecClass: kSecClassGenericPassword, 36 | kSecAttrAccount: key, 37 | kSecReturnAttributes: true, 38 | kSecReturnData: true, 39 | kSecMatchLimit: kSecMatchLimitOne 40 | ] 41 | 42 | var result: CFTypeRef? 43 | SecItemCopyMatching(query as CFDictionary, &result) 44 | guard let result = result as? [String: AnyObject], 45 | let data = result[kSecValueData as String] as? Data, 46 | let token = try? JSONDecoder().decode(T.self, from: data) else { return nil } 47 | 48 | return token 49 | } 50 | 51 | func delete(key: String) throws { 52 | let query: [CFString: Any] = [ 53 | kSecClass: kSecClassGenericPassword, 54 | kSecAttrAccount: key 55 | ] 56 | 57 | let status = SecItemDelete(query as CFDictionary) 58 | 59 | guard status != errSecItemNotFound else { throw KeychainError.notFound } 60 | guard status == errSecSuccess else { throw KeychainError.unknown(status) } 61 | } 62 | 63 | func update(key: String, newValue: T) throws { 64 | do { 65 | let data = try JSONEncoder().encode(newValue) 66 | let query: [CFString: Any] = [ 67 | kSecClass: kSecClassGenericPassword, 68 | kSecAttrAccount: key 69 | ] 70 | let attribute: [CFString: Any] = [ 71 | kSecValueData: data 72 | ] 73 | let status = SecItemUpdate(query as CFDictionary, attribute as CFDictionary) 74 | 75 | guard status != errSecItemNotFound else { throw KeychainError.notFound } 76 | guard status == errSecSuccess else { throw KeychainError.unknown(status) } 77 | } 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Entity/AuthenticationToken.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Token.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/27/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AuthenticationToken: Codable { 11 | 12 | let accessToken: String 13 | let refreshToken: String 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case accessToken = "access_token" 17 | case refreshToken = "refresh_token" 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/AppleOAuthDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleOAuthDTO.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/27/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct AppleOAuthDTO: Encodable { 11 | 12 | let identityToken: String 13 | let authorizationCode: String 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case identityToken = "identity_token" 17 | case authorizationCode = "authorization_code" 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/BlockedUserDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlockedUserDTO.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/10/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct BlockedUserDTO: Decodable, Hashable { 11 | 12 | let nickname: String? 13 | let profileImageURL: String? 14 | let userID: String 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case nickname 18 | case profileImageURL = "profile_img" 19 | case userID = "user_id" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/ChatRoomRequestDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatRoomRequestDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/12/03. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ChatRoomRequestDTO: Encodable { 11 | 12 | let roomID: Int 13 | 14 | } 15 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/GetAllReadResponseDTO .swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetAllReadResponseDTO .swift 3 | // Village 4 | // 5 | // Created by 박동재 on 12/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GetAllReadResponseDTO: Codable { 11 | 12 | let allRead: Bool 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case allRead = "all_read" 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/GetChatListResponseDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetChatListResponseDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 12/6/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ChatListData: Hashable, Codable { 11 | 12 | let roomID: Int 13 | let writer: String? 14 | let writerProfileIMG: String? 15 | let writerNickname: String? 16 | let user: String? 17 | let userProfileIMG: String? 18 | let userNickname: String? 19 | let postID: Int 20 | let postTitle: String? 21 | let postThumbnail: String? 22 | let lastChat: String? 23 | let lastChatDate: String? 24 | let allRead: Bool 25 | 26 | enum CodingKeys: String, CodingKey { 27 | case roomID = "room_id" 28 | case writer 29 | case writerProfileIMG = "writer_profile_img" 30 | case writerNickname = "writer_nickname" 31 | case user 32 | case userProfileIMG = "user_profile_img" 33 | case userNickname = "user_nickname" 34 | case postID = "post_id" 35 | case postTitle = "post_title" 36 | case postThumbnail = "post_thumbnail" 37 | case lastChat = "last_chat" 38 | case lastChatDate = "last_chat_date" 39 | case allRead = "all_read" 40 | } 41 | 42 | } 43 | 44 | struct GetChatListResponseDTO: Hashable, Codable { 45 | 46 | let allRead: Bool 47 | let chatList: [ChatListData] 48 | 49 | enum CodingKeys: String, CodingKey { 50 | case allRead = "all_read" 51 | case chatList = "chat_list" 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/GetRoomResponseDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetRoomResponseDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/12/05. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Chat: Hashable, Codable { 11 | 12 | let id: Int 13 | let message: String 14 | let sender: String 15 | let chatRoom: Int 16 | let isRead: Bool 17 | let createDate: String 18 | let deleteDate: String? 19 | let count: Int 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case id 23 | case message 24 | case sender 25 | case chatRoom = "chat_room" 26 | case isRead = "is_read" 27 | case createDate = "create_date" 28 | case deleteDate = "delete_date" 29 | case count 30 | } 31 | 32 | } 33 | 34 | struct GetRoomResponseDTO: Codable { 35 | 36 | let writer: String 37 | let writerProfileIMG: String 38 | let user: String 39 | let userProfileIMG: String 40 | let postID: Int 41 | let chatLog: [Chat] 42 | 43 | enum CodingKeys: String, CodingKey { 44 | case writer 45 | case writerProfileIMG = "writer_profile_img" 46 | case user 47 | case userProfileIMG = "user_profile_img" 48 | case postID = "post_id" 49 | case chatLog = "chat_log" 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PatchUserDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PatchUserDTO.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/9/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PatchUserInfo: Encodable { 11 | let nickname: String? 12 | } 13 | 14 | struct PatchUserDTO: Encodable, MultipartFormData { 15 | 16 | let userInfo: PatchUserInfo? 17 | let image: Data? 18 | let userID: String 19 | var boundary: String = UUID().uuidString 20 | var httpBody: Data { 21 | let body = NSMutableData() 22 | 23 | var fieldString = "" 24 | if let dictionary = try? userInfo.toDictionary(), 25 | !dictionary.isEmpty, 26 | let data = try? JSONSerialization.data(withJSONObject: dictionary), 27 | let string = String(data: data, encoding: .utf8) { 28 | fieldString += "--\(boundary)\r\n" 29 | fieldString += "Content-Disposition: form-data; name=\"profile\"\r\n" 30 | fieldString += "\r\n" 31 | fieldString += "\(string)\r\n" 32 | } 33 | 34 | body.appendString(fieldString) 35 | 36 | if let image = image { 37 | body.appendString("--\(boundary)\r\n") 38 | body.appendString("Content-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\n") 39 | body.appendString("Content-Type: image\r\n\r\n") 40 | body.append(image) 41 | body.appendString("\r\n") 42 | } 43 | body.appendString("--\(boundary)--") 44 | 45 | return body as Data 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostCreateDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostCreateDTO.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/24/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostCreateInfo: Codable { 11 | 12 | let postInfo: PostInfoDTO 13 | let images: [ImageDTO] 14 | 15 | } 16 | 17 | struct PostInfoDTO: Codable { 18 | 19 | let title: String 20 | let description: String 21 | let price: Int? 22 | let isRequest: Bool 23 | let startDate: String 24 | let endDate: String 25 | 26 | enum CodingKeys: String, CodingKey { 27 | case title 28 | case price 29 | case description 30 | case isRequest = "is_request" 31 | case startDate = "start_date" 32 | case endDate = "end_date" 33 | } 34 | } 35 | 36 | struct ImageDTO: Codable { 37 | let fileName: String 38 | let type: String 39 | let data: Data 40 | 41 | enum CodingKeys: String, CodingKey { 42 | case fileName = "filename" 43 | case type 44 | case data 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostListRequestDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostRequestDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/22. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostListRequestDTO: Encodable { 11 | 12 | let searchKeyword: String? 13 | let requestFilter: String? 14 | let writer: String? 15 | let page: String? 16 | 17 | init(searchKeyword: String? = nil, requestFilter: String? = nil, writer: String? = nil, page: String? = nil) { 18 | self.searchKeyword = searchKeyword 19 | self.requestFilter = requestFilter 20 | self.writer = writer 21 | self.page = page 22 | } 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case searchKeyword 26 | case requestFilter 27 | case writer 28 | case page 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostModifyRequestDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostModifyRequestDTO.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/24/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostModifyRequestDTO: Encodable, MultipartFormData { 11 | 12 | let postInfo: PostInfoDTO 13 | let image: [Data] 14 | var postID: Int? 15 | let boundary = UUID().uuidString 16 | 17 | var httpBody: Data { 18 | let body = NSMutableData() 19 | 20 | var fieldString = "--\(boundary)\r\n" 21 | fieldString += "Content-Disposition: form-data; name=\"post_info\"\r\n" 22 | fieldString += "\r\n" 23 | 24 | if let dictionary = try? postInfo.toDictionary(), 25 | !dictionary.isEmpty, 26 | let data = try? JSONSerialization.data(withJSONObject: dictionary), 27 | let string = String(data: data, encoding: .utf8) { 28 | fieldString += "\(string)\r\n" 29 | } 30 | 31 | body.appendString(fieldString) 32 | 33 | image.forEach { image in 34 | body.appendString("--\(boundary)\r\n") 35 | body.appendString("Content-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\n") 36 | body.appendString("Content-Type: image\r\n\r\n") 37 | body.append(image) 38 | body.appendString("\r\n") 39 | } 40 | body.appendString("--\(boundary)--") 41 | 42 | return body as Data 43 | } 44 | 45 | enum CodingKeys: String, CodingKey { 46 | case postInfo = "post_info" 47 | case image 48 | } 49 | 50 | } 51 | 52 | struct PostInfoDTO: Encodable { 53 | 54 | let title: String 55 | let description: String 56 | let price: Int? 57 | let isRequest: Bool 58 | let startDate: String 59 | let endDate: String 60 | let deletedImages: [String] 61 | 62 | enum CodingKeys: String, CodingKey { 63 | case title 64 | case price 65 | case description 66 | case isRequest = "is_request" 67 | case startDate = "start_date" 68 | case endDate = "end_date" 69 | case deletedImages = "deleted_images" 70 | } 71 | 72 | } 73 | 74 | extension NSMutableData { 75 | 76 | func appendString(_ string: String) { 77 | if let data = string.data(using: .utf8) { 78 | self.append(data) 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostMuteResponseDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostMuteResponseDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/12/04. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostMuteResponseDTO: Hashable, Decodable { 11 | 12 | let title: String 13 | let postImage: String? 14 | let postID: Int 15 | let isRequest: Bool 16 | let startDate: String 17 | let endDate: String 18 | let price: Int? 19 | 20 | enum CodingKeys: String, CodingKey { 21 | case title 22 | case postImage = "post_image" 23 | case postID = "post_id" 24 | case isRequest = "is_request" 25 | case startDate = "start_date" 26 | case endDate = "end_date" 27 | case price 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostResponseDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostResponseDTO.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/24/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostResponseDTO: Decodable { 11 | 12 | let title: String 13 | let description: String 14 | let price: Int? 15 | let postID: Int 16 | let userID: String 17 | let images: [String] 18 | let isRequest: Bool 19 | let startDate: String 20 | let endDate: String 21 | 22 | enum CodingKeys: String, CodingKey { 23 | case title 24 | case description 25 | case price 26 | case images 27 | case postID = "post_id" 28 | case userID = "user_id" 29 | case isRequest = "is_request" 30 | case startDate = "start_date" 31 | case endDate = "end_date" 32 | } 33 | 34 | } 35 | 36 | extension PostResponseDTO: Hashable { 37 | 38 | func hash(into hasher: inout Hasher) { 39 | hasher.combine(postID) 40 | } 41 | 42 | static func == (lhs: Self, rhs: Self) -> Bool { 43 | return lhs.postID == rhs.postID 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostRoomRequestDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostRoomRequestDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/12/04. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostRoomRequestDTO: Codable { 11 | 12 | let writer: String 13 | let postID: Int 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case writer 17 | case postID = "post_id" 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/PostRoomResponseDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostRoomResponseDTO.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/12/04. 6 | // 7 | 8 | import Foundation 9 | 10 | struct PostRoomResponseDTO: Codable { 11 | 12 | let roomID: Int 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case roomID = "room_id" 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/Protocol/MultipartFormData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultipartFormData.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/28/23. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MultipartFormData { 11 | var boundary: String { get } 12 | var httpBody: Data { get } 13 | } 14 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/ReportDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReportDTO.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ReportDTO: Encodable { 11 | 12 | let postID: Int 13 | let userID: String 14 | let description: String 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case postID = "post_id" 18 | case userID = "user_id" 19 | case description 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/RequestFilterDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequestFilterDTO.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/9/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct RequestFilterDTO: Encodable { 11 | 12 | let requestFilter: String 13 | 14 | } 15 | -------------------------------------------------------------------------------- /iOS/Village/Village/Domain/Network/UserResponseDTO.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserResponseDTO.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/24/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UserResponseDTO: Hashable, Decodable { 11 | 12 | let nickname: String 13 | let profileImageURL: String 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case nickname 17 | case profileImageURL = "profile_img" 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/APIProvider/AuthInterceptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthInterceptor.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/4/23. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Interceptor { 11 | func adapt(request: URLRequest) -> URLRequest? 12 | func retry(request: URLRequest, error: NetworkError, attempt: Int) async -> RetryResult 13 | } 14 | 15 | enum RetryResult { 16 | case retry 17 | case doNotRetry 18 | case doNotRetryWithError(Error) 19 | } 20 | 21 | struct AuthInterceptor: Interceptor { 22 | 23 | private let session: URLSession 24 | private let maxAttempt: Int 25 | 26 | init(session: URLSession = .shared, maxAttempt: Int = 3) { 27 | self.session = session 28 | self.maxAttempt = maxAttempt 29 | } 30 | 31 | func adapt(request: URLRequest) -> URLRequest? { 32 | guard let accessToken = JWTManager.shared.get()?.accessToken else { return request } 33 | 34 | var urlRequest = request 35 | urlRequest.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 36 | return urlRequest 37 | } 38 | 39 | func retry(request: URLRequest, error: NetworkError, attempt: Int) async -> RetryResult { 40 | if attempt > maxAttempt { 41 | return .doNotRetry 42 | } 43 | 44 | switch error { 45 | case .serverError(.unauthorized): 46 | do { 47 | try await refreshToken() 48 | return .retry 49 | } catch let error { 50 | NotificationCenter.default.post(name: .shouldLogin, object: nil) 51 | return .doNotRetryWithError(error) 52 | } 53 | default: 54 | return .retry 55 | } 56 | } 57 | 58 | } 59 | 60 | private extension AuthInterceptor { 61 | 62 | func refreshToken() async throws { 63 | guard let refreshToken = JWTManager.shared.get()?.refreshToken else { return } 64 | 65 | do { 66 | let request = try APIEndPoints.tokenRefresh(refreshToken: refreshToken).makeURLRequest() 67 | let (data, response) = try await session.data(for: request) 68 | 69 | guard let response = response as? HTTPURLResponse, 70 | (200..<300).contains(response.statusCode) else { 71 | throw NetworkError.refreshTokenExpired 72 | } 73 | let newToken = try JSONDecoder().decode(AuthenticationToken.self, from: data) 74 | try JWTManager.shared.update(token: newToken) 75 | } catch { 76 | throw error 77 | } 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/EndPoint/EndPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EndPoint.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | final class EndPoint: Requestable, Responsable { 11 | 12 | typealias Response = R 13 | 14 | var baseURL: String 15 | var path: String 16 | var method: HTTPMethod 17 | var queryParameters: Encodable? 18 | var bodyParameters: Encodable? 19 | var headers: [String: String]? 20 | 21 | init(baseURL: String, 22 | path: String = "", 23 | method: HTTPMethod = .GET, 24 | queryParameters: Encodable? = nil, 25 | bodyParameters: Encodable? = nil, 26 | headers: [String: String]? = [:]) { 27 | self.baseURL = baseURL 28 | self.path = path 29 | self.method = method 30 | self.queryParameters = queryParameters 31 | self.bodyParameters = bodyParameters 32 | self.headers = headers 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/EndPoint/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPMethod.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum HTTPMethod: String { 11 | 12 | case GET 13 | case POST 14 | case PATCH 15 | case DELETE 16 | 17 | } 18 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/EndPoint/Protocol/Requestable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Requestable.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Requestable { 11 | 12 | var baseURL: String { get } 13 | var path: String { get } 14 | var method: HTTPMethod { get } 15 | var queryParameters: Encodable? { get } 16 | var bodyParameters: Encodable? { get } 17 | var headers: [String: String]? { get } 18 | 19 | } 20 | 21 | extension Requestable { 22 | 23 | func makeURLRequest() throws -> URLRequest { 24 | let url = try makeURL() 25 | var urlRequest = URLRequest(url: url) 26 | 27 | do { 28 | if let bodyParameters = try bodyParameters?.toDictionary() { 29 | urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: bodyParameters) 30 | } 31 | } catch { 32 | urlRequest.httpBody = bodyParameters as? Data 33 | } 34 | 35 | urlRequest.httpMethod = method.rawValue 36 | 37 | headers?.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) } 38 | 39 | return urlRequest 40 | } 41 | 42 | func makeURL() throws -> URL { 43 | let fullPath = baseURL + path 44 | guard var urlComponents = URLComponents(string: fullPath) else { throw NetworkError.componentsError } 45 | 46 | if let queryParameters { 47 | guard let parameters = try queryParameters.toDictionary(), 48 | !parameters.isEmpty else { throw NetworkError.queryParameterError } 49 | urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") } 50 | } 51 | 52 | guard let url = urlComponents.url else { throw NetworkError.componentsError } 53 | 54 | return url 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/EndPoint/Protocol/Responsable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Responsable.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Responsable { 11 | 12 | associatedtype Response 13 | 14 | } 15 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/EndPoint/Reponsable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reponsable.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol Responsable { 11 | 12 | associatedtype Response 13 | 14 | } 15 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/NetworkError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkError.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum NetworkError: Error { 11 | 12 | case unknownError 13 | case queryParameterError 14 | case componentsError 15 | case urlRequestError 16 | case serverError(ServerError) 17 | case emptyData 18 | case parsingError 19 | case refreshTokenExpired 20 | case decodingError(Error) 21 | 22 | var errorDescription: String { 23 | switch self { 24 | case .unknownError: 25 | "Unknown Error." 26 | case .queryParameterError: 27 | "Query Parameter toDictionary Failed" 28 | case .componentsError: 29 | "URL Components Error." 30 | case .urlRequestError: 31 | "URL Request Error." 32 | case .serverError(let serverError): 33 | "Server Error: \(serverError)." 34 | case .emptyData: 35 | "Empty Data." 36 | case .parsingError: 37 | "Parsing Error." 38 | case .decodingError(let error): 39 | "Decoding Error: \(error)." 40 | case .refreshTokenExpired: 41 | "Refresh Token Expired. Login again." 42 | } 43 | } 44 | 45 | } 46 | 47 | enum ServerError: Int { 48 | 49 | case unknown 50 | case badRequest = 400 51 | case unauthorized = 401 52 | case forbidden = 403 53 | case notFound = 404 54 | case alreadyRegister = 409 55 | case serverError = 500 56 | 57 | } 58 | -------------------------------------------------------------------------------- /iOS/Village/Village/Network/NetworkService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkService.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/17. 6 | // 7 | 8 | import Foundation 9 | 10 | enum NetworkService { 11 | 12 | static func loadData(from urlString: String) async throws -> Data { 13 | guard let url = URL(string: urlString) else { throw NetworkError.urlRequestError } 14 | let (data, _) = try await URLSession.shared.data(from: url) 15 | return data 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/BlockedUser/ViewModel/BlockedUsersViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlockedUsersViewModel.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/10/23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | struct BlockUserInfo { 12 | 13 | let userID: String 14 | let isBlocked: Bool 15 | 16 | } 17 | 18 | final class BlockedUsersViewModel { 19 | 20 | private let blockedUsers = CurrentValueSubject<[BlockedUserDTO], Never>([]) 21 | 22 | private let blockToggleOutput = PassthroughSubject() 23 | 24 | private var cancellableBag = Set() 25 | 26 | func transform(input: Input) -> Output { 27 | 28 | input.blockedToggleInput 29 | .receive(on: DispatchQueue.main) 30 | .sink { [weak self] userInfo in 31 | self?.toggleBlock( 32 | userID: userInfo.userID, 33 | isBlocked: userInfo.isBlocked 34 | ) 35 | } 36 | .store(in: &cancellableBag) 37 | 38 | return Output( 39 | blockedUsersOutput: blockedUsers.eraseToAnyPublisher() 40 | ) 41 | } 42 | 43 | init() { 44 | getMyBlockedUsers() 45 | } 46 | 47 | deinit { 48 | PostNotificationPublisher.shared.publishPostRefreshAll() 49 | } 50 | 51 | private func getMyBlockedUsers() { 52 | let endpoint = APIEndPoints.getBlockedUsers() 53 | 54 | Task { 55 | do { 56 | guard let data = try await APIProvider.shared.request(with: endpoint) else { return } 57 | blockedUsers.send(data) 58 | } catch { 59 | dump(error) 60 | } 61 | } 62 | } 63 | 64 | private func toggleBlock(userID: String, isBlocked: Bool) { 65 | let endpoint = isBlocked ? 66 | APIEndPoints.blockUser(userID: userID) : 67 | APIEndPoints.unblockUser(userID: userID) 68 | 69 | Task { 70 | do { 71 | try await APIProvider.shared.request(with: endpoint) 72 | } catch { 73 | dump(error) 74 | } 75 | } 76 | } 77 | 78 | } 79 | 80 | extension BlockedUsersViewModel { 81 | 82 | struct Input { 83 | let blockedToggleInput: AnyPublisher 84 | } 85 | 86 | struct Output { 87 | let blockedUsersOutput: AnyPublisher<[BlockedUserDTO], Never> 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/ChatList/ViewModel/ChatListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatListViewModel.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/24. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class ChatListViewModel { 12 | 13 | private var cancellableBag = Set() 14 | private let chatList = PassthroughSubject() 15 | 16 | func transform(input: Input) -> Output { 17 | input.getChatListSubject 18 | .sink(receiveValue: { [weak self] () in 19 | self?.getChatList() 20 | }) 21 | .store(in: &cancellableBag) 22 | 23 | return Output(chatList: chatList.eraseToAnyPublisher()) 24 | } 25 | 26 | private func getChatList() { 27 | let endpoint = APIEndPoints.getChatList() 28 | 29 | Task { 30 | do { 31 | guard let data = try await APIProvider.shared.request(with: endpoint) else { return } 32 | chatList.send(data) 33 | } catch let error as NetworkError { 34 | chatList.send(completion: .failure(error)) 35 | } 36 | } 37 | } 38 | 39 | func deleteChatRoom(roomID: Int) { 40 | let request = ChatRoomRequestDTO(roomID: roomID) 41 | let endpoint = APIEndPoints.deleteChatRoom(with: request) 42 | 43 | Task { 44 | do { 45 | try await APIProvider.shared.request(with: endpoint) 46 | } catch { 47 | dump(error) 48 | } 49 | } 50 | } 51 | 52 | } 53 | 54 | extension ChatListViewModel { 55 | 56 | struct Input { 57 | let getChatListSubject: AnyPublisher 58 | } 59 | 60 | struct Output { 61 | let chatList: AnyPublisher 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/ChatRoom/View/ChatRoomTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatRoomTableViewCell.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/28/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ChatRoomTableViewCell: UITableViewCell { 11 | 12 | private let messageView: UITextView = { 13 | let textView = UITextView() 14 | textView.translatesAutoresizingMaskIntoConstraints = false 15 | textView.isEditable = false 16 | textView.isScrollEnabled = false 17 | textView.setLayer(borderWidth: 0) 18 | textView.font = UIFont.systemFont(ofSize: 16) 19 | textView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) 20 | textView.textColor = .white 21 | textView.sizeToFit() 22 | textView.backgroundColor = .myChatMessage 23 | 24 | return textView 25 | }() 26 | 27 | private let profileImageView: UIImageView = { 28 | let imageView = UIImageView() 29 | imageView.translatesAutoresizingMaskIntoConstraints = false 30 | imageView.layer.cornerRadius = 12 31 | imageView.clipsToBounds = true 32 | 33 | return imageView 34 | }() 35 | 36 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 37 | super.init(style: style, reuseIdentifier: reuseIdentifier) 38 | setUI() 39 | } 40 | 41 | required init?(coder: NSCoder) { 42 | fatalError("init(coder:) has not been implemented") 43 | } 44 | 45 | override func prepareForReuse() { 46 | profileImageView.image = nil 47 | messageView.text = nil 48 | } 49 | 50 | func configureData(message: String) { 51 | messageView.text = message 52 | messageView.sizeToFit() 53 | } 54 | 55 | func configureImage(image: Data) { 56 | profileImageView.image = UIImage(data: image) 57 | } 58 | 59 | } 60 | 61 | private extension ChatRoomTableViewCell { 62 | 63 | func setUI() { 64 | contentView.addSubview(messageView) 65 | contentView.addSubview(profileImageView) 66 | 67 | setConstraints() 68 | } 69 | 70 | func setConstraints() { 71 | NSLayoutConstraint.activate([ 72 | profileImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), 73 | profileImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), 74 | profileImageView.widthAnchor.constraint(equalToConstant: 24), 75 | profileImageView.heightAnchor.constraint(equalToConstant: 24), 76 | messageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), 77 | messageView.trailingAnchor.constraint(equalTo: profileImageView.leadingAnchor, constant: -10), 78 | messageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), 79 | messageView.widthAnchor.constraint(lessThanOrEqualToConstant: 255) 80 | ]) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/ChatRoom/View/OpponentChatTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpponentChatTableViewCell.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 12/8/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class OpponentChatTableViewCell: UITableViewCell { 11 | 12 | private let messageView: UITextView = { 13 | let textView = UITextView() 14 | textView.translatesAutoresizingMaskIntoConstraints = false 15 | textView.isEditable = false 16 | textView.isScrollEnabled = false 17 | textView.setLayer(borderWidth: 0) 18 | textView.font = UIFont.systemFont(ofSize: 16) 19 | textView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) 20 | textView.textColor = .white 21 | textView.sizeToFit() 22 | textView.backgroundColor = .userChatMessage 23 | 24 | return textView 25 | }() 26 | 27 | private let profileImageView: UIImageView = { 28 | let imageView = UIImageView() 29 | imageView.translatesAutoresizingMaskIntoConstraints = false 30 | imageView.layer.cornerRadius = 12 31 | imageView.clipsToBounds = true 32 | 33 | return imageView 34 | }() 35 | 36 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 37 | super.init(style: style, reuseIdentifier: reuseIdentifier) 38 | setUI() 39 | } 40 | 41 | required init?(coder: NSCoder) { 42 | fatalError("init(coder:) has not been implemented") 43 | } 44 | 45 | override func prepareForReuse() { 46 | profileImageView.image = nil 47 | messageView.text = nil 48 | } 49 | 50 | func configureData(message: String) { 51 | messageView.text = message 52 | messageView.sizeToFit() 53 | } 54 | 55 | func configureImage(image: Data) { 56 | profileImageView.image = UIImage(data: image) 57 | } 58 | 59 | } 60 | 61 | private extension OpponentChatTableViewCell { 62 | 63 | func setUI() { 64 | contentView.addSubview(messageView) 65 | contentView.addSubview(profileImageView) 66 | 67 | setConstraints() 68 | } 69 | 70 | func setConstraints() { 71 | NSLayoutConstraint.activate([ 72 | profileImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), 73 | profileImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), 74 | profileImageView.widthAnchor.constraint(equalToConstant: 24), 75 | profileImageView.heightAnchor.constraint(equalToConstant: 24), 76 | messageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), 77 | messageView.leadingAnchor.constraint(equalTo: profileImageView.trailingAnchor, constant: 10), 78 | messageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), 79 | messageView.widthAnchor.constraint(lessThanOrEqualToConstant: 255) 80 | ]) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Commons/Cell/RentPostTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RentPostTableViewCell.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/5/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class RentPostTableViewCell: UITableViewCell { 11 | 12 | private let postSummaryView: RentPostSummaryView = { 13 | let view = RentPostSummaryView() 14 | view.translatesAutoresizingMaskIntoConstraints = false 15 | 16 | return view 17 | }() 18 | 19 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 20 | super.init(style: style, reuseIdentifier: reuseIdentifier) 21 | contentView.addSubview(postSummaryView) 22 | configureConstraints() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | override func prepareForReuse() { 30 | postSummaryView.postImageView.image = nil 31 | postSummaryView.postTitleLabel.text = nil 32 | postSummaryView.postPriceLabel.text = nil 33 | } 34 | 35 | func configureData(post: PostResponseDTO) { 36 | postSummaryView.postTitleLabel.text = post.title 37 | postSummaryView.setPrice(price: post.price) 38 | configureImageView(imageURL: post.images.first) 39 | } 40 | 41 | private func configureImageView(imageURL: String?) { 42 | guard let imageURL = imageURL else { 43 | postSummaryView.postImageView.image = UIImage(systemName: ImageSystemName.photo.rawValue)? 44 | .withTintColor(.primary500, renderingMode: .alwaysOriginal) 45 | postSummaryView.postImageView.backgroundColor = .primary100 46 | return 47 | } 48 | 49 | Task { 50 | do { 51 | let data = try await APIProvider.shared.request(from: imageURL) 52 | guard let image = UIImage(data: data) else { 53 | postSummaryView.postImageView.image = UIImage(systemName: ImageSystemName.photo.rawValue)? 54 | .withTintColor(.primary500, renderingMode: .alwaysOriginal) 55 | postSummaryView.postImageView.backgroundColor = .primary100 56 | return 57 | } 58 | postSummaryView.postImageView.backgroundColor = nil 59 | postSummaryView.postImageView.image = image 60 | } catch { 61 | dump(error) 62 | } 63 | } 64 | } 65 | 66 | private func configureConstraints() { 67 | NSLayoutConstraint.activate([ 68 | postSummaryView.topAnchor.constraint(equalTo: contentView.topAnchor), 69 | postSummaryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 70 | postSummaryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 71 | postSummaryView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) 72 | ]) 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Commons/Cell/RequstPostTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RequstPostTableViewCell.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/5/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class RequestPostTableViewCell: UITableViewCell { 11 | 12 | private let postSummaryView: RequestPostSummaryView = { 13 | let view = RequestPostSummaryView() 14 | view.translatesAutoresizingMaskIntoConstraints = false 15 | 16 | return view 17 | }() 18 | 19 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 20 | super.init(style: style, reuseIdentifier: reuseIdentifier) 21 | contentView.addSubview(postSummaryView) 22 | configureConstraints() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | override func prepareForReuse() { 30 | super.prepareForReuse() 31 | postSummaryView.postPeriodLabel.text = nil 32 | postSummaryView.postTitleLabel.text = nil 33 | } 34 | 35 | func configureData(post: PostResponseDTO) { 36 | postSummaryView.postTitleLabel.text = post.title 37 | 38 | let dateFormatter = DateFormatter() 39 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 40 | guard let startTimeDate = dateFormatter.date(from: post.startDate), 41 | let endTimeDate = dateFormatter.date(from: post.endDate) else { return } 42 | dateFormatter.dateFormat = "yyyy.MM.dd. HH시" 43 | let startTime = dateFormatter.string(from: startTimeDate) 44 | let endTime = dateFormatter.string(from: endTimeDate) 45 | 46 | postSummaryView.postPeriodLabel.text = startTime + " ~ " + endTime 47 | } 48 | 49 | private func configureConstraints() { 50 | NSLayoutConstraint.activate([ 51 | postSummaryView.topAnchor.constraint(equalTo: contentView.topAnchor), 52 | postSummaryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 53 | postSummaryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 54 | postSummaryView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) 55 | ]) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Commons/PostSegmentedControl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostTypeSegmentedControl.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/10/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class PostSegmentedControl: UISegmentedControl { 11 | 12 | private lazy var underline: UIView = { 13 | let view = UIView() 14 | view.translatesAutoresizingMaskIntoConstraints = false 15 | view.backgroundColor = .primary500 16 | 17 | return view 18 | }() 19 | 20 | private lazy var underlineLeadingConstraint: NSLayoutConstraint = { 21 | underline.leadingAnchor.constraint(equalTo: leadingAnchor) 22 | }() 23 | 24 | override init(items: [Any]?) { 25 | super.init(items: items) 26 | 27 | addSubview(underline) 28 | setSegmentedControlUI() 29 | setLayoutConstraints() 30 | } 31 | 32 | required init?(coder: NSCoder) { 33 | fatalError("init(coder:) has not been implemented") 34 | } 35 | 36 | override func layoutSubviews() { 37 | super.layoutSubviews() 38 | 39 | let leadingDistance = (frame.width / CGFloat(numberOfSegments)) * CGFloat(selectedSegmentIndex) 40 | UIView.animate(withDuration: 0.2, animations: { [weak self] in 41 | self?.underlineLeadingConstraint.constant = leadingDistance 42 | self?.layoutIfNeeded() 43 | }) 44 | } 45 | 46 | private func setSegmentedControlUI() { 47 | let image = UIImage() 48 | setBackgroundImage(image, for: .normal, barMetrics: .default) 49 | setBackgroundImage(image, for: .selected, barMetrics: .default) 50 | setBackgroundImage(image, for: .highlighted, barMetrics: .default) 51 | setDividerImage(image, forLeftSegmentState: .selected, 52 | rightSegmentState: .normal, barMetrics: .default) 53 | 54 | setTitleTextAttributes([ 55 | NSAttributedString.Key.foregroundColor: UIColor.primary500, 56 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 16) 57 | ], for: .selected) 58 | 59 | setTitleTextAttributes([ 60 | NSAttributedString.Key.foregroundColor: UIColor.label, 61 | NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 14) 62 | ], for: .normal) 63 | } 64 | 65 | private func setLayoutConstraints() { 66 | NSLayoutConstraint.activate([ 67 | underlineLeadingConstraint, 68 | underline.bottomAnchor.constraint(equalTo: bottomAnchor), 69 | underline.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5), 70 | underline.heightAnchor.constraint(equalToConstant: 2) 71 | ]) 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/EditProfile/View/Custom/ProfileImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileImageView.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/27/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ProfileImageView: UIView { 11 | 12 | private lazy var profileImageView: UIImageView = { 13 | let imageView = UIImageView() 14 | imageView.translatesAutoresizingMaskIntoConstraints = false 15 | imageView.backgroundColor = .systemGray4 16 | imageView.tintColor = .systemGray6 17 | imageView.image = UIImage(systemName: ImageSystemName.personFill.rawValue) 18 | imageView.layer.cornerRadius = 50 19 | imageView.clipsToBounds = true 20 | 21 | return imageView 22 | }() 23 | 24 | private lazy var photoImageView: UIImageView = { 25 | let imageView = UIImageView() 26 | imageView.translatesAutoresizingMaskIntoConstraints = false 27 | imageView.backgroundColor = .secondarySystemFill.withAlphaComponent(1) 28 | imageView.tintColor = .white 29 | imageView.image = UIImage(systemName: ImageSystemName.cameraCircleFill.rawValue) 30 | imageView.layer.cornerRadius = 15 31 | imageView.clipsToBounds = true 32 | 33 | return imageView 34 | }() 35 | 36 | override init(frame: CGRect) { 37 | super.init(frame: frame) 38 | 39 | configureUI() 40 | setLayoutConstraints() 41 | } 42 | 43 | required init?(coder: NSCoder) { 44 | fatalError("should not be called") 45 | } 46 | 47 | func setProfile(image: UIImage) { 48 | profileImageView.image = image 49 | } 50 | 51 | } 52 | 53 | private extension ProfileImageView { 54 | 55 | func configureUI() { 56 | addSubview(profileImageView) 57 | addSubview(photoImageView) 58 | } 59 | 60 | func setLayoutConstraints() { 61 | NSLayoutConstraint.activate([ 62 | profileImageView.leadingAnchor.constraint(equalTo: leadingAnchor), 63 | profileImageView.trailingAnchor.constraint(equalTo: trailingAnchor), 64 | profileImageView.topAnchor.constraint(equalTo: topAnchor), 65 | profileImageView.bottomAnchor.constraint(equalTo: bottomAnchor) 66 | ]) 67 | 68 | NSLayoutConstraint.activate([ 69 | photoImageView.trailingAnchor.constraint(equalTo: trailingAnchor), 70 | photoImageView.bottomAnchor.constraint(equalTo: bottomAnchor), 71 | photoImageView.widthAnchor.constraint(equalToConstant: 30), 72 | photoImageView.heightAnchor.constraint(equalToConstant: 30) 73 | ]) 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Home/Custom/MenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuView.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/16/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class MenuView: UIView { 11 | 12 | private var menuStackView: UIStackView = { 13 | let stackView = UIStackView() 14 | stackView.translatesAutoresizingMaskIntoConstraints = false 15 | stackView.axis = .vertical 16 | stackView.distribution = .fillEqually 17 | return stackView 18 | }() 19 | 20 | override init(frame: CGRect) { 21 | super.init(frame: frame) 22 | 23 | setupUI() 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | super.init(coder: coder) 28 | 29 | setupUI() 30 | } 31 | 32 | func setMenuActions(_ actions: [UIAction]) { 33 | updateMenuButton(actions: actions) 34 | } 35 | 36 | private func setupUI() { 37 | self.alpha = 0 38 | self.layer.cornerRadius = 12 39 | self.layer.masksToBounds = true 40 | self.addSubview(menuStackView) 41 | setMenuStackViewLayoutConstraint() 42 | } 43 | 44 | private func updateMenuButton(actions: [UIAction]) { 45 | menuStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } 46 | 47 | actions.forEach { action in 48 | var configuration = UIButton.Configuration.plain() 49 | configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 0) 50 | let button = UIButton(configuration: configuration, primaryAction: action) 51 | 52 | button.setTitle(action.title, for: .normal) 53 | button.backgroundColor = .grey800 54 | button.tintColor = .white 55 | button.contentHorizontalAlignment = .leading 56 | button.titleLabel?.font = .preferredFont(forTextStyle: .title2) 57 | 58 | menuStackView.addArrangedSubview(button) 59 | } 60 | } 61 | 62 | private func setMenuStackViewLayoutConstraint() { 63 | NSLayoutConstraint.activate([ 64 | menuStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), 65 | menuStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0), 66 | menuStackView.topAnchor.constraint(equalTo: topAnchor, constant: 0), 67 | menuStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0) 68 | ]) 69 | } 70 | 71 | } 72 | 73 | // MARK: - Fade In & Out 74 | extension MenuView { 75 | 76 | func fadeIn() { 77 | self.isHidden = false 78 | UIView.transition(with: self, duration: 0.2) { 79 | self.alpha = 1 80 | } 81 | } 82 | 83 | func fadeOut() { 84 | UIView.transition(with: self, duration: 0.2) { 85 | self.alpha = 0 86 | } 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Login/ViewModel/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/27/23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class LoginViewModel { 12 | 13 | private var cancellableBag = Set() 14 | private var loginSucceed = PassthroughSubject() 15 | 16 | func transform(input: Input) -> Output { 17 | Publishers.Zip(input.identityToken, input.authorizationCode) 18 | .sink(receiveValue: { [weak self] (token, code) in 19 | guard let tokenString = String(data: token, encoding: .utf8), 20 | let codeString = String(data: code, encoding: .utf8) else { return } 21 | 22 | let dto = AppleOAuthDTO(identityToken: tokenString, authorizationCode: codeString) 23 | self?.login(dto: dto) 24 | }) 25 | .store(in: &cancellableBag) 26 | 27 | return Output(loginSucceed: loginSucceed.eraseToAnyPublisher()) 28 | } 29 | 30 | private func login(dto: AppleOAuthDTO) { 31 | let endpoint = APIEndPoints.loginAppleOAuth(with: dto) 32 | Task { 33 | do { 34 | guard let token = try await APIProvider.shared.request(with: endpoint) else { return } 35 | 36 | try JWTManager.shared.save(token: token) 37 | loginSucceed.send() 38 | FCMManager.shared.sendFCMToken() 39 | } catch let error { 40 | loginSucceed.send(completion: .failure(error)) 41 | } 42 | } 43 | } 44 | 45 | } 46 | 47 | extension LoginViewModel { 48 | 49 | struct Input { 50 | var identityToken: AnyPublisher 51 | var authorizationCode: AnyPublisher 52 | } 53 | 54 | struct Output { 55 | var loginSucceed: AnyPublisher 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/MyHiddenPosts/ViewModel/MyHiddenPostsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MyHiddenPostsViewModel.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/9/23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | struct HidePostInfo { 12 | 13 | let postID: Int 14 | let isHidden: Bool 15 | 16 | } 17 | 18 | final class MyHiddenPostsViewModel { 19 | 20 | var posts: [PostMuteResponseDTO] = [] 21 | 22 | private var requestFilter: String = "0" 23 | 24 | private var toggleOutput = PassthroughSubject<[PostMuteResponseDTO], Never>() 25 | 26 | private var cancellableBag = Set() 27 | 28 | func transform(input: Input) -> Output { 29 | 30 | input.toggleSubject 31 | .sink { [weak self] in 32 | self?.requestFilter = self?.requestFilter == "0" ? "1" : "0" 33 | self?.getMyHiddenPosts() 34 | } 35 | .store(in: &cancellableBag) 36 | 37 | input.toggleHideSubject 38 | .sink { [weak self] hideInfo in 39 | self?.toggleHide(hideInfo: hideInfo) 40 | } 41 | .store(in: &cancellableBag) 42 | 43 | return Output( 44 | toggleUpdateOutput: toggleOutput.eraseToAnyPublisher() 45 | ) 46 | } 47 | 48 | init() { 49 | getMyHiddenPosts() 50 | } 51 | 52 | deinit { 53 | PostNotificationPublisher.shared.publishPostRefreshAll() 54 | } 55 | 56 | private func getMyHiddenPosts() { 57 | let endpoint = APIEndPoints.getHiddenPosts( 58 | requestFilter: RequestFilterDTO( 59 | requestFilter: requestFilter 60 | ) 61 | ) 62 | Task { 63 | do { 64 | guard let data = try await APIProvider.shared.request(with: endpoint) else { return } 65 | posts = data 66 | toggleOutput.send(data) 67 | } catch { 68 | dump(error) 69 | } 70 | } 71 | } 72 | 73 | private func toggleHide(hideInfo: HidePostInfo) { 74 | let endpoint = hideInfo.isHidden ? 75 | APIEndPoints.hidePost(postID: hideInfo.postID) : 76 | APIEndPoints.unhidePost(postID: hideInfo.postID) 77 | 78 | Task { 79 | do { 80 | try await APIProvider.shared.request(with: endpoint) 81 | } catch { 82 | dump(error) 83 | } 84 | } 85 | } 86 | 87 | } 88 | 89 | extension MyHiddenPostsViewModel { 90 | 91 | struct Input { 92 | let toggleSubject: AnyPublisher 93 | let toggleHideSubject: AnyPublisher 94 | } 95 | 96 | struct Output { 97 | let toggleUpdateOutput: AnyPublisher<[PostMuteResponseDTO], Never> 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/MyPage/View/MyPageTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MyPageTableViewCell.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/14/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class MyPageTableViewCell: UITableViewCell { 11 | 12 | private let titleLabel: UILabel = { 13 | let label = UILabel() 14 | label.translatesAutoresizingMaskIntoConstraints = false 15 | label.font = .systemFont(ofSize: 16, weight: .bold) 16 | 17 | return label 18 | }() 19 | 20 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 21 | super.init(style: style, reuseIdentifier: reuseIdentifier) 22 | setUI() 23 | setConstraints() 24 | } 25 | 26 | required init?(coder: NSCoder) { 27 | fatalError("init(coder:) has not been implemented") 28 | } 29 | 30 | override func prepareForReuse() { 31 | titleLabel.text = nil 32 | } 33 | 34 | func configureData(text: String) { 35 | titleLabel.text = text 36 | } 37 | 38 | private func setUI() { 39 | contentView.addSubview(titleLabel) 40 | } 41 | 42 | private func setConstraints() { 43 | NSLayoutConstraint.activate([ 44 | titleLabel.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 30), 45 | titleLabel.centerYAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.centerYAnchor, constant: 0) 46 | ]) 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostCreate/Model/ImageItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageItem.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/9/23. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ImageItem: Hashable { 11 | 12 | let id = UUID() 13 | let data: Data 14 | let url: String? 15 | 16 | init(data: Data, url: String? = nil) { 17 | self.data = data 18 | self.url = url 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostCreate/View/Custom/Cell/CameraButtonCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraButtonCell.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/7/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class CameraButtonCell: UICollectionViewCell { 11 | 12 | private lazy var cameraButton: UIButton = { 13 | var configuration = UIButton.Configuration.plain() 14 | configuration.imagePlacement = .top 15 | configuration.image = UIImage(systemName: ImageSystemName.cameraFill.rawValue) 16 | configuration.baseForegroundColor = .grey800 17 | configuration.imagePadding = 5 18 | 19 | let button = UIButton(configuration: configuration) 20 | button.setLayer(cornerRadius: 5) 21 | button.translatesAutoresizingMaskIntoConstraints = false 22 | 23 | return button 24 | }() 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | 29 | addSubview(cameraButton) 30 | setLayoutConstraint() 31 | } 32 | 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | func setImageCount(_ count: Int) { 38 | let attributedTitle = NSAttributedString( 39 | string: "\(count)/\(Constant.maxImageCount)", 40 | attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .light)] 41 | ) 42 | cameraButton.setAttributedTitle(attributedTitle, for: .normal) 43 | } 44 | 45 | func addButtonAction(_ action: UIAction) { 46 | cameraButton.addAction(action, for: .touchUpInside) 47 | } 48 | 49 | } 50 | 51 | private extension CameraButtonCell { 52 | 53 | func setLayoutConstraint() { 54 | NSLayoutConstraint.activate([ 55 | cameraButton.leadingAnchor.constraint(equalTo: leadingAnchor), 56 | cameraButton.trailingAnchor.constraint(equalTo: trailingAnchor), 57 | cameraButton.topAnchor.constraint(equalTo: topAnchor), 58 | cameraButton.bottomAnchor.constraint(equalTo: bottomAnchor) 59 | ]) 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostCreate/View/Custom/Cell/ImageViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageViewCell.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/7/23. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | 11 | final class ImageViewCell: UICollectionViewCell { 12 | 13 | private lazy var imageView: UIImageView = { 14 | let view = UIImageView() 15 | view.translatesAutoresizingMaskIntoConstraints = false 16 | view.contentMode = .scaleAspectFill 17 | view.layer.cornerRadius = 5 18 | view.clipsToBounds = true 19 | 20 | return view 21 | }() 22 | 23 | private lazy var deleteButton: UIButton = { 24 | var configuration = UIButton.Configuration.plain() 25 | configuration.image = UIImage(systemName: ImageSystemName.xmark.rawValue)?.resize(newWidth: 15, newHeight: 13) 26 | configuration.baseForegroundColor = .black 27 | 28 | let button = UIButton(configuration: configuration) 29 | button.translatesAutoresizingMaskIntoConstraints = false 30 | button.backgroundColor = .grey100 31 | button.layer.cornerRadius = 10 32 | 33 | return button 34 | }() 35 | 36 | private override init(frame: CGRect) { 37 | super.init(frame: frame) 38 | 39 | clipsToBounds = false 40 | addSubview(imageView) 41 | addSubview(deleteButton) 42 | setLayoutConstraint() 43 | } 44 | 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | override func prepareForReuse() { 50 | super.prepareForReuse() 51 | 52 | imageView.image = nil 53 | deleteButton.removeTarget(nil, action: nil, for: .touchUpInside) 54 | } 55 | 56 | func configure(imageData: Data, deleteAction: UIAction) { 57 | imageView.image = UIImage(data: imageData) 58 | deleteButton.addAction(deleteAction, for: .touchUpInside) 59 | } 60 | 61 | } 62 | 63 | private extension ImageViewCell { 64 | 65 | func setLayoutConstraint() { 66 | NSLayoutConstraint.activate([ 67 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor), 68 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor), 69 | imageView.topAnchor.constraint(equalTo: topAnchor), 70 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor) 71 | ]) 72 | 73 | NSLayoutConstraint.activate([ 74 | deleteButton.widthAnchor.constraint(equalToConstant: 20), 75 | deleteButton.heightAnchor.constraint(equalTo: deleteButton.widthAnchor), 76 | deleteButton.centerXAnchor.constraint(equalTo: trailingAnchor, constant: -3), 77 | deleteButton.centerYAnchor.constraint(equalTo: topAnchor, constant: 2) 78 | ]) 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostCreate/ViewModel/PostCreateRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostCreateRepository.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/23/23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class PostCreateRepository { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostCreate/ViewModel/PostCreateUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PostCreateUseCase.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/24/23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class PostCreateUseCase { 12 | 13 | private let postCreateRepository: PostCreateRepository 14 | 15 | init(postCreateRepository: PostCreateRepository) { 16 | self.postCreateRepository = postCreateRepository 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostDetail/Custom/DateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateView.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/21/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class DateView: UIView { 11 | 12 | private let containerStackView: UIStackView = { 13 | let stackView = UIStackView() 14 | stackView.translatesAutoresizingMaskIntoConstraints = false 15 | stackView.axis = .vertical 16 | stackView.distribution = .fillEqually 17 | stackView.alignment = .center 18 | stackView.spacing = 5 19 | return stackView 20 | }() 21 | 22 | private let titleLabel: UILabel = { 23 | let label = UILabel() 24 | label.numberOfLines = 1 25 | label.font = .systemFont(ofSize: 15) 26 | return label 27 | }() 28 | 29 | private let dateLabel: UILabel = { 30 | let label = UILabel() 31 | label.numberOfLines = 1 32 | label.font = .systemFont(ofSize: 17, weight: .bold) 33 | return label 34 | }() 35 | 36 | override init(frame: CGRect) { 37 | super.init(frame: frame) 38 | 39 | configureUI() 40 | } 41 | 42 | required init(coder: NSCoder) { 43 | fatalError("Should not be called") 44 | } 45 | 46 | func setLabelText(title: String, date: Date) { 47 | let dateFormatter = DateFormatter() 48 | dateFormatter.dateFormat = "yyyy.MM.dd HH시" 49 | titleLabel.text = title 50 | dateLabel.text = dateFormatter.string(from: date) 51 | } 52 | 53 | private func configureUI() { 54 | addSubview(containerStackView) 55 | containerStackView.addArrangedSubview(titleLabel) 56 | containerStackView.addArrangedSubview(dateLabel) 57 | 58 | setLayoutConstraints() 59 | } 60 | 61 | private func setLayoutConstraints() { 62 | NSLayoutConstraint.activate([ 63 | containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), 64 | containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), 65 | containerStackView.topAnchor.constraint(equalTo: topAnchor), 66 | containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor) 67 | ]) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostDetail/Custom/DurationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DurationView.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/21/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class DurationView: UIView { 11 | 12 | private var containerStackView: UIStackView = { 13 | let stackView = UIStackView() 14 | stackView.translatesAutoresizingMaskIntoConstraints = false 15 | stackView.distribution = .equalSpacing 16 | stackView.alignment = .center 17 | stackView.isLayoutMarginsRelativeArrangement = true 18 | stackView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) 19 | return stackView 20 | }() 21 | 22 | private var startDateView = DateView() 23 | private var divider = UIView.divider(.vertical) 24 | private var endDateView = DateView() 25 | 26 | override init(frame: CGRect) { 27 | super.init(frame: frame) 28 | 29 | setLayer(borderWidth: 1, borderColor: .grey100) 30 | configureUI() 31 | } 32 | 33 | required init(coder: NSCoder) { 34 | fatalError("Should not be called") 35 | } 36 | 37 | func setDuration(from startDate: Date, to endDate: Date) { 38 | startDateView.setLabelText(title: "시작", date: startDate) 39 | endDateView.setLabelText(title: "반납", date: endDate) 40 | } 41 | 42 | private func configureUI() { 43 | addSubview(containerStackView) 44 | containerStackView.addArrangedSubview(startDateView) 45 | containerStackView.addArrangedSubview(divider) 46 | containerStackView.addArrangedSubview(endDateView) 47 | 48 | setLayoutConstraints() 49 | } 50 | 51 | private func setLayoutConstraints() { 52 | startDateView.translatesAutoresizingMaskIntoConstraints = false 53 | endDateView.translatesAutoresizingMaskIntoConstraints = false 54 | 55 | NSLayoutConstraint.activate([ 56 | containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), 57 | containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), 58 | containerStackView.topAnchor.constraint(equalTo: topAnchor), 59 | containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor) 60 | ]) 61 | 62 | NSLayoutConstraint.activate([ 63 | divider.heightAnchor.constraint(equalTo: heightAnchor) 64 | ]) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostDetail/Custom/ImageDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageDetailView.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/13/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ImageDetailView: UIView { 11 | 12 | private lazy var imageView: UIImageView = { 13 | let view = UIImageView() 14 | view.translatesAutoresizingMaskIntoConstraints = false 15 | view.contentMode = .scaleAspectFit 16 | view.backgroundColor = .black 17 | 18 | return view 19 | }() 20 | 21 | private lazy var dismissButton: UIButton = { 22 | let button = UIButton() 23 | button.translatesAutoresizingMaskIntoConstraints = false 24 | button.setImage(UIImage(systemName: ImageSystemName.xmark.rawValue), for: .normal) 25 | button.tintColor = .white 26 | button.addTarget(self, action: #selector(dismissAction), for: .touchUpInside) 27 | 28 | return button 29 | }() 30 | 31 | override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | 34 | addSubview(imageView) 35 | addSubview(dismissButton) 36 | setLayoutConstraints() 37 | } 38 | 39 | required init?(coder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | func setImage(data: Data) { 44 | imageView.image = UIImage(data: data) 45 | } 46 | 47 | @objc 48 | private func dismissAction() { 49 | UIView.animate(withDuration: 0.2, animations: { [weak self] in 50 | self?.alpha = 0 51 | }) 52 | } 53 | 54 | private func setLayoutConstraints() { 55 | NSLayoutConstraint.activate([ 56 | dismissButton.widthAnchor.constraint(equalToConstant: 30), 57 | dismissButton.heightAnchor.constraint(equalToConstant: 30), 58 | dismissButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5), 59 | dismissButton.topAnchor.constraint(equalTo: topAnchor, constant: 5) 60 | ]) 61 | 62 | NSLayoutConstraint.activate([ 63 | imageView.leadingAnchor.constraint(equalTo: leadingAnchor), 64 | imageView.trailingAnchor.constraint(equalTo: trailingAnchor), 65 | imageView.topAnchor.constraint(equalTo: topAnchor), 66 | imageView.bottomAnchor.constraint(equalTo: bottomAnchor) 67 | ]) 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostDetail/Custom/PriceLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PriceLabel.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/22/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class PriceLabel: UIView { 11 | 12 | private let stackView: UIStackView = { 13 | let stackView = UIStackView() 14 | stackView.translatesAutoresizingMaskIntoConstraints = false 15 | stackView.spacing = 10 16 | return stackView 17 | }() 18 | 19 | private let priceLabel: UILabel = { 20 | let label = UILabel() 21 | label.font = .boldSystemFont(ofSize: 20) 22 | return label 23 | }() 24 | 25 | private let unitLabel: UILabel = { 26 | let label = UILabel() 27 | label.text = "시간당" 28 | label.font = .systemFont(ofSize: 15) 29 | label.textColor = .secondaryLabel 30 | return label 31 | }() 32 | 33 | override init(frame: CGRect) { 34 | super.init(frame: frame) 35 | 36 | configureUI() 37 | } 38 | 39 | required init(coder: NSCoder) { 40 | fatalError("Should not be implemented") 41 | } 42 | 43 | func setPrice(price: Int?) { 44 | guard let price = price else { return } 45 | let priceText = price.priceText() 46 | priceLabel.text = "\(priceText)원" 47 | } 48 | 49 | private func configureUI() { 50 | addSubview(stackView) 51 | stackView.addArrangedSubview(unitLabel) 52 | stackView.addArrangedSubview(priceLabel) 53 | 54 | setLayoutConstaints() 55 | } 56 | 57 | private func setLayoutConstaints() { 58 | NSLayoutConstraint.activate([ 59 | stackView.leadingAnchor.constraint(equalTo: leadingAnchor), 60 | stackView.trailingAnchor.constraint(equalTo: trailingAnchor), 61 | stackView.topAnchor.constraint(equalTo: topAnchor), 62 | stackView.bottomAnchor.constraint(equalTo: bottomAnchor) 63 | ]) 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/PostDetail/Custom/UserInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserInfoView.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/23/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class UserInfoView: UIView { 11 | 12 | private var profileImageView: UIImageView = { 13 | let imageView = UIImageView() 14 | imageView.translatesAutoresizingMaskIntoConstraints = false 15 | imageView.clipsToBounds = true 16 | imageView.contentMode = .scaleAspectFill 17 | imageView.setLayer(borderColor: .grey100, cornerRadius: 26) 18 | return imageView 19 | }() 20 | 21 | private var nicknameLabel: UILabel = { 22 | let label = UILabel() 23 | label.translatesAutoresizingMaskIntoConstraints = false 24 | label.numberOfLines = 1 25 | label.font = .systemFont(ofSize: 18, weight: .bold) 26 | return label 27 | }() 28 | 29 | override init(frame: CGRect) { 30 | super.init(frame: frame) 31 | 32 | configureUI() 33 | setLayoutConstraints() 34 | } 35 | 36 | required init(coder: NSCoder) { 37 | fatalError("Should not be called") 38 | } 39 | 40 | func setContent(imageURL: String?, nickname: String) { 41 | Task { 42 | do { 43 | guard let imageURL = imageURL else { return } 44 | let data = try await APIProvider.shared.request(from: imageURL) 45 | profileImageView.image = UIImage(data: data) 46 | } catch let error { 47 | dump(error) 48 | } 49 | } 50 | nicknameLabel.text = nickname 51 | } 52 | 53 | } 54 | 55 | private extension UserInfoView { 56 | 57 | func configureUI() { 58 | addSubview(profileImageView) 59 | addSubview(nicknameLabel) 60 | } 61 | 62 | func setLayoutConstraints() { 63 | NSLayoutConstraint.activate([ 64 | heightAnchor.constraint(equalToConstant: 70) 65 | ]) 66 | 67 | NSLayoutConstraint.activate([ 68 | profileImageView.widthAnchor.constraint(equalToConstant: 60), 69 | profileImageView.heightAnchor.constraint(equalToConstant: 60), 70 | profileImageView.centerYAnchor.constraint(equalTo: centerYAnchor), 71 | profileImageView.leadingAnchor.constraint(equalTo: leadingAnchor) 72 | ]) 73 | 74 | NSLayoutConstraint.activate([ 75 | nicknameLabel.leadingAnchor.constraint(equalTo: profileImageView.trailingAnchor, constant: 15), 76 | nicknameLabel.centerYAnchor.constraint(equalTo: centerYAnchor) 77 | ]) 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Report/ViewModel/ReportViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReportViewModel.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/10/23. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class ReportViewModel { 12 | 13 | private let userID: String 14 | private let postID: Int 15 | 16 | private let completeOutput = PassthroughSubject() 17 | 18 | private var cancellableBag = Set() 19 | 20 | init(userID: String, postID: Int) { 21 | self.userID = userID 22 | self.postID = postID 23 | } 24 | 25 | func transform(input: Input) -> Output { 26 | 27 | input.reportButtonTapped 28 | .receive(on: DispatchQueue.main) 29 | .sink { [weak self] description in 30 | self?.report(description: description) 31 | } 32 | .store(in: &cancellableBag) 33 | return Output( 34 | completeOutput: completeOutput.eraseToAnyPublisher() 35 | ) 36 | } 37 | 38 | private func report(description: String) { 39 | let endpoint = APIEndPoints.reportUser( 40 | reportInfo: ReportDTO( 41 | postID: postID, userID: userID, description: description 42 | ) 43 | ) 44 | 45 | Task { 46 | do { 47 | try await APIProvider.shared.request(with: endpoint) 48 | completeOutput.send() 49 | } catch { 50 | dump(error) 51 | } 52 | } 53 | 54 | } 55 | 56 | } 57 | 58 | extension ReportViewModel { 59 | 60 | struct Input { 61 | let reportButtonTapped: AnyPublisher 62 | } 63 | 64 | struct Output { 65 | let completeOutput: AnyPublisher 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Search/SearchViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewController.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/20. 6 | // 7 | 8 | import UIKit 9 | 10 | final class SearchViewController: UIViewController { 11 | 12 | private let searchController = UISearchController(searchResultsController: nil) 13 | private var searchTitle: String = "" 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | 18 | setNavigationBarUI() 19 | definesPresentationContext = true 20 | 21 | view.backgroundColor = .systemBackground 22 | } 23 | 24 | private func setNavigationBarUI() { 25 | searchController.searchBar.placeholder = "검색어를 입력해주세요." 26 | searchController.searchBar.frame = CGRect(x: 0, y: 0, width: view.frame.width - 70, height: 0) 27 | searchController.searchResultsUpdater = self 28 | searchController.searchBar.delegate = self 29 | searchController.hidesNavigationBarDuringPresentation = false 30 | searchController.automaticallyShowsCancelButton = false 31 | 32 | navigationItem.rightBarButtonItem = UIBarButtonItem(customView: searchController.searchBar) 33 | navigationItem.backButtonDisplayMode = .minimal 34 | navigationItem.hidesSearchBarWhenScrolling = false 35 | 36 | } 37 | 38 | } 39 | 40 | extension SearchViewController: UISearchResultsUpdating, UISearchBarDelegate { 41 | 42 | func updateSearchResults(for searchController: UISearchController) { 43 | self.searchTitle = searchController.searchBar.text ?? "" 44 | } 45 | 46 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { 47 | 48 | // self.navigationController?.pushViewController( 49 | // SearchResultViewController(title: self.searchTitle), animated: false 50 | // ) 51 | } 52 | 53 | func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { 54 | // searchBar 보이기 55 | searchController.hidesNavigationBarDuringPresentation = false 56 | navigationController?.navigationBar.layoutIfNeeded() 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/SearchResult/View/SearchRequstTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchRequstTableViewCell.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 12/9/23. 6 | // 7 | 8 | import UIKit 9 | 10 | final class SearchRequstTableViewCell: UITableViewCell { 11 | 12 | private let postSummaryView: RequestPostSummaryView = { 13 | let view = RequestPostSummaryView() 14 | view.translatesAutoresizingMaskIntoConstraints = false 15 | 16 | return view 17 | }() 18 | 19 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 20 | super.init(style: style, reuseIdentifier: reuseIdentifier) 21 | contentView.addSubview(postSummaryView) 22 | configureConstraints() 23 | } 24 | 25 | required init?(coder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | override func prepareForReuse() { 30 | super.prepareForReuse() 31 | postSummaryView.postPeriodLabel.text = nil 32 | postSummaryView.postTitleLabel.text = nil 33 | postSummaryView.postAccessoryView.image = nil 34 | } 35 | 36 | func configureData(post: PostResponseDTO) { 37 | postSummaryView.postTitleLabel.text = post.title 38 | 39 | let dateFormatter = DateFormatter() 40 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 41 | guard let startTimeDate = dateFormatter.date(from: post.startDate), 42 | let endTimeDate = dateFormatter.date(from: post.endDate) else { return } 43 | dateFormatter.dateFormat = "yyyy.MM.dd. HH시" 44 | let startTime = dateFormatter.string(from: startTimeDate) 45 | let endTime = dateFormatter.string(from: endTimeDate) 46 | 47 | postSummaryView.postPeriodLabel.text = startTime + " ~ " + endTime 48 | } 49 | 50 | private func configureConstraints() { 51 | NSLayoutConstraint.activate([ 52 | postSummaryView.topAnchor.constraint(equalTo: contentView.topAnchor), 53 | postSummaryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), 54 | postSummaryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 55 | postSummaryView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) 56 | ]) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/NSItemProvider+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSItemProvider+.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 12/14/23. 6 | // 7 | 8 | import PhotosUI 9 | 10 | extension NSItemProvider { 11 | 12 | func getImageData(completion: @escaping (Data?) -> Void) { 13 | let imageTypeIdentifiers = [ 14 | UTType.webP.identifier, 15 | UTType.heic.identifier, 16 | UTType.rawImage.identifier 17 | ] 18 | 19 | if canLoadObject(ofClass: UIImage.self) { 20 | loadObject(ofClass: UIImage.self) { uiimage, _ in 21 | guard let image = uiimage as? UIImage, 22 | let jpegData = image.jpegData(compressionQuality: 0.1) else { return } 23 | completion(jpegData) 24 | } 25 | } else { 26 | for identifier in imageTypeIdentifiers { 27 | loadFileRepresentation(forTypeIdentifier: identifier) { [weak self] url, _ in 28 | if let fileURL = url, 29 | let fileData = try? Data(contentsOf: fileURL), 30 | let compressedData = UIImage(data: fileData)?.jpegData(compressionQuality: 0.1) { 31 | self?.deleteFile(url: fileURL) 32 | completion(compressedData) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | } 40 | 41 | fileprivate extension NSItemProvider { 42 | 43 | func deleteFile(url: URL) { 44 | let fileManager = FileManager.default 45 | 46 | do { 47 | try fileManager.removeItem(at: url) 48 | } catch { 49 | dump("File deletion failed: \(error)") 50 | } 51 | 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UICollectionViewCell+Identifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionViewCell+Identifier.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/27. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UICollectionViewCell { 11 | 12 | static var identifier: String { 13 | return String(describing: self) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UIImage+Resize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Resize.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 12/8/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIImage { 11 | 12 | func resize(newWidth: CGFloat, newHeight: CGFloat) -> UIImage { 13 | let size = CGSize(width: newWidth, height: newHeight) 14 | let render = UIGraphicsImageRenderer(size: size) 15 | let renderImage = render.image { _ in 16 | self.draw(in: CGRect(origin: .zero, size: size)) 17 | } 18 | 19 | return renderImage 20 | } 21 | 22 | func resizeKeepScale(newWidth: CGFloat) -> UIImage { 23 | let scale = newWidth / self.size.width 24 | let newHeight = self.size.height * scale 25 | 26 | let size = CGSize(width: newWidth, height: newHeight) 27 | let render = UIGraphicsImageRenderer(size: size) 28 | let renderImage = render.image { _ in 29 | self.draw(in: CGRect(origin: .zero, size: size)) 30 | } 31 | 32 | return renderImage 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UILabel+SetTitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UILabel+SetTitle.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/15. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UILabel { 11 | 12 | func setTitle(_ title: String) { 13 | self.text = title 14 | self.font = UIFont.systemFont(ofSize: 24, weight: .bold) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UINavigationItem+MakeSFSymbolButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationItem+MakeSFSymbolButton.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/15. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UINavigationItem { 11 | 12 | func makeSFSymbolButton(_ target: Any?, action: Selector, symbolName: ImageSystemName) -> UIBarButtonItem { 13 | let button = UIButton(type: .system) 14 | button.setImage(UIImage(systemName: symbolName.rawValue), for: .normal) 15 | button.addTarget(target, action: action, for: .touchUpInside) 16 | // button.tintColor = .primary500 17 | 18 | let barButtonItem = UIBarButtonItem(customView: button) 19 | barButtonItem.customView?.translatesAutoresizingMaskIntoConstraints = false 20 | barButtonItem.customView?.heightAnchor.constraint(equalToConstant: 24).isActive = true 21 | barButtonItem.customView?.widthAnchor.constraint(equalToConstant: 24).isActive = true 22 | 23 | return barButtonItem 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UIStackView+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIStackView+.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/21/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIStackView { 11 | 12 | open override func touchesBegan(_ touches: Set, with event: UIEvent?) { 13 | endEditing(true) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UITableViewCell+Identifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableViewCell+Identifier.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/28/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UITableViewCell { 11 | 12 | static var identifier: String { 13 | return String(describing: self) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UITextField+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITextField+.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/18/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UITextField { 11 | 12 | func setPlaceHolder(_ placeholder: String) { 13 | let attributedPlaceholder = NSAttributedString( 14 | string: placeholder, 15 | attributes: [NSAttributedString.Key.foregroundColor: UIColor.lightGray] 16 | ) 17 | self.attributedPlaceholder = attributedPlaceholder 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UIView+Divider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Divider.swift 3 | // Village 4 | // 5 | // Created by 정상윤 on 11/21/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | enum DividerDirection { 13 | case horizontal 14 | case vertical 15 | } 16 | 17 | static func divider(_ direction: DividerDirection, width: CGFloat = 1.0, color: UIColor = .systemGray4) -> UIView { 18 | let view = UIView() 19 | view.translatesAutoresizingMaskIntoConstraints = false 20 | view.backgroundColor = color 21 | 22 | if direction == .horizontal { 23 | view.heightAnchor.constraint(equalToConstant: width).isActive = true 24 | } else { 25 | view.widthAnchor.constraint(equalToConstant: width).isActive = true 26 | } 27 | 28 | return view 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /iOS/Village/Village/Presentation/Utils/Extensions/UIView+Layer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIView+Layer.swift 3 | // Village 4 | // 5 | // Created by 조성민 on 11/17/23. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIView { 11 | 12 | func setLayer(borderWidth: CGFloat = 1, borderColor: UIColor = .grey800, cornerRadius: CGFloat = 8) { 13 | layer.borderWidth = borderWidth 14 | layer.borderColor = borderColor.cgColor 15 | layer.cornerRadius = cornerRadius 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AlertColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.188", 9 | "green" : "0.231", 10 | "red" : "0.996" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.188", 27 | "green" : "0.231", 28 | "red" : "0.996" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "29.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "58.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "87.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "80.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "57.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "114.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "120.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "180.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "1024.png", 71 | "idiom" : "ios-marketing", 72 | "scale" : "1x", 73 | "size" : "1024x1024" 74 | } 75 | ], 76 | "info" : { 77 | "author" : "xcode", 78 | "version" : 1 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Grey-100.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xEB", 9 | "green" : "0xE7", 10 | "red" : "0xE3" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Grey-800.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "75", 9 | "green" : "63", 10 | "red" : "50" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/KeyboardTextFieldColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.973", 9 | "green" : "0.973", 10 | "red" : "0.973" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.227", 27 | "green" : "0.220", 28 | "red" : "0.220" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "LoginLogo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "LoginLogo 1.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "LoginLogo 2.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/LoginLogo 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/LoginLogo 1.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/LoginLogo 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/LoginLogo 2.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/LoginLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/LoginLogo.imageset/LoginLogo.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Logo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Logo@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "Logo@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Logo.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Logo@2x.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/Logo.imageset/Logo@3x.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "LogoLabel.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "LogoLabel@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "LogoLabel@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/LogoLabel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/LogoLabel.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/LogoLabel@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/LogoLabel@2x.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/LogoLabel@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/LogoLabel.imageset/LogoLabel@3x.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/MyChatMessage.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "226", 9 | "green" : "187", 10 | "red" : "100" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "179", 27 | "green" : "148", 28 | "red" : "79" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Negative-400.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "43", 9 | "green" : "43", 10 | "red" : "224" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Primary-100.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xF1", 9 | "green" : "0xDD", 10 | "red" : "0x99" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/Primary-500.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "221", 9 | "green" : "183", 10 | "red" : "60" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/UserChatMessage.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xB7", 9 | "green" : "0xB3", 10 | "red" : "0xB1" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x89", 27 | "green" : "0x86", 28 | "red" : "0x83" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "WhiteLogo.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "WhiteLogo@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "WhiteLogo@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/WhiteLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/WhiteLogo.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/WhiteLogo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/WhiteLogo@2x.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/WhiteLogo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/Village/Resources/Assets.xcassets/WhiteLogo.imageset/WhiteLogo@3x.png -------------------------------------------------------------------------------- /iOS/Village/Village/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FirebaseAppDelegateProxyEnabled 6 | 7 | NSAppTransportSecurity 8 | 9 | NSAllowsArbitraryLoads 10 | 11 | 12 | UIApplicationSceneManifest 13 | 14 | UIApplicationSupportsMultipleScenes 15 | 16 | UISceneConfigurations 17 | 18 | UIWindowSceneSessionRoleApplication 19 | 20 | 21 | UISceneConfigurationName 22 | Default Configuration 23 | UISceneDelegateClassName 24 | $(PRODUCT_MODULE_NAME).SceneDelegate 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /iOS/Village/Village/Socket/WebSocketError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebSocketError.swift 3 | // Village 4 | // 5 | // Created by 박동재 on 2023/11/29. 6 | // 7 | 8 | import Foundation 9 | 10 | enum WebSocketError: Error { 11 | case invalidURL 12 | } 13 | -------------------------------------------------------------------------------- /iOS/Village/Village/Socket/chat-server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectedSocket, 3 | MessageBody, 4 | OnGatewayConnection, 5 | SubscribeMessage, 6 | WebSocketGateway, 7 | WebSocketServer, 8 | } from '@nestjs/websockets'; 9 | import { Server, Websocket } from 'ws'; 10 | import { ChatService } from './chat.service'; 11 | 12 | @WebSocketGateway({ 13 | path: 'chats', 14 | }) 15 | export class ChatsGateway implements OnGatewayConnection { 16 | @WebSocketServer() server: Server; 17 | private rooms = new Map>(); 18 | 19 | constructor(private readonly chatService: ChatService) {} 20 | handleConnection(client: Websocket) { 21 | // 인증 로직 22 | console.log(`on connnect : ${client}`); 23 | } 24 | 25 | handleDisconnect(client: Websocket) { 26 | console.log(`on disconnect : ${client}`); 27 | } 28 | 29 | @SubscribeMessage('send-message') 30 | sendMessage( 31 | @MessageBody() message: object, 32 | @ConnectedSocket() client: Websocket, 33 | ) { 34 | console.log(message); 35 | const room = this.rooms.get(message['room']); 36 | const sender = message['sender']; 37 | room.forEach((people) => { 38 | if (people !== client) 39 | people.send(JSON.stringify({ sender, message: message['message'] })); 40 | }); 41 | } 42 | 43 | @SubscribeMessage('join-room') 44 | joinRoom( 45 | @MessageBody() message: object, 46 | @ConnectedSocket() client: Websocket, 47 | ) { 48 | console.log("join", message); 49 | const roomName = message['room']; 50 | if (this.rooms.has(roomName)) this.rooms.get(roomName).add(client); 51 | else this.rooms.set(roomName, new Set([client])); 52 | console.log(this.rooms) 53 | } 54 | 55 | @SubscribeMessage('leave-room') 56 | leaveRoom( 57 | @MessageBody() message: object, 58 | @ConnectedSocket() client: Websocket, 59 | ) { 60 | const roomName = message['room']; 61 | const room = this.rooms.get(roomName); 62 | room.delete(client); 63 | if (room.size === 0) { 64 | this.rooms.delete(roomName); 65 | } 66 | console.log(this.rooms); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /iOS/Village/Village/Village.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.applesignin 8 | 9 | Default 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /iOS/Village/Village/VillageRelease.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.applesignin 8 | 9 | Default 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /iOS/Village/VillageTests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/iOS05-Village/2563cca22405d54cbfcffae5e57d30a10898ce8d/iOS/Village/VillageTests/.gitkeep --------------------------------------------------------------------------------