diff --git a/.github/workflows/deploy-nest-dev.yml b/.github/workflows/deploy-nest-dev.yml index dce53aba..329530b8 100644 --- a/.github/workflows/deploy-nest-dev.yml +++ b/.github/workflows/deploy-nest-dev.yml @@ -28,6 +28,7 @@ jobs: run: | cd /home/ubuntu sudo chmod 666 /var/run/docker.sock + cd /home/ubuntu/actions-runner/_work/kokomen-client/kokomen-client yarn types:build docker system prune -f docker compose -f ./compose.server.dev.yaml down || true diff --git a/.github/workflows/deploy-nest-prod.yml b/.github/workflows/deploy-nest-prod.yml index 356f215f..6d5d49b1 100644 --- a/.github/workflows/deploy-nest-prod.yml +++ b/.github/workflows/deploy-nest-prod.yml @@ -7,27 +7,32 @@ on: jobs: build-and-deploy: name: Build and Deploy to EC2 - runs-on: [self-hosted, nest-prod] + runs-on: [self-hosted, "${{ matrix.server }}"] + strategy: + max-parallel: 1 # 순차 배포 + matrix: + server: [nest-prod-a, nest-prod-b] steps: - name: Checkout source code uses: actions/checkout@v3 + - name: install dependencies run: | corepack enable yarn install - - name: Run Docker Compose + - name: Run Docker Compose on ${{ matrix.server }} env: DATABASE_HOST_PROD: ${{ secrets.DATABASE_HOST_PROD }} DATABASE_USERNAME_PROD: ${{ secrets.DATABASE_USERNAME_PROD }} DATABASE_PASSWORD_PROD: ${{ secrets.DATABASE_PASSWORD_PROD }} DATABASE_ROOT_PASSWORD_PROD: ${{ secrets.DATABASE_ROOT_PASSWORD_PROD }} REDIS_HOST_PROD: ${{ secrets.REDIS_HOST_PROD }} - run: | cd /home/ubuntu sudo chmod 666 /var/run/docker.sock + cd /home/ubuntu/actions-runner/_work/kokomen-client/kokomen-client yarn types:build docker system prune -f docker compose -f ./compose.server.prod.yaml down || true diff --git a/.gitignore b/.gitignore index e3babef7..ea4045a7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ node_modules/ .tsbuildinfo .tanstack -*.pem \ No newline at end of file +*.pem +*.key +*.crt \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index 545223dd..c4e4352b 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,6 +1,7 @@ nodeLinker: pnp yarnPath: .yarn/releases/yarn-4.9.2.cjs +enableGlobalCache: false packageExtensions: "@sentry/nextjs@*": peerDependencies: @@ -38,3 +39,6 @@ packageExtensions: peerDependencies: react: "*" react-dom: "*" + "@nestjs/axios@*": + dependencies: + axios: "*" diff --git a/apps/kokomen-native/.yarnrc.yml b/apps/kokomen-native/.yarnrc.yml index af0e972a..b03cf60a 100644 --- a/apps/kokomen-native/.yarnrc.yml +++ b/apps/kokomen-native/.yarnrc.yml @@ -1,2 +1,9 @@ nodeLinker: node-modules enableGlobalCache: false +packageExtensions: + "@invertase/react-native-apple-authentication@*": + dependencies: + "prop-types": "^15.5.10" + "@apollo/client@*": + dependencies: + "rxjs": "^7.3.0" diff --git a/apps/kokomen-native/assets/icon.png b/apps/kokomen-native/assets/icon.png index a7855c7c..52e4663a 100644 Binary files a/apps/kokomen-native/assets/icon.png and b/apps/kokomen-native/assets/icon.png differ diff --git a/apps/kokomen-native/ios/Podfile b/apps/kokomen-native/ios/Podfile index 725b3d4f..00d872d9 100644 --- a/apps/kokomen-native/ios/Podfile +++ b/apps/kokomen-native/ios/Podfile @@ -13,7 +13,7 @@ install! 'cocoapods', prepare_react_native_project! -target 'kokomennative' do +target 'kokomen' do use_expo_modules! if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' diff --git a/apps/kokomen-native/ios/Podfile.lock b/apps/kokomen-native/ios/Podfile.lock index a0bc5659..81faec37 100644 --- a/apps/kokomen-native/ios/Podfile.lock +++ b/apps/kokomen-native/ios/Podfile.lock @@ -1817,6 +1817,8 @@ PODS: - React-logger (= 0.79.5) - React-perflogger (= 0.79.5) - React-utils (= 0.79.5) + - RNAppleAuthentication (2.4.1): + - React-Core - RNGestureHandler (2.24.0): - DoubleConversion - glog @@ -2026,6 +2028,7 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNScreens (from `../node_modules/react-native-screens`) - RNSVG (from `../node_modules/react-native-svg`) @@ -2195,6 +2198,8 @@ EXTERNAL SOURCES: :path: build/generated/ios ReactCommon: :path: "../node_modules/react-native/ReactCommon" + RNAppleAuthentication: + :path: "../node_modules/@invertase/react-native-apple-authentication" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNScreens: @@ -2284,12 +2289,13 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: c42e7abdd2228ae583bdabc3dcd8e5cda6bef944 ReactCodegen: 4d001cd4fa72b876bbff500bbb3811e458bb3c72 ReactCommon: 41137f7e87cf7fd1c041a7124dfa3d0d48aa43f3 + RNAppleAuthentication: b2d8b5ccba86b5b1e55a1a2e858f7ac6d8a21816 RNGestureHandler: ccf4105b125002bd88e39d2a1f2b7e6001bcdf34 RNScreens: c2e3cc506212228c607b4785b315205e28acbf0f RNSVG: ee32efbed652c5151fd3f98bed13c68af285bc38 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: adb397651e1c00672c12e9495babca70777e411e -PODFILE CHECKSUM: a8260410ec25c84cedaa7495eb2458ec0b66aa36 +PODFILE CHECKSUM: f8809590e5ed708ff0a8e48e8975651f84586cc4 COCOAPODS: 1.16.2 diff --git a/apps/kokomen-native/ios/kokomen.xcodeproj/xcshareddata/xcschemes/kokomennative.xcscheme b/apps/kokomen-native/ios/kokomen.xcodeproj/xcshareddata/xcschemes/kokomen.xcscheme similarity index 79% rename from apps/kokomen-native/ios/kokomen.xcodeproj/xcshareddata/xcschemes/kokomennative.xcscheme rename to apps/kokomen-native/ios/kokomen.xcodeproj/xcshareddata/xcschemes/kokomen.xcscheme index dd45acf4..197527c8 100644 --- a/apps/kokomen-native/ios/kokomen.xcodeproj/xcshareddata/xcschemes/kokomennative.xcscheme +++ b/apps/kokomen-native/ios/kokomen.xcodeproj/xcshareddata/xcschemes/kokomen.xcscheme @@ -15,9 +15,9 @@ + BuildableName = "kokomen.app" + BlueprintName = "kokomen" + ReferencedContainer = "container:kokomen.xcodeproj"> @@ -33,9 +33,9 @@ + BuildableName = "kokomenTests.xctest" + BlueprintName = "kokomenTests" + ReferencedContainer = "container:kokomen.xcodeproj"> @@ -55,9 +55,9 @@ + BuildableName = "kokomen.app" + BlueprintName = "kokomen" + ReferencedContainer = "container:kokomen.xcodeproj"> @@ -72,9 +72,9 @@ + BuildableName = "kokomen.app" + BlueprintName = "kokomen" + ReferencedContainer = "container:kokomen.xcodeproj"> diff --git a/apps/kokomen-native/ios/kokomennative.xcworkspace/contents.xcworkspacedata b/apps/kokomen-native/ios/kokomen.xcworkspace/contents.xcworkspacedata similarity index 100% rename from apps/kokomen-native/ios/kokomennative.xcworkspace/contents.xcworkspacedata rename to apps/kokomen-native/ios/kokomen.xcworkspace/contents.xcworkspacedata diff --git a/apps/kokomen-native/ios/kokomennative/AppDelegate.swift b/apps/kokomen-native/ios/kokomen/AppDelegate.swift similarity index 100% rename from apps/kokomen-native/ios/kokomennative/AppDelegate.swift rename to apps/kokomen-native/ios/kokomen/AppDelegate.swift diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/apps/kokomen-native/ios/kokomen/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png similarity index 100% rename from apps/kokomen-native/ios/kokomennative/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png rename to apps/kokomen-native/ios/kokomen/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/AppIcon.appiconset/Contents.json b/apps/kokomen-native/ios/kokomen/Images.xcassets/AppIcon.appiconset/Contents.json similarity index 99% rename from apps/kokomen-native/ios/kokomennative/Images.xcassets/AppIcon.appiconset/Contents.json rename to apps/kokomen-native/ios/kokomen/Images.xcassets/AppIcon.appiconset/Contents.json index 5f6956c1..90d8d4c2 100644 --- a/apps/kokomen-native/ios/kokomennative/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/kokomen-native/ios/kokomen/Images.xcassets/AppIcon.appiconset/Contents.json @@ -11,4 +11,4 @@ "version": 1, "author": "expo" } -} +} \ No newline at end of file diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/Contents.json b/apps/kokomen-native/ios/kokomen/Images.xcassets/Contents.json similarity index 100% rename from apps/kokomen-native/ios/kokomennative/Images.xcassets/Contents.json rename to apps/kokomen-native/ios/kokomen/Images.xcassets/Contents.json diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenBackground.colorset/Contents.json b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenBackground.colorset/Contents.json similarity index 99% rename from apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenBackground.colorset/Contents.json rename to apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenBackground.colorset/Contents.json index 15f02abe..3402288a 100644 --- a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenBackground.colorset/Contents.json +++ b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenBackground.colorset/Contents.json @@ -17,4 +17,4 @@ "version": 1, "author": "expo" } -} \ No newline at end of file +} diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/Contents.json b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/Contents.json similarity index 100% rename from apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/Contents.json rename to apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/Contents.json diff --git a/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image.png b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image.png new file mode 100644 index 00000000..601afb00 Binary files /dev/null and b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image.png differ diff --git a/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image@2x.png b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image@2x.png new file mode 100644 index 00000000..601afb00 Binary files /dev/null and b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image@2x.png differ diff --git a/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image@3x.png b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image@3x.png new file mode 100644 index 00000000..601afb00 Binary files /dev/null and b/apps/kokomen-native/ios/kokomen/Images.xcassets/SplashScreenLogo.imageset/image@3x.png differ diff --git a/apps/kokomen-native/ios/kokomen/Info.plist b/apps/kokomen-native/ios/kokomen/Info.plist new file mode 100644 index 00000000..4fcb4caa --- /dev/null +++ b/apps/kokomen-native/ios/kokomen/Info.plist @@ -0,0 +1,78 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + kokomen + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + kr.kokomen + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSMicrophoneUsageDescription + 면접에서 음성을 인식하기 위해 마이크 권한이 필요합니다. + NSSpeechRecognitionUsageDescription + 면접에서 음성을 인식하기 위해 음성 인식 권한이 필요합니다. + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/apps/kokomen-native/ios/kokomennative/PrivacyInfo.xcprivacy b/apps/kokomen-native/ios/kokomen/PrivacyInfo.xcprivacy similarity index 100% rename from apps/kokomen-native/ios/kokomennative/PrivacyInfo.xcprivacy rename to apps/kokomen-native/ios/kokomen/PrivacyInfo.xcprivacy diff --git a/apps/kokomen-native/ios/kokomennative/SplashScreen.storyboard b/apps/kokomen-native/ios/kokomen/SplashScreen.storyboard similarity index 100% rename from apps/kokomen-native/ios/kokomennative/SplashScreen.storyboard rename to apps/kokomen-native/ios/kokomen/SplashScreen.storyboard diff --git a/apps/kokomen-native/ios/kokomennative/Supporting/Expo.plist b/apps/kokomen-native/ios/kokomen/Supporting/Expo.plist similarity index 100% rename from apps/kokomen-native/ios/kokomennative/Supporting/Expo.plist rename to apps/kokomen-native/ios/kokomen/Supporting/Expo.plist diff --git a/apps/kokomen-native/ios/kokomennative/kokomennative-Bridging-Header.h b/apps/kokomen-native/ios/kokomen/kokomen-Bridging-Header.h similarity index 100% rename from apps/kokomen-native/ios/kokomennative/kokomennative-Bridging-Header.h rename to apps/kokomen-native/ios/kokomen/kokomen-Bridging-Header.h diff --git a/apps/kokomen-native/ios/kokomen/kokomen.entitlements b/apps/kokomen-native/ios/kokomen/kokomen.entitlements new file mode 100644 index 00000000..80b5221d --- /dev/null +++ b/apps/kokomen-native/ios/kokomen/kokomen.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.developer.applesignin + + Default + + + diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image.png b/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image.png deleted file mode 100644 index 9d444ba0..00000000 Binary files a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image.png and /dev/null differ diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image@2x.png b/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image@2x.png deleted file mode 100644 index cc1a2815..00000000 Binary files a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image@2x.png and /dev/null differ diff --git a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image@3x.png b/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image@3x.png deleted file mode 100644 index c1132063..00000000 Binary files a/apps/kokomen-native/ios/kokomennative/Images.xcassets/SplashScreenLogo.imageset/image@3x.png and /dev/null differ diff --git a/apps/kokomen-native/ios/kokomennative/Info.plist b/apps/kokomen-native/ios/kokomennative/Info.plist deleted file mode 100644 index 4cb7749c..00000000 --- a/apps/kokomen-native/ios/kokomennative/Info.plist +++ /dev/null @@ -1,80 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - kokomen - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 0.0.1 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - kr.kokomen - - - - CFBundleVersion - 1 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSMicrophoneUsageDescription - $(PRODUCT_NAME)이 음성 면접 서비스 제공을 위해 마이크 권한이 필요합니다. - NSSpeechRecognitionUsageDescription - $(PRODUCT_NAME)이 음성 면접 과정에서 음성 인식 권한이 필요합니다. - NSPhotoLibraryUsageDescription - $(PRODUCT_NAME)에서 사진을 업로드하거나 프로필 이미지를 변경할 때 사진 라이브러리 접근이 필요합니다. - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Light - UIViewControllerBasedStatusBarAppearance - - - diff --git a/apps/kokomen-native/ios/kokomennative/kokomennative.entitlements b/apps/kokomen-native/ios/kokomennative/kokomennative.entitlements deleted file mode 100644 index f683276c..00000000 --- a/apps/kokomen-native/ios/kokomennative/kokomennative.entitlements +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/apps/kokomen-native/package.json b/apps/kokomen-native/package.json index 396e567c..9ebd421b 100644 --- a/apps/kokomen-native/package.json +++ b/apps/kokomen-native/package.json @@ -1,5 +1,5 @@ { - "name": "kokomen-native", + "name": "kokomen", "version": "1.0.0", "main": "index.ts", "scripts": { @@ -20,13 +20,17 @@ ] }, "dependencies": { + "@apollo/client": "^4.0.6", + "@invertase/react-native-apple-authentication": "^2.4.1", "@react-navigation/bottom-tabs": "^7.4.2", "@react-navigation/native": "^7.1.14", "@react-navigation/native-stack": "^7.3.21", "@react-navigation/stack": "^7.4.2", + "axios": "^1.9.0", "expo": "~53.0.17", "expo-speech-recognition": "^2.1.1", "expo-status-bar": "~2.2.3", + "graphql": "^16.11.0", "lucide-react-native": "^0.525.0", "react": "19.0.0", "react-native": "0.79.5", diff --git a/apps/kokomen-native/src/constants/index.ts b/apps/kokomen-native/src/constants/index.ts new file mode 100644 index 00000000..9bff5aa8 --- /dev/null +++ b/apps/kokomen-native/src/constants/index.ts @@ -0,0 +1,9 @@ +import { Platform } from "react-native"; + +const WEBVIEW_RUN_FIRST_SCRIPT = ` + window.isNativeApp = true; + window.OS="${Platform.OS}"; + true; +`; + +export { WEBVIEW_RUN_FIRST_SCRIPT }; diff --git a/apps/kokomen-native/src/hooks/useWebviewEvents.ts b/apps/kokomen-native/src/hooks/useWebviewEvents.ts new file mode 100644 index 00000000..a5f031b1 --- /dev/null +++ b/apps/kokomen-native/src/hooks/useWebviewEvents.ts @@ -0,0 +1,112 @@ +import useSpeechRecognition from "@/hooks/useSpeechRecognition"; +import appleAuth from "@invertase/react-native-apple-authentication"; +import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; +import WebView, { WebViewMessageEvent } from "react-native-webview"; + +export default function useWebviewEvents( + webviewRef: React.RefObject, +) { + const { handleStart, handleStop, isListening } = useSpeechRecognition({ + onResult: (transcript) => { + const speechRecognitionResult = JSON.stringify({ + type: "speechRecognitionResult", + data: transcript, + }); + webviewRef.current?.postMessage(speechRecognitionResult); + }, + onStart: () => { + webviewRef.current?.postMessage( + JSON.stringify({ + type: "startListening", + }), + ); + }, + onEnd: () => { + webviewRef.current?.postMessage( + JSON.stringify({ + type: "stopListening", + }), + ); + }, + }); + + const checkSpeechRecognitionSupported = () => { + ExpoSpeechRecognitionModule.requestPermissionsAsync() + .then((result) => { + webviewRef.current?.postMessage( + JSON.stringify({ + type: "checkSpeechRecognitionSupported", + data: result.status === "granted", + }), + ); + }) + .catch(() => { + webviewRef.current?.postMessage( + JSON.stringify({ + type: "checkSpeechRecognitionSupported", + data: false, + }), + ); + }); + }; + + const pageChange = () => { + if (isListening) { + handleStop(); + } + }; + + const appleLogin = async () => { + try { + const appleAuthResult = await appleAuth.performRequest({ + requestedOperation: appleAuth.Operation.LOGIN, + requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL], + }); + + webviewRef.current?.postMessage( + JSON.stringify({ + type: "appleLoginResult", + data: { + authorizationCode: appleAuthResult.authorizationCode, + identityToken: appleAuthResult.identityToken, + user: appleAuthResult.user, + realUserStatus: appleAuthResult.realUserStatus, + fullName: appleAuthResult.fullName, + nonce: appleAuthResult.nonce, + state: appleAuthResult.state, + }, + }), + ); + } catch (error) { + console.error("error while apple login", error); + } + }; + + const handleMessage = (event: WebViewMessageEvent) => { + try { + const data = JSON.parse(event.nativeEvent.data); + switch (data.type) { + case "startListening": + handleStart(); + break; + case "stopListening": + handleStop(); + break; + case "checkSpeechRecognitionSupported": + checkSpeechRecognitionSupported(); + break; + case "pageChange": + pageChange(); + break; + case "appleLogin": + appleLogin(); + break; + } + } catch (error) { + console.error("error while parsing message", error); + } + }; + return { + handleMessage, + }; +} diff --git a/apps/kokomen-native/src/screens/interviews/interviewMain.tsx b/apps/kokomen-native/src/screens/interviews/interviewMain.tsx index b5a09902..52537e4a 100644 --- a/apps/kokomen-native/src/screens/interviews/interviewMain.tsx +++ b/apps/kokomen-native/src/screens/interviews/interviewMain.tsx @@ -5,95 +5,14 @@ import { SafeAreaView, View, } from "react-native"; -import WebView, { WebViewMessageEvent } from "react-native-webview"; -import useSpeechRecognition from "@/hooks/useSpeechRecognition"; -import { ExpoSpeechRecognitionModule } from "expo-speech-recognition"; +import WebView from "react-native-webview"; +import useWebviewEvents from "@/hooks/useWebviewEvents"; +import { WEBVIEW_RUN_FIRST_SCRIPT } from "@/constants"; export default function InterviewMainScreen() { const webviewRef = useRef(null); - const runFirst = ` - window.isNativeApp = true; - true; - `; - const { handleStart, handleStop, isListening } = useSpeechRecognition({ - onResult: (transcript) => { - const speechRecognitionResult = JSON.stringify({ - type: "speechRecognitionResult", - data: transcript, - }); - webviewRef.current?.postMessage(speechRecognitionResult); - }, - onStart: () => { - webviewRef.current?.postMessage( - JSON.stringify({ - type: "startListening", - }), - ); - }, - onEnd: () => { - webviewRef.current?.postMessage( - JSON.stringify({ - type: "stopListening", - }), - ); - }, - }); - const checkSpeechRecognitionSupported = () => { - ExpoSpeechRecognitionModule.requestPermissionsAsync() - .then((result) => { - if (result.status === "granted") { - webviewRef.current?.postMessage( - JSON.stringify({ - type: "checkSpeechRecognitionSupported", - data: true, - }), - ); - } else { - webviewRef.current?.postMessage( - JSON.stringify({ - type: "checkSpeechRecognitionSupported", - data: false, - }), - ); - } - }) - .catch(() => { - webviewRef.current?.postMessage( - JSON.stringify({ - type: "checkSpeechRecognitionSupported", - data: false, - }), - ); - }); - }; - - const pageChange = () => { - if (isListening) { - handleStop(); - } - }; - const handleMessage = (event: WebViewMessageEvent) => { - try { - const data = JSON.parse(event.nativeEvent.data); - switch (data.type) { - case "startListening": - handleStart(); - break; - case "stopListening": - handleStop(); - break; - case "checkSpeechRecognitionSupported": - checkSpeechRecognitionSupported(); - break; - case "pageChange": - pageChange(); - break; - } - } catch (error) { - console.error("error while parsing message", error); - } - }; + const { handleMessage } = useWebviewEvents(webviewRef); return ( @@ -114,7 +33,7 @@ export default function InterviewMainScreen() { userAgent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" javaScriptEnabled={true} originWhitelist={["*"]} - injectedJavaScriptBeforeContentLoaded={runFirst} + injectedJavaScriptBeforeContentLoaded={WEBVIEW_RUN_FIRST_SCRIPT} webviewDebuggingEnabled onMessage={handleMessage} style={{ flex: 1 }} diff --git a/apps/kokomen-native/src/screens/my/dashboard.tsx b/apps/kokomen-native/src/screens/my/dashboard.tsx index 39a8f849..7ae9e330 100644 --- a/apps/kokomen-native/src/screens/my/dashboard.tsx +++ b/apps/kokomen-native/src/screens/my/dashboard.tsx @@ -1,3 +1,5 @@ +import { WEBVIEW_RUN_FIRST_SCRIPT } from "@/constants"; +import useWebviewEvents from "@/hooks/useWebviewEvents"; import { useRef } from "react"; import { KeyboardAvoidingView, @@ -9,10 +11,7 @@ import WebView from "react-native-webview"; export default function DashboardScreen() { const webviewRef = useRef(null); - const runFirst = ` - window.isNativeApp = true; - true; - `; + const { handleMessage } = useWebviewEvents(webviewRef); return ( @@ -30,13 +29,14 @@ export default function DashboardScreen() { userAgent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" javaScriptEnabled={true} originWhitelist={["*"]} - injectedJavaScriptBeforeContentLoaded={runFirst} + injectedJavaScriptBeforeContentLoaded={WEBVIEW_RUN_FIRST_SCRIPT} webviewDebuggingEnabled style={{ flex: 1 }} pullToRefreshEnabled={true} setBuiltInZoomControls={false} domStorageEnabled={true} setDisplayZoomControls={false} + onMessage={handleMessage} /> diff --git a/apps/kokomen-native/yarn.lock b/apps/kokomen-native/yarn.lock index 2d2b2e56..dc65023f 100644 --- a/apps/kokomen-native/yarn.lock +++ b/apps/kokomen-native/yarn.lock @@ -27,6 +27,37 @@ __metadata: languageName: node linkType: hard +"@apollo/client@npm:^4.0.6": + version: 4.0.6 + resolution: "@apollo/client@npm:4.0.6" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.1.1" + "@wry/caches": "npm:^1.0.0" + "@wry/equality": "npm:^0.5.6" + "@wry/trie": "npm:^0.5.0" + graphql-tag: "npm:^2.12.6" + optimism: "npm:^0.18.0" + tslib: "npm:^2.3.0" + peerDependencies: + graphql: ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + rxjs: ^7.3.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + checksum: 10c0/f8328786c5f551c0e0ee6dc41084bbbe8a4094e71cdcd6ca3bc2f92b12aa2b8fb73bc56d662c143d4818b7212c8b45c8d05180f0a924113e39a84e5072fb8d3b + languageName: node + linkType: hard + "@babel/code-frame@npm:7.10.4, @babel/code-frame@npm:~7.10.4": version: 7.10.4 resolution: "@babel/code-frame@npm:7.10.4" @@ -1440,6 +1471,15 @@ __metadata: languageName: node linkType: hard +"@graphql-typed-document-node/core@npm:^3.1.1": + version: 3.2.0 + resolution: "@graphql-typed-document-node/core@npm:3.2.0" + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/94e9d75c1f178bbae8d874f5a9361708a3350c8def7eaeb6920f2c820e82403b7d4f55b3735856d68e145e86c85cbfe2adc444fdc25519cd51f108697e99346c + languageName: node + linkType: hard + "@hapi/hoek@npm:^9.0.0, @hapi/hoek@npm:^9.3.0": version: 9.3.0 resolution: "@hapi/hoek@npm:9.3.0" @@ -1456,6 +1496,13 @@ __metadata: languageName: node linkType: hard +"@invertase/react-native-apple-authentication@npm:^2.4.1": + version: 2.4.1 + resolution: "@invertase/react-native-apple-authentication@npm:2.4.1" + checksum: 10c0/4508f96dd3fde8c5009db184c5697a4f9b63ce2856f05650ec71274fd50d96cca7088cc084e0bd88f13e801497036e40d529af150e750880448053bf74e9e540 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -2370,6 +2417,42 @@ __metadata: languageName: node linkType: hard +"@wry/caches@npm:^1.0.0": + version: 1.0.1 + resolution: "@wry/caches@npm:1.0.1" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/a7bca3377f1131d3f1080f2e39d0692c9d1ca86bfd55734786f167f46aad28a4c8e772107324e8319843fb8068fdf98abcdea376d8a589316b1f0cdadf81f8b1 + languageName: node + linkType: hard + +"@wry/context@npm:^0.7.0": + version: 0.7.4 + resolution: "@wry/context@npm:0.7.4" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/6cc8249b8ba195cda7643bffb30969e33d54a99f118a29dd12f1c34064ee0adf04253cfa0ba5b9893afde0a9588745828962877b9585106f7488e8299757638b + languageName: node + linkType: hard + +"@wry/equality@npm:^0.5.6": + version: 0.5.7 + resolution: "@wry/equality@npm:0.5.7" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/8503ff6d4eb80f303d1387e71e51da59ccfc2160fa6d464618be80946fe43a654ea73f0c5b90d659fc4dfc3e38cbbdd6650d595fe5865be476636e444470853e + languageName: node + linkType: hard + +"@wry/trie@npm:^0.5.0": + version: 0.5.0 + resolution: "@wry/trie@npm:0.5.0" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/8c8cfcac96ba4bc69dabf02740e19e613f501b398e80bacc32cd95e87228f75ecb41cd1a76a65abae9756c0f61ab3536e0da52de28857456f9381ffdf5995d3e + languageName: node + linkType: hard + "@xmldom/xmldom@npm:^0.8.8": version: 0.8.10 resolution: "@xmldom/xmldom@npm:0.8.10" @@ -2567,6 +2650,24 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"axios@npm:^1.9.0": + version: 1.12.2 + resolution: "axios@npm:1.12.2" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/80b063e318cf05cd33a4d991cea0162f3573481946f9129efb7766f38fde4c061c34f41a93a9f9521f02b7c9565ccbc197c099b0186543ac84a24580017adfed + languageName: node + linkType: hard + "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -3187,6 +3288,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + "command-exists@npm:^1.2.8": version: 1.2.9 resolution: "command-exists@npm:1.2.9" @@ -3460,6 +3570,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -3701,6 +3818,18 @@ __metadata: languageName: node linkType: hard +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -4068,6 +4197,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 + languageName: node + linkType: hard + "fontfaceobserver@npm:^2.1.0": version: 2.3.0 resolution: "fontfaceobserver@npm:2.3.0" @@ -4085,6 +4224,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.4": + version: 4.0.4 + resolution: "form-data@npm:4.0.4" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695 + languageName: node + linkType: hard + "freeport-async@npm:^2.0.0": version: 2.0.0 resolution: "freeport-async@npm:2.0.0" @@ -4166,7 +4318,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -4268,6 +4420,24 @@ __metadata: languageName: node linkType: hard +"graphql-tag@npm:^2.12.6": + version: 2.12.6 + resolution: "graphql-tag@npm:2.12.6" + dependencies: + tslib: "npm:^2.1.0" + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + checksum: 10c0/7763a72011bda454ed8ff1a0d82325f43ca6478e4ce4ab8b7910c4c651dd00db553132171c04d80af5d5aebf1ef6a8a9fd53ccfa33b90ddc00aa3d4be6114419 + languageName: node + linkType: hard + +"graphql@npm:^16.11.0": + version: 16.11.0 + resolution: "graphql@npm:16.11.0" + checksum: 10c0/124da7860a2292e9acf2fed0c71fc0f6a9b9ca865d390d112bdd563c1f474357141501c12891f4164fe984315764736ad67f705219c62f7580681d431a85db88 + languageName: node + linkType: hard + "has-flag@npm:^3.0.0": version: 3.0.0 resolution: "has-flag@npm:3.0.0" @@ -4282,13 +4452,22 @@ __metadata: languageName: node linkType: hard -"has-symbols@npm:^1.1.0": +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": version: 1.1.0 resolution: "has-symbols@npm:1.1.0" checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e languageName: node linkType: hard +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -4911,20 +5090,24 @@ __metadata: languageName: node linkType: hard -"kokomen-native@workspace:.": +"kokomen@workspace:.": version: 0.0.0-use.local - resolution: "kokomen-native@workspace:." + resolution: "kokomen@workspace:." dependencies: + "@apollo/client": "npm:^4.0.6" "@babel/core": "npm:^7.25.2" + "@invertase/react-native-apple-authentication": "npm:^2.4.1" "@react-native-community/cli": "npm:^19.1.0" "@react-navigation/bottom-tabs": "npm:^7.4.2" "@react-navigation/native": "npm:^7.1.14" "@react-navigation/native-stack": "npm:^7.3.21" "@react-navigation/stack": "npm:^7.4.2" "@types/react": "npm:~19.0.10" + axios: "npm:^1.9.0" expo: "npm:~53.0.17" expo-speech-recognition: "npm:^2.1.1" expo-status-bar: "npm:~2.2.3" + graphql: "npm:^16.11.0" lucide-react-native: "npm:^0.525.0" prettier: "npm:^3.5.3" react: "npm:19.0.0" @@ -5155,7 +5338,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.0.0": +"loose-envify@npm:^1.0.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -5519,7 +5702,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.27, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -5860,7 +6043,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.0.1": +"object-assign@npm:^4.0.1, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414 @@ -5956,6 +6139,18 @@ __metadata: languageName: node linkType: hard +"optimism@npm:^0.18.0": + version: 0.18.1 + resolution: "optimism@npm:0.18.1" + dependencies: + "@wry/caches": "npm:^1.0.0" + "@wry/context": "npm:^0.7.0" + "@wry/trie": "npm:^0.5.0" + tslib: "npm:^2.3.0" + checksum: 10c0/1c1cd065d306de2220c6a2bdd8701cb7f9aadace36a9f16d6e02db2bee23b0291f15a1219b92cde5c66d816bd33dca876dfdcdbad04b4cf9b2a7fc5a1a221e77 + languageName: node + linkType: hard + "ora@npm:^3.4.0": version: 3.4.0 resolution: "ora@npm:3.4.0" @@ -6282,6 +6477,24 @@ __metadata: languageName: node linkType: hard +"prop-types@npm:^15.5.10": + version: 15.8.1 + resolution: "prop-types@npm:15.8.1" + dependencies: + loose-envify: "npm:^1.4.0" + object-assign: "npm:^4.1.1" + react-is: "npm:^16.13.1" + checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077 + languageName: node + linkType: hard + +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b + languageName: node + linkType: hard + "punycode@npm:^2.1.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -6387,7 +6600,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.7.0": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 @@ -6797,6 +7010,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:^7.3.0": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 + languageName: node + linkType: hard + "safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -7466,6 +7688,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.1.0, tslib@npm:^2.3.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + "type-detect@npm:4.0.8": version: 4.0.8 resolution: "type-detect@npm:4.0.8" diff --git a/apps/kokomen-server/local.Dockerfile b/apps/kokomen-server/local.Dockerfile new file mode 100644 index 00000000..865b9fef --- /dev/null +++ b/apps/kokomen-server/local.Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine + +WORKDIR /app + +RUN corepack enable + +# Yarn Berry (PnP) 환경에서는 .yarn 캐시와 .pnp.cjs가 필요 +# 컨테이너 시작 시 yarn install 실행 +CMD ["yarn", "server:dev"] \ No newline at end of file diff --git a/apps/kokomen-server/nginx/local/nginx.conf b/apps/kokomen-server/nginx/local/nginx.conf new file mode 100644 index 00000000..b0403c94 --- /dev/null +++ b/apps/kokomen-server/nginx/local/nginx.conf @@ -0,0 +1,49 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + + +http { + charset utf-8; + + log_format main '$request_id $remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" "$request_time" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + '"$ssl_protocol/$ssl_cipher" "$content_length" "$request_length"'; + access_log /var/log/nginx/access.log main; + + upstream kokomen-graphql-api-dev { + server kokomen-nest-server-dev:3000; + } + + upstream kokomen-interview-local-api { + server kokomen-interview-local-api:8080; + } + + server{ + listen 443 ssl; + server_name api.local.kokomen.kr; + server_tokens off; + + ssl_certificate /etc/nginx/certs/localhost.crt; + ssl_certificate_key /etc/nginx/certs/localhost.key; + + location /api/v3 { + proxy_pass http://kokomen-graphql-api-dev; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-RequestID $request_id; + } + + location / { + proxy_pass http://kokomen-interview-local-api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-RequestID $request_id; + } + } +} diff --git a/apps/kokomen-server/package.json b/apps/kokomen-server/package.json index 8eb1a424..605b88a5 100644 --- a/apps/kokomen-server/package.json +++ b/apps/kokomen-server/package.json @@ -23,16 +23,21 @@ "dependencies": { "@apollo/server": "^4.9.3", "@nestjs/apollo": "^13.1.0", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/graphql": "^13.1.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.0", + "class-validator": "^0.14.2", "connect-redis": "^9.0.0", + "cookie-parser": "^1.4.7", "express-session": "^1.18.2", "graphql": "^16.11.0", "ioredis": "^5.7.0", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", "mysql2": "^3.14.5", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -44,9 +49,11 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.0", "@types/express-session": "^1", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", diff --git a/apps/kokomen-server/src/app.module.ts b/apps/kokomen-server/src/app.module.ts index 2e381437..0eb89ed5 100644 --- a/apps/kokomen-server/src/app.module.ts +++ b/apps/kokomen-server/src/app.module.ts @@ -12,6 +12,7 @@ import { MemberService } from "./member/member.service"; import { RedisModule } from "src/redis/redis.module"; import { CategoryModule } from "src/interview/modules/category"; import { RootQuestionModule } from "src/interview/modules/rootQuestion"; +import { AuthModule } from "./auth/auth.module"; @Module({ imports: [ @@ -38,11 +39,13 @@ import { RootQuestionModule } from "src/interview/modules/rootQuestion"; autoSchemaFile: true, path: "api/v3/graphql", sortSchema: true, - introspection: true + introspection: true, + context: ({ req, res }) => ({ req, res }) }), RedisModule, CategoryModule, - RootQuestionModule + RootQuestionModule, + AuthModule ], controllers: [AppController], providers: [AppService, MemberResolver, MemberService] diff --git a/apps/kokomen-server/src/auth/auth.module.ts b/apps/kokomen-server/src/auth/auth.module.ts new file mode 100644 index 00000000..2c622e28 --- /dev/null +++ b/apps/kokomen-server/src/auth/auth.module.ts @@ -0,0 +1,41 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { HttpModule } from "@nestjs/axios"; +import { ConfigModule } from "@nestjs/config"; +import { Member } from "../member/domains/member"; +import { MemberSocialLogin } from "../member/domains/memberSocialLogin"; +import { AuthService } from "./services/auth.service"; +import { AppleAuthService } from "./services/apple-auth.service"; +import { SpringSessionService } from "./services/spring-session.service"; +import { AuthResolver } from "./auth.resolver"; +import { SessionAuthGuard } from "../globals/session-auth.guard"; +import { RedisModule } from "../redis/redis.module"; +import { MemberService } from "src/member/member.service"; +import { SocialLoginService } from "src/auth/services/socialLogin.service"; +import { TokenModule } from "src/token/token.module"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Member, MemberSocialLogin]), + HttpModule, + ConfigModule, + RedisModule, + TokenModule + ], + providers: [ + AuthService, + AppleAuthService, + SpringSessionService, + AuthResolver, + SessionAuthGuard, + MemberService, + SocialLoginService + ], + exports: [ + AuthService, + AppleAuthService, + SpringSessionService, + SessionAuthGuard + ] +}) +export class AuthModule {} diff --git a/apps/kokomen-server/src/auth/auth.resolver.ts b/apps/kokomen-server/src/auth/auth.resolver.ts new file mode 100644 index 00000000..f3eda3a8 --- /dev/null +++ b/apps/kokomen-server/src/auth/auth.resolver.ts @@ -0,0 +1,43 @@ +import { Resolver, Mutation, Args, Context } from "@nestjs/graphql"; +import { UseInterceptors } from "@nestjs/common"; +import { Response } from "express"; +import { AuthService } from "./services/auth.service"; +import { AppleAuthInput, AuthResponse } from "./dto/apple-auth.dto"; +import { TransactionInterceptor } from "src/globals/interceptors/transactionInterceptor"; +import { TransactionManager } from "src/globals/decorators/transactionManager"; +import { EntityManager } from "typeorm"; + +@Resolver() +export class AuthResolver { + constructor(private readonly authService: AuthService) {} + + @Mutation(() => AuthResponse, { + description: "Authenticate with Apple Sign In" + }) + @UseInterceptors(TransactionInterceptor) + async appleAuth( + @Args("input") input: AppleAuthInput, + @TransactionManager() transactionManager: EntityManager, + @Context() context: { req: any; res: Response } + ): Promise { + console.log("appleAuth Start"); + const { sessionId, member } = await this.authService.appleAuth( + transactionManager, + input + ); + console.log("appleAuth SessionId", sessionId, member); + + // Set JSESSIONID cookie - GraphQL context에서 res 가져오기 + const response: Response = context.res; + response.setHeader( + "Set-Cookie", + `JSESSIONID=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400` + ); + + return { + id: member.id, + nickname: member.nickname, + profile_completed: member.profileCompleted + }; + } +} diff --git a/apps/kokomen-server/src/auth/dto/apple-auth.dto.ts b/apps/kokomen-server/src/auth/dto/apple-auth.dto.ts new file mode 100644 index 00000000..a86dcc9a --- /dev/null +++ b/apps/kokomen-server/src/auth/dto/apple-auth.dto.ts @@ -0,0 +1,109 @@ +import { Field, InputType, ObjectType } from "@nestjs/graphql"; +import { + IsNotEmpty, + IsOptional, + IsString, + IsInt, + Min, + Max +} from "class-validator"; +import { AppleCredentialState } from "../enums/apple-credential-state.enum"; + +@InputType() +export class AppleFullNameInput { + @Field({ nullable: true }) + @IsOptional() + @IsString() + givenName?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + familyName?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + middleName?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + namePrefix?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + nameSuffix?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + nickname?: string; +} + +@InputType() +export class AppleAuthInput { + @Field() + @IsNotEmpty() + @IsString() + authorizationCode: string; + + @Field() + @IsNotEmpty() + @IsString() + identityToken: string; + + @Field() + @IsOptional() + @IsInt() + @Min(0) + @Max(3) + realUserStatus: AppleCredentialState; + + @Field() + @IsOptional() + @IsString() + user: string; + + @Field(() => AppleFullNameInput, { nullable: true }) + @IsOptional() + fullName?: AppleFullNameInput; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + nonce?: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + state?: string; +} + +@ObjectType() +export class AuthResponse { + @Field() + id: number; + + @Field() + nickname: string; + + @Field() + profile_completed: boolean; +} + +export interface AppleTokenPayload { + iss: string; + aud: string; + exp: number; + iat: number; + sub: string; + nonce?: string; + c_hash?: string; + email?: string; + email_verified?: boolean; + auth_time?: number; + nonce_supported?: boolean; + real_user_status?: AppleCredentialState; +} diff --git a/apps/kokomen-server/src/auth/enums/apple-credential-state.enum.ts b/apps/kokomen-server/src/auth/enums/apple-credential-state.enum.ts new file mode 100644 index 00000000..925743fc --- /dev/null +++ b/apps/kokomen-server/src/auth/enums/apple-credential-state.enum.ts @@ -0,0 +1,6 @@ +export enum AppleCredentialState { + REVOKED = 0, + AUTHORIZED = 1, + NOT_FOUND = 2, + TRANSFERRED = 3 +} diff --git a/apps/kokomen-server/src/auth/services/apple-auth.service.ts b/apps/kokomen-server/src/auth/services/apple-auth.service.ts new file mode 100644 index 00000000..477b6fab --- /dev/null +++ b/apps/kokomen-server/src/auth/services/apple-auth.service.ts @@ -0,0 +1,93 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as jwt from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; +import { AppleTokenPayload } from "../dto/apple-auth.dto"; + +@Injectable() +export class AppleAuthService { + private readonly appleIssuer = "https://appleid.apple.com"; + private readonly appleKeysUrl = "https://appleid.apple.com/auth/keys"; + private readonly appleKeysCacheMaxAge = 600000; + private readonly clientId: string; + private jwksClient: JwksClient; + + constructor(private readonly configService: ConfigService) { + this.clientId = this.configService.get( + "APPLE_CLIENT_ID", + "kr.kokomen" + ); + + this.jwksClient = new JwksClient({ + jwksUri: this.appleKeysUrl, + cache: true, + cacheMaxAge: this.appleKeysCacheMaxAge + }); + } + + async verifyIdentityToken(identityToken: string): Promise { + try { + // token 디코딩 + const decodedToken = jwt.decode(identityToken, { complete: true }); + + if (!decodedToken || typeof decodedToken === "string") { + throw new UnauthorizedException("Invalid token format"); + } + + const { kid, alg } = decodedToken.header; + if (!kid) { + throw new UnauthorizedException("Token missing key id"); + } + + // Apple 서명 키 조회 + const key = await this.getSigningKey(kid); + + // token 검증 + const payload = jwt.verify(identityToken, key, { + algorithms: [(alg as jwt.Algorithm) || "RS256"], + issuer: this.appleIssuer, + audience: this.clientId + }) as AppleTokenPayload; + + // 추가 검증 + if (!payload.sub) { + throw new UnauthorizedException("Invalid token: missing subject"); + } + + if (payload.iss !== this.appleIssuer) { + throw new UnauthorizedException("Invalid token issuer"); + } + + if (payload.aud !== this.clientId) { + throw new UnauthorizedException("Invalid token audience"); + } + + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) { + throw new UnauthorizedException("Token has expired"); + } + + return payload; + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException( + `Token verification failed: ${error.message}` + ); + } + } + + private async getSigningKey(kid: string): Promise { + return new Promise((resolve, reject) => { + this.jwksClient.getSigningKey(kid, (err, key) => { + if (err || !key) { + reject(err); + return; + } + const signingKey = key.getPublicKey(); + resolve(signingKey); + }); + }); + } +} diff --git a/apps/kokomen-server/src/auth/services/auth.service.ts b/apps/kokomen-server/src/auth/services/auth.service.ts new file mode 100644 index 00000000..1df0e081 --- /dev/null +++ b/apps/kokomen-server/src/auth/services/auth.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from "@nestjs/common"; +import { EntityManager } from "typeorm"; +import { Member } from "../../member/domains/member"; +import { AppleAuthService } from "./apple-auth.service"; +import { SpringSessionService } from "./spring-session.service"; +import { AppleAuthInput } from "../dto/apple-auth.dto"; +import { MemberService } from "src/member/member.service"; +import { SocialLoginService } from "src/auth/services/socialLogin.service"; +import { TokenService } from "src/token/services/token.service"; + +@Injectable() +export class AuthService { + private readonly appleLoginProvider = "APPLE"; + constructor( + private readonly memberService: MemberService, + private readonly appleAuthService: AppleAuthService, + private readonly springSessionService: SpringSessionService, + private readonly socialLoginService: SocialLoginService, + private readonly tokenService: TokenService + ) {} + + async appleAuth( + transactionManager: EntityManager, + input: AppleAuthInput + ): Promise<{ sessionId: string; member: Member }> { + // apple identity token 검증 + const tokenPayload = await this.appleAuthService.verifyIdentityToken( + input.identityToken + ); + + // 유저 정보 추출 + const appleUserId = tokenPayload.sub; + + // 소셜 로그인 아이디 조회 + let socialLogin = await this.socialLoginService.findById( + appleUserId, + this.appleLoginProvider + ); + + let member: Member | undefined = socialLogin?.member; + if (!member) { + // 소셜 로그인 아이디가 없으면 새로운 가입으로 취급 + const newMember = await this.memberService.create(transactionManager, { + nickname: `꼬꼬면_${tokenPayload.sub.slice(0, 6)}`, + createdAt: new Date(), + profileCompleted: false + }); + socialLogin = await this.socialLoginService.create(transactionManager, { + member: newMember, + provider: this.appleLoginProvider, + socialId: appleUserId, + createdAt: new Date() + }); + member = newMember; + await this.tokenService.createTokensForNewMember( + transactionManager, + member.id + ); + } + + // spring session 형식에 맞춰 세션 정보 생성 + const sessionId = await this.springSessionService.createSession(member); + + return { + sessionId, + member + }; + } +} diff --git a/apps/kokomen-server/src/auth/services/socialLogin.service.ts b/apps/kokomen-server/src/auth/services/socialLogin.service.ts new file mode 100644 index 00000000..a76acdf5 --- /dev/null +++ b/apps/kokomen-server/src/auth/services/socialLogin.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, Repository } from "typeorm"; +import { MemberSocialLogin } from "src/member/domains/memberSocialLogin"; + +@Injectable() +export class SocialLoginService { + constructor( + @InjectRepository(MemberSocialLogin) + private memberSocialLoginRepository: Repository + ) {} + + async findAll(): Promise { + return this.memberSocialLoginRepository.find(); + } + + async findById( + socialId: string, + provider: string + ): Promise { + return this.memberSocialLoginRepository.findOne({ + where: { socialId, provider }, + relations: ["member"] + }); + } + + async create( + transactionManager: EntityManager, + memberData: Partial + ): Promise { + const member = this.memberSocialLoginRepository.create(memberData); + return transactionManager.save(member); + } +} diff --git a/apps/kokomen-server/src/auth/services/spring-session.service.ts b/apps/kokomen-server/src/auth/services/spring-session.service.ts new file mode 100644 index 00000000..4fedf585 --- /dev/null +++ b/apps/kokomen-server/src/auth/services/spring-session.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; +import { Member } from "../../member/domains/member"; +import * as crypto from "crypto"; +import { JavaSerializer } from "src/auth/utils/javaSerializer"; + +export interface SpringSessionData { + sessionId: string; + memberId: number; + createdAt: number; + lastAccessedAt: number; + maxInactiveInterval: number; +} + +@Injectable() +export class SpringSessionService { + private readonly SESSION_NAMESPACE = "spring:session"; + private readonly SESSION_PREFIX = `${this.SESSION_NAMESPACE}:sessions:`; + private readonly DEFAULT_MAX_INACTIVE_INTERVAL = 1000 * 60 * 60 * 24; // 24시간 + + constructor( + @Inject("REDIS_CLIENT") private redis: Redis, + private configService: ConfigService + ) {} + + async createSession(member: Member): Promise { + const sessionId = this.generateSessionId(); + const now = Date.now(); + const maxInactiveInterval = this.configService.get( + "SESSION_MAX_INACTIVE_INTERVAL", + this.DEFAULT_MAX_INACTIVE_INTERVAL + ); + + // Redis에 세션 데이터 저장 (Spring Session 형식으로 Java serialization) + const sessionKey = `${this.SESSION_PREFIX}${sessionId}`; + + // Redis pipeline 사용 + const pipeline = this.redis.pipeline(); + + // Java serialized 값으로 session hash 설정 + pipeline.hset( + sessionKey, + "lastAccessedTime", + JavaSerializer.serializeLong(now) + ); + pipeline.hset( + sessionKey, + "maxInactiveInterval", + JavaSerializer.serializeInteger(maxInactiveInterval) + ); + pipeline.hset( + sessionKey, + "creationTime", + JavaSerializer.serializeLong(now) + ); + pipeline.hset( + sessionKey, + "sessionAttr:MEMBER_ID", + JavaSerializer.serializeLong(member.id) + ); + + // 세션 TTL 설정 + pipeline.expire(sessionKey, maxInactiveInterval); + + await pipeline.exec(); + + // Base64 encoded sessionId 반환 (Spring Session 형식) + return Buffer.from(sessionId).toString("base64"); + } + + async getSession(sessionId: string): Promise { + // Base64 sessionId 디코딩 + const decodedSessionId = Buffer.from(sessionId, "base64").toString("utf-8"); + const sessionKey = `${this.SESSION_PREFIX}${decodedSessionId}`; + + // 모든 세션 데이터를 buffer로 가져오기 + const sessionData = await this.redis.hgetallBuffer(sessionKey); + + if (!sessionData || Object.keys(sessionData).length === 0) { + return null; + } + + // 마지막 접근 시간 업데이트 + const now = Date.now(); + await this.redis.hset( + sessionKey, + "lastAccessedTime", + JavaSerializer.serializeLong(now) + ); + + // member ID 디코딩 + const memberIdBuffer = sessionData["sessionAttr:MEMBER_ID"]; + if (!memberIdBuffer) { + return null; + } + + const memberId = JavaSerializer.deserializeLong(memberIdBuffer); + const creationTime = JavaSerializer.deserializeLong( + sessionData["creationTime"] + ); + const maxInactiveInterval = JavaSerializer.deserializeInteger( + sessionData["maxInactiveInterval"] + ); + + return { + sessionId, + memberId, + createdAt: creationTime, + lastAccessedAt: now, + maxInactiveInterval + }; + } + + async touchSession(sessionId: string): Promise { + // Base64 sessionId 디코딩 + const decodedSessionId = Buffer.from(sessionId, "base64").toString("utf-8"); + const sessionKey = `${this.SESSION_PREFIX}${decodedSessionId}`; + const exists = await this.redis.exists(sessionKey); + + if (!exists) { + return false; + } + + const now = Date.now(); + await this.redis.hset( + sessionKey, + "lastAccessedTime", + JavaSerializer.serializeLong(now) + ); + + // Refresh TTL + const maxInactiveInterval = this.configService.get( + "SESSION_MAX_INACTIVE_INTERVAL", + this.DEFAULT_MAX_INACTIVE_INTERVAL + ); + await this.redis.expire(sessionKey, maxInactiveInterval); + + return true; + } + + private generateSessionId(): string { + // Spring Session 호환 ID 생성 (UUID v4 형식) + const bytes = crypto.randomBytes(16); + + // 버전 (4)과 변종 비트 설정 + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + // UUID + const hex = bytes.toString("hex"); + return [ + hex.substring(0, 8), + hex.substring(8, 12), + hex.substring(12, 16), + hex.substring(16, 20), + hex.substring(20, 32) + ].join("-"); + } +} diff --git a/apps/kokomen-server/src/auth/utils/javaSerializer.ts b/apps/kokomen-server/src/auth/utils/javaSerializer.ts new file mode 100644 index 00000000..ebb081ac --- /dev/null +++ b/apps/kokomen-server/src/auth/utils/javaSerializer.ts @@ -0,0 +1,54 @@ +// Redis Java Serializer +export class JavaSerializer { + // Serialize Java Long - Redis dump 참고 + static serializeLong(value: number): Buffer { + // Redis 헤더 (마지막 8바이트는 값) + const header = Buffer.from([ + 0xac, 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, + 0x2e, 0x6c, 0x61, 0x6e, 0x67, 0x2e, 0x4c, 0x6f, 0x6e, 0x67, 0x3b, 0x8b, + 0xe4, 0x90, 0xcc, 0x8f, 0x23, 0xdf, 0x02, 0x00, 0x01, 0x4a, 0x00, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x78, 0x72, 0x00, 0x10, 0x6a, 0x61, 0x76, + 0x61, 0x2e, 0x6c, 0x61, 0x6e, 0x67, 0x2e, 0x4e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x86, 0xac, 0x95, 0x1d, 0x0b, 0x94, 0xe0, 0x8b, 0x02, 0x00, 0x00, + 0x78, 0x70 + ]); + + // 실제 long 값 (8바이트) + const valueBuffer = Buffer.alloc(8); + valueBuffer.writeBigInt64BE(BigInt(value)); + + return Buffer.concat([header, valueBuffer]); + } + + // Serialize Java Integer - Redis dump 참고 + static serializeInteger(value: number): Buffer { + // Redis 헤더 (마지막 4바이트는 값) + const header = Buffer.from([ + 0xac, 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x11, 0x6a, 0x61, 0x76, 0x61, + 0x2e, 0x6c, 0x61, 0x6e, 0x67, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x67, 0x65, + 0x72, 0x12, 0xe2, 0xa0, 0xa4, 0xf7, 0x81, 0x87, 0x38, 0x02, 0x00, 0x01, + 0x49, 0x00, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x78, 0x72, 0x00, 0x10, + 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x6c, 0x61, 0x6e, 0x67, 0x2e, 0x4e, 0x75, + 0x6d, 0x62, 0x65, 0x72, 0x86, 0xac, 0x95, 0x1d, 0x0b, 0x94, 0xe0, 0x8b, + 0x02, 0x00, 0x00, 0x78, 0x70 + ]); + + // 실제 int 값 (4바이트) + const valueBuffer = Buffer.alloc(4); + valueBuffer.writeInt32BE(value); + + return Buffer.concat([header, valueBuffer]); + } + + // Deserialize Java Long - 마지막 8바이트 값 추출 + static deserializeLong(buffer: Buffer): number { + const valueBuf = buffer.slice(-8); + return Number(valueBuf.readBigInt64BE(0)); + } + + // Deserialize Java Integer - 마지막 4바이트 값 추출 + static deserializeInteger(buffer: Buffer): number { + const valueBuf = buffer.slice(-4); + return valueBuf.readInt32BE(0); + } +} diff --git a/apps/kokomen-server/src/globals/decorators/transactionManager.ts b/apps/kokomen-server/src/globals/decorators/transactionManager.ts new file mode 100644 index 00000000..2a7c8d55 --- /dev/null +++ b/apps/kokomen-server/src/globals/decorators/transactionManager.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; + +export const TransactionManager = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const gqlContext = GqlExecutionContext.create(ctx); + const context = gqlContext.getContext(); + return context.req.queryRunnerManager; + } +); diff --git a/apps/kokomen-server/src/globals/interceptors/transactionInterceptor.ts b/apps/kokomen-server/src/globals/interceptors/transactionInterceptor.ts new file mode 100644 index 00000000..41204b2f --- /dev/null +++ b/apps/kokomen-server/src/globals/interceptors/transactionInterceptor.ts @@ -0,0 +1,60 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + InternalServerErrorException, + Logger, + NestInterceptor +} from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import { catchError, Observable, tap } from "rxjs"; +import { DataSource } from "typeorm"; + +@Injectable() +export class TransactionInterceptor implements NestInterceptor { + private readonly logger = new Logger(TransactionInterceptor.name); + constructor(private readonly dataSource: DataSource) {} + + async intercept( + context: ExecutionContext, + next: CallHandler + ): Promise> { + try { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + // GraphQL 컨텍스트에서 request 가져오기 + const gqlContext = GqlExecutionContext.create(context); + const ctx = gqlContext.getContext(); + + if (!ctx || !ctx.req) { + await queryRunner.release(); + throw new InternalServerErrorException("Invalid context"); + } + + ctx.req.queryRunnerManager = queryRunner.manager; + + return next.handle().pipe( + catchError(async (error) => { + this.logger.error("error in transaction", error); + await queryRunner.rollbackTransaction(); + await queryRunner.release(); + + if (error instanceof HttpException) { + throw new HttpException(error.getResponse(), error.getStatus()); + } + throw new InternalServerErrorException(); + }), + tap(async () => { + await queryRunner.commitTransaction(); + await queryRunner.release(); + }) + ); + } catch (error) { + this.logger.error("unhandled error", error); + throw new InternalServerErrorException(); + } + } +} diff --git a/apps/kokomen-server/src/globals/session-auth.guard.ts b/apps/kokomen-server/src/globals/session-auth.guard.ts new file mode 100644 index 00000000..b006e66a --- /dev/null +++ b/apps/kokomen-server/src/globals/session-auth.guard.ts @@ -0,0 +1,48 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { SpringSessionService } from '../auth/services/spring-session.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Member } from '../member/domains/member'; + +@Injectable() +export class SessionAuthGuard implements CanActivate { + constructor( + private readonly springSessionService: SpringSessionService, + @InjectRepository(Member) + private readonly memberRepository: Repository, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + + // Get JSESSIONID from cookie + const sessionId = request.cookies?.JSESSIONID; + + if (!sessionId) { + throw new UnauthorizedException('No session found'); + } + + // Validate session + const sessionData = await this.springSessionService.getSession(sessionId); + + if (!sessionData) { + throw new UnauthorizedException('Invalid or expired session'); + } + + // Get member data + const member = await this.memberRepository.findOne({ + where: { id: sessionData.memberId }, + }); + + if (!member) { + throw new UnauthorizedException('Member not found'); + } + + // Attach member to request + request.member = member; + + return true; + } +} \ No newline at end of file diff --git a/apps/kokomen-server/src/main.ts b/apps/kokomen-server/src/main.ts index 1be8db2f..7e0b116b 100644 --- a/apps/kokomen-server/src/main.ts +++ b/apps/kokomen-server/src/main.ts @@ -8,9 +8,16 @@ async function bootstrap() { app.enableCors({ origin: [ + "http://localhost:80", "http://localhost:3000", "http://localhost:3001", + "https://localhost:3000", + "https://localhost:3001", "http://local.kokomen.kr:3000", + "http://local.kokomen.kr", + "https://local.kokomen.kr:3000", + "https://local.kokomen.kr:3001", + "https://local.kokomen.kr", "https://dev.kokomen.kr", "https://kokomen.kr", "https://webview.kokomen.kr", diff --git a/apps/kokomen-server/src/member/domains/member.ts b/apps/kokomen-server/src/member/domains/member.ts index 81e770c0..7ee1d020 100644 --- a/apps/kokomen-server/src/member/domains/member.ts +++ b/apps/kokomen-server/src/member/domains/member.ts @@ -8,6 +8,7 @@ import { import { Interview } from "../../interview/domains/interview"; import { AnswerLike } from "../../answer/domains/answerLike"; import { InterviewLike } from "../../interview/domains/interviewLike"; +import { MemberSocialLogin } from "./memberSocialLogin"; import { Field, ID, Int, ObjectType } from "@nestjs/graphql"; @ObjectType() @@ -18,25 +19,17 @@ export class Member { id: number; @Field(() => Date) - @CreateDateColumn({ name: "created_at", type: "datetime", precision: 6 }) + @Column({ name: "created_at", type: "datetime", precision: 6 }) createdAt: Date; - @Field(() => Int, { nullable: true }) - @Column({ name: "kakao_id", type: "bigint", nullable: true, unique: true }) - kakaoId: number | null; - - @Field(() => String, { nullable: true }) + @Field(() => String) @Column({ type: "varchar", length: 255, nullable: true }) - nickname: string | null; + nickname: string; @Field(() => Int) @Column({ type: "int", default: 0 }) score: number; - @Field(() => Int) - @Column({ name: "free_token_count", type: "int" }) - freeTokenCount: number; - @Field(() => Boolean) @Column({ name: "profile_completed", type: "boolean" }) profileCompleted: boolean; @@ -52,4 +45,8 @@ export class Member { @Field(() => [InterviewLike]) @OneToMany(() => InterviewLike, (interviewLike) => interviewLike.member) interviewLikes: InterviewLike[]; + + @Field(() => [MemberSocialLogin]) + @OneToMany(() => MemberSocialLogin, (socialLogin) => socialLogin.member) + socialLogins: MemberSocialLogin[]; } diff --git a/apps/kokomen-server/src/member/domains/memberSocialLogin.ts b/apps/kokomen-server/src/member/domains/memberSocialLogin.ts new file mode 100644 index 00000000..f4d13536 --- /dev/null +++ b/apps/kokomen-server/src/member/domains/memberSocialLogin.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, + Unique +} from "typeorm"; +import { Member } from "./member"; +import { Field, ID, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +@Entity("member_social_login") +@Unique(["member", "provider"]) +@Index(["member", "provider"]) +@Index(["socialId"]) +export class MemberSocialLogin { + @Field(() => ID) + @PrimaryGeneratedColumn({ type: "bigint" }) + id: number; + + @Field(() => Date) + @Column({ name: "created_at", type: "datetime" }) + createdAt: Date; + + @Field(() => Member) + @ManyToOne(() => Member, (member) => member.socialLogins, { + onDelete: "CASCADE" + }) + @JoinColumn({ name: "member_id" }) + member: Member; + + @Field(() => String) + @Column({ type: "varchar", length: 255 }) + provider: string; + + @Field(() => String) + @Column({ name: "social_id", type: "varchar", length: 255 }) + socialId: string; +} diff --git a/apps/kokomen-server/src/member/member.service.ts b/apps/kokomen-server/src/member/member.service.ts index 08c9d1b9..e0315369 100644 --- a/apps/kokomen-server/src/member/member.service.ts +++ b/apps/kokomen-server/src/member/member.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { EntityManager, Repository } from "typeorm"; import { Member } from "./domains/member"; import { InterviewState } from "src/interview/domains/interviewState"; @@ -24,16 +24,12 @@ export class MemberService { }); } - async findByKakaoId(kakaoId: number): Promise { - return this.memberRepository.findOne({ - where: { kakaoId }, - relations: ["interviews"] - }); - } - - async create(memberData: Partial): Promise { + async create( + transactionManager: EntityManager, + memberData: Partial + ): Promise { const member = this.memberRepository.create(memberData); - return this.memberRepository.save(member); + return transactionManager.save(member); } async updateScore(id: number, score: number): Promise { diff --git a/apps/kokomen-server/src/token/domains/token.ts b/apps/kokomen-server/src/token/domains/token.ts new file mode 100644 index 00000000..d25379dc --- /dev/null +++ b/apps/kokomen-server/src/token/domains/token.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index +} from "typeorm"; +import { BadRequestException } from "@nestjs/common"; + +export enum TokenType { + FREE = "FREE", + PAID = "PAID" +} + +@Entity("token") +@Index("uk_token_member_type", ["memberId", "type"], { unique: true }) +export class Token { + @PrimaryGeneratedColumn({ name: "id" }) + id: number; + + @Column({ name: "member_id", type: "bigint", nullable: false }) + memberId: number; + + @Column({ + name: "type", + type: "enum", + enum: TokenType, + nullable: false + }) + type: TokenType; + + @Column({ name: "token_count", type: "int", nullable: false, default: 0 }) + tokenCount: number; + + @CreateDateColumn({ + name: "created_at", + type: "timestamp", + default: () => "CURRENT_TIMESTAMP" + }) + createdAt: Date; + + // Constructor + constructor(memberId: number, type: TokenType, tokenCount: number) { + this.memberId = memberId; + this.type = type; + this.tokenCount = tokenCount; + } + + // Business methods + addTokens(count: number): void { + if (count < 0) { + throw new Error("Token count cannot be negative"); + } + this.tokenCount += count; + } + + useToken(): void { + if (this.tokenCount <= 0) { + throw new BadRequestException("Token count cannot be negative"); + } + this.tokenCount--; + } + + hasTokens(): boolean { + return this.tokenCount > 0; + } + + hasEnoughTokens(requiredCount: number): boolean { + return this.tokenCount >= requiredCount; + } + + setTokenCount(count: number): void { + if (count < 0) { + throw new Error("Token count cannot be negative"); + } + this.tokenCount = count; + } +} diff --git a/apps/kokomen-server/src/token/services/token.service.ts b/apps/kokomen-server/src/token/services/token.service.ts new file mode 100644 index 00000000..dc363d61 --- /dev/null +++ b/apps/kokomen-server/src/token/services/token.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, Repository } from "typeorm"; +import { Token, TokenType } from "../domains/token"; +import { BadRequestException } from "@nestjs/common"; + +@Injectable() +export class TokenService { + private readonly logger = new Logger(TokenService.name); + public static readonly DAILY_FREE_TOKEN_COUNT = 20; + + constructor( + @InjectRepository(Token) + private readonly tokenRepository: Repository + ) {} + + async createTokensForNewMember( + transactionManager: EntityManager, + memberId: number + ): Promise { + const freeToken = new Token( + memberId, + TokenType.FREE, + TokenService.DAILY_FREE_TOKEN_COUNT + ); + const paidToken = new Token(memberId, TokenType.PAID, 0); + + await transactionManager.save(freeToken); + await transactionManager.save(paidToken); + } + + async addPaidTokens(memberId: number, count: number): Promise { + const result = await this.tokenRepository.increment( + { memberId, type: TokenType.PAID }, + "tokenCount", + count + ); + + if (!result.affected || result.affected === 0) { + throw new Error(`Failed to add paid tokens. memberId: ${memberId}`); + } + } + + async setFreeTokens(memberId: number, count: number): Promise { + const freeToken = await this.readTokenByMemberIdAndType( + memberId, + TokenType.FREE + ); + freeToken.setTokenCount(count); + await this.tokenRepository.save(freeToken); + } + + async useFreeToken(memberId: number): Promise { + const freeToken = await this.readTokenByMemberIdAndType( + memberId, + TokenType.FREE + ); + freeToken.useToken(); + await this.tokenRepository.save(freeToken); + } + + async usePaidToken(memberId: number): Promise { + const paidToken = await this.readTokenByMemberIdAndType( + memberId, + TokenType.PAID + ); + paidToken.useToken(); + await this.tokenRepository.save(paidToken); + } + + async refundPaidTokenCount(memberId: number, count: number): Promise { + const result = await this.tokenRepository.decrement( + { memberId, type: TokenType.PAID }, + "tokenCount", + count + ); + + if (!result.affected || result.affected === 0) { + throw new Error(`Failed to refund paid tokens. memberId: ${memberId}`); + } + } + + async validateEnoughTokens( + memberId: number, + requiredCount: number + ): Promise { + const hasEnough = await this.hasEnoughTokens(memberId, requiredCount); + if (!hasEnough) { + throw new BadRequestException("Not enough tokens"); + } + } + + async hasEnoughTokens( + memberId: number, + requiredCount: number + ): Promise { + const totalCount = await this.calculateTotalTokenCount(memberId); + return totalCount >= requiredCount; + } + + private async calculateTotalTokenCount(memberId: number): Promise { + const freeCount = await this.readFreeTokenCount(memberId); + const paidCount = await this.readPaidTokenCount(memberId); + return freeCount + paidCount; + } + + async readFreeTokenCount(memberId: number): Promise { + const token = await this.readTokenByMemberIdAndType( + memberId, + TokenType.FREE + ); + return token.tokenCount; + } + + async readPaidTokenCount(memberId: number): Promise { + const token = await this.readTokenByMemberIdAndType( + memberId, + TokenType.PAID + ); + return token.tokenCount; + } + + async readTokenByMemberIdAndType( + memberId: number, + type: TokenType + ): Promise { + const token = await this.tokenRepository.findOne({ + where: { memberId, type } + }); + + if (!token) { + throw new Error(`Token not found. type: ${type}`); + } + + return token; + } +} diff --git a/apps/kokomen-server/src/token/token.module.ts b/apps/kokomen-server/src/token/token.module.ts new file mode 100644 index 00000000..eae39f46 --- /dev/null +++ b/apps/kokomen-server/src/token/token.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Token } from "src/token/domains/token"; +import { TokenService } from "src/token/services/token.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([Token])], + providers: [TokenService], + exports: [TokenService] +}) +export class TokenModule {} diff --git a/apps/kokomen-webview/index.d.ts b/apps/kokomen-webview/index.d.ts index 213211d4..393d2aca 100644 --- a/apps/kokomen-webview/index.d.ts +++ b/apps/kokomen-webview/index.d.ts @@ -6,6 +6,7 @@ declare global { ReactNativeWebView?: { postMessage: (message: string) => void; }; + OS?: "ios" | "android"; } } diff --git a/apps/kokomen-webview/package.json b/apps/kokomen-webview/package.json index bbc919a5..6b1e7fa3 100644 --- a/apps/kokomen-webview/package.json +++ b/apps/kokomen-webview/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@apollo/client": "^4.0.6", "@hookform/resolvers": "^5.1.1", "@kokomen/ui": "workspace:*", "@tanstack/react-query": "^5.80.6", @@ -18,11 +19,13 @@ "@tanstack/react-router-devtools": "^1.129.3", "@woowa-babble/random-nickname": "^1.0.2", "axios": "^1.9.0", + "graphql": "^16.11.0", "lucide-react": "^0.511.0", "posthog-js": "^1.261.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.59.0", + "rxjs": "^7.8.2", "zod": "^3.25.74", "zustand": "^5.0.6" }, diff --git a/apps/kokomen-webview/public/appleLogo-dark.svg b/apps/kokomen-webview/public/appleLogo-dark.svg new file mode 100644 index 00000000..5a228173 --- /dev/null +++ b/apps/kokomen-webview/public/appleLogo-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/kokomen-webview/src/domains/auth/api/gql.ts b/apps/kokomen-webview/src/domains/auth/api/gql.ts new file mode 100644 index 00000000..79d0fe46 --- /dev/null +++ b/apps/kokomen-webview/src/domains/auth/api/gql.ts @@ -0,0 +1,31 @@ +import { gql } from "@apollo/client"; + +type AppleLoginInput = { + authorizationCode: string; + identityToken: string; + realUserStatus: number; + user: string; + fullName?: { + givenName?: string; + familyName?: string; + middleName?: string; + namePrefix?: string; + nameSuffix?: string; + nickname?: string; + }; + nonce?: string; + state?: string; +}; + +const CREATE_APPLE_LOGIN_MUTATION = gql` + mutation appleAuth($input: AppleAuthInput!) { + appleAuth(input: $input) { + id + nickname + profile_completed + } + } +`; + +export type { AppleLoginInput }; +export { CREATE_APPLE_LOGIN_MUTATION }; diff --git a/apps/kokomen-webview/src/domains/auth/hooks/useLogin.ts b/apps/kokomen-webview/src/domains/auth/hooks/useLogin.ts new file mode 100644 index 00000000..7365a529 --- /dev/null +++ b/apps/kokomen-webview/src/domains/auth/hooks/useLogin.ts @@ -0,0 +1,80 @@ +import { + AppleLoginInput, + CREATE_APPLE_LOGIN_MUTATION +} from "@/domains/auth/api/gql"; +import { useAuthStore } from "@/store"; +import { useApolloClient } from "@apollo/client/react"; +import { WebviewMessage } from "@kokomen/types"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter, useSearch } from "@tanstack/react-router"; +import { useEffect } from "react"; + +function useLogin(defaultRedirectTo: string = "/interviews") { + const ROOT_URI = "/interviews"; + const client = useApolloClient(); + const router = useRouter(); + + const query = useSearch({ + from: "/login/", + select: (search) => search as { redirectTo?: string } + }); + const redirectTo = query.redirectTo || defaultRedirectTo; + + const appleAuthMutation = useMutation({ + mutationFn: async (input: AppleLoginInput) => { + return client.mutate({ + mutation: CREATE_APPLE_LOGIN_MUTATION, + variables: { input } + }); + }, + onSuccess: ({ data }: any) => { + const authData = data.appleAuth; + useAuthStore.getState().setAuth(authData); + if (!authData.profile_completed) { + router.navigate({ + to: `${redirectTo}` + }); + return; + } + const redirectPath = query.redirectTo || ROOT_URI; + router.navigate({ to: redirectPath, replace: true }); + }, + onError: (error) => { + console.error("Apple 로그인 실패:", error.message); + alert("Apple 로그인에 실패했습니다. 다시 시도해주세요."); + } + }); + + const onAppleLogin = () => { + window.ReactNativeWebView?.postMessage( + JSON.stringify({ + type: "appleLogin" + }) + ); + }; + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + try { + const message = JSON.parse( + event.data + ) as WebviewMessage; + + if (message.type === "appleLoginResult" && message.data) { + appleAuthMutation.mutate(message.data); + } + } catch (error) { + console.error("Failed to parse message:", error); + } + }; + + if (window.ReactNativeWebView) { + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + } + }, [appleAuthMutation]); + + return { onAppleLogin, isAppleLoginLoading: appleAuthMutation.isPending }; +} + +export default useLogin; diff --git a/apps/kokomen-webview/src/domains/members/components/rankCard.tsx b/apps/kokomen-webview/src/domains/members/components/rankCard.tsx index 3b256d49..05f4291b 100644 --- a/apps/kokomen-webview/src/domains/members/components/rankCard.tsx +++ b/apps/kokomen-webview/src/domains/members/components/rankCard.tsx @@ -30,15 +30,16 @@ export const RankCardSkeleton = (): JSX.Element => { ); }; -export function RankCard(): JSX.Element { - const { data } = useSuspenseQuery({ +export function RankCard(): JSX.Element | null { + const { data, error } = useSuspenseQuery({ queryKey: memberKeys.rank(), queryFn: () => getRankList() }); const navigate = useNavigate(); + if (error) return null; return ( -
+

현재 면접 등수 diff --git a/apps/kokomen-webview/src/main.tsx b/apps/kokomen-webview/src/main.tsx index 68addc78..98466acb 100644 --- a/apps/kokomen-webview/src/main.tsx +++ b/apps/kokomen-webview/src/main.tsx @@ -3,6 +3,8 @@ import { StrictMode } from "react"; import { LoadingFullScreen } from "@kokomen/ui"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; +import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client"; +import { ApolloProvider } from "@apollo/client/react"; import { QueryClient, QueryClientProvider, @@ -61,14 +63,24 @@ posthog.init(import.meta.env.VITE_POSTHOG_KEY, { defaults: "2025-05-24" }); +const apolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_API_URL, + credentials: "include" + }) +}); + const rootElement = document.getElementById("root")!; if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - - - + + + + + ); } diff --git a/apps/kokomen-webview/src/routes/__root.tsx b/apps/kokomen-webview/src/routes/__root.tsx index 5acbc977..d628b543 100644 --- a/apps/kokomen-webview/src/routes/__root.tsx +++ b/apps/kokomen-webview/src/routes/__root.tsx @@ -69,7 +69,7 @@ function RootComponent(): React.ReactNode {

-
+
diff --git a/apps/kokomen-webview/src/routes/login/index.tsx b/apps/kokomen-webview/src/routes/login/index.tsx index 224b3e9b..c8a7d514 100644 --- a/apps/kokomen-webview/src/routes/login/index.tsx +++ b/apps/kokomen-webview/src/routes/login/index.tsx @@ -1,5 +1,7 @@ import { createFileRoute, Link, useSearch } from "@tanstack/react-router"; +import { Button } from "@kokomen/ui"; import React from "react"; +import useLogin from "@/domains/auth/hooks/useLogin"; // eslint-disable-next-line @rushstack/typedef-var export const Route = createFileRoute("/login/")({ @@ -14,6 +16,10 @@ function RouteComponent(): React.ReactNode { const redirectTo = `&state=${query.redirectTo ?? "/"}`; const redirectUri = `${import.meta.env.VITE_BASE_URL}/login/callback${redirectTo}`; const googleRedirectUri = `${import.meta.env.VITE_BASE_URL}/login/google/callback${redirectTo}`; + const ROOT_URI = "/interviews"; + const { onAppleLogin, isAppleLoginLoading } = useLogin( + query.redirectTo ?? ROOT_URI + ); return ( <>
@@ -32,7 +38,7 @@ function RouteComponent(): React.ReactNode { + {window.OS === "ios" && ( + + )}
diff --git a/apps/kokomen-webview/src/vite-env.d.ts b/apps/kokomen-webview/src/vite-env.d.ts index b8bdeaf6..004de665 100644 --- a/apps/kokomen-webview/src/vite-env.d.ts +++ b/apps/kokomen-webview/src/vite-env.d.ts @@ -14,6 +14,7 @@ interface ImportMetaEnv { readonly VITE_API_TIMEOUT: number; readonly VITE_WEB_BASE_URL: string; readonly VITE_CDN_BASE_URL: string; + readonly VITE_GRAPHQL_API_URL: string; } interface ImportMeta { diff --git a/compose.server.local.yaml b/compose.server.local.yaml index 5d02e0fa..b8846dd3 100644 --- a/compose.server.local.yaml +++ b/compose.server.local.yaml @@ -2,20 +2,10 @@ services: kokomen-nest-server-dev: build: context: . - dockerfile: ./apps/kokomen-server/Dockerfile - args: - TZ: Asia/Seoul - NODE_ENV: development - DATABASE_HOST: kokomen-interview-local-mysql - DATABASE_PORT: 3306 - DATABASE_USERNAME: kokomen - DATABASE_PASSWORD: kokomen - DATABASE_ROOT_PASSWORD: root - DATABASE_DATABASE: kokomen-local - REDIS_HOST: kokomen-local-redis - REDIS_PORT: 6379 - PORT: 3000 + dockerfile: ./apps/kokomen-server/local.Dockerfile container_name: kokomen-nest-server-dev + volumes: + - .:/app restart: on-failure:3 ports: - "3000:3000" @@ -40,7 +30,8 @@ services: - "80:80" - "443:443" volumes: - - ./apps/kokomen-server/nginx/dev/nginx.conf:/etc/nginx/nginx.conf:ro + - ./apps/kokomen-server/nginx/local/nginx.conf:/etc/nginx/nginx.conf:ro + - ./apps/kokomen-server/nginx/local/certs:/etc/nginx/certs:ro depends_on: - kokomen-nest-server-dev restart: unless-stopped diff --git a/package.json b/package.json index d0565f07..84bdb7cb 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,11 @@ "test": "yarn workspace @kokomen/client test && yarn workspace @kokomen/webview test && yarn workspace @kokomen/server test", "lhci": "yarn workspace @kokomen/client lhci", "build": "yarn workspace @kokomen/client build" + }, + "dependencies": { + "@types/cookie-parser": "^1.4.9", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7" } } diff --git a/packages/types/src/webview/index.ts b/packages/types/src/webview/index.ts index a87f8292..95e6ab71 100644 --- a/packages/types/src/webview/index.ts +++ b/packages/types/src/webview/index.ts @@ -2,7 +2,8 @@ type WebviewMessageType = | "startListening" | "stopListening" | "speechRecognitionResult" - | "checkSpeechRecognitionSupported"; + | "checkSpeechRecognitionSupported" + | "appleLoginResult"; type WebviewMessage = { type: WebviewMessageType; diff --git a/packages/ui/src/components/button/index.tsx b/packages/ui/src/components/button/index.tsx index e1d08b32..51fcbc16 100644 --- a/packages/ui/src/components/button/index.tsx +++ b/packages/ui/src/components/button/index.tsx @@ -43,7 +43,8 @@ const buttonVariants = cva( "bg-warning-bg text-warning hover:bg-warning-bg-hover active:bg-warning-border shadow-sm hover:shadow-md", glass: "bg-white/20 backdrop-blur-md border border-white/30 text-text-primary hover:bg-white/30 hover:border-white/50 shadow-lg hover:shadow-xl", - neon: "bg-primary text-text-light-solid shadow-[0_0_20px_rgba(22,104,220,0.5)] hover:shadow-[0_0_30px_rgba(22,104,220,0.7)] transform hover:scale-105" + neon: "bg-primary text-text-light-solid shadow-[0_0_20px_rgba(22,104,220,0.5)] hover:shadow-[0_0_30px_rgba(22,104,220,0.7)] transform hover:scale-105", + none: "" }, size: { default: "px-4 py-2 text-sm", diff --git a/yarn.lock b/yarn.lock index 0665c6d6..f46c4f59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -86,6 +86,37 @@ __metadata: languageName: node linkType: hard +"@apollo/client@npm:^4.0.6": + version: 4.0.6 + resolution: "@apollo/client@npm:4.0.6" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.1.1" + "@wry/caches": "npm:^1.0.0" + "@wry/equality": "npm:^0.5.6" + "@wry/trie": "npm:^0.5.0" + graphql-tag: "npm:^2.12.6" + optimism: "npm:^0.18.0" + tslib: "npm:^2.3.0" + peerDependencies: + graphql: ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + rxjs: ^7.3.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + checksum: 10c0/f8328786c5f551c0e0ee6dc41084bbbe8a4094e71cdcd6ca3bc2f92b12aa2b8fb73bc56d662c143d4818b7212c8b45c8d05180f0a924113e39a84e5072fb8d3b + languageName: node + linkType: hard + "@apollo/protobufjs@npm:1.2.7": version: 1.2.7 resolution: "@apollo/protobufjs@npm:1.2.7" @@ -4291,6 +4322,7 @@ __metadata: "@eslint/eslintrc": "npm:^3.2.0" "@eslint/js": "npm:^9.18.0" "@nestjs/apollo": "npm:^13.1.0" + "@nestjs/axios": "npm:^4.0.1" "@nestjs/cli": "npm:^11.0.0" "@nestjs/common": "npm:^11.0.1" "@nestjs/config": "npm:^4.0.2" @@ -4300,12 +4332,16 @@ __metadata: "@nestjs/schematics": "npm:^11.0.0" "@nestjs/testing": "npm:^11.0.1" "@nestjs/typeorm": "npm:^11.0.0" + "@types/cookie-parser": "npm:^1.4.9" "@types/express": "npm:^5.0.0" "@types/express-session": "npm:^1" "@types/jest": "npm:^30.0.0" + "@types/jsonwebtoken": "npm:^9" "@types/node": "npm:^22.10.7" "@types/supertest": "npm:^6.0.2" + class-validator: "npm:^0.14.2" connect-redis: "npm:^9.0.0" + cookie-parser: "npm:^1.4.7" eslint: "npm:^9.18.0" eslint-config-prettier: "npm:^10.0.1" eslint-plugin-prettier: "npm:^5.2.2" @@ -4315,6 +4351,8 @@ __metadata: ioredis: "npm:^5.7.0" jest: "npm:^30.0.0" jest-util: "npm:^30.0.0" + jsonwebtoken: "npm:^9.0.2" + jwks-rsa: "npm:^3.2.0" mysql2: "npm:^3.14.5" prettier: "npm:^3.4.2" reflect-metadata: "npm:^0.2.2" @@ -4415,6 +4453,7 @@ __metadata: version: 0.0.0-use.local resolution: "@kokomen/webview@workspace:apps/kokomen-webview" dependencies: + "@apollo/client": "npm:^4.0.6" "@babel/core": "npm:^7.28.0" "@babel/preset-env": "npm:^7.28.0" "@babel/preset-react": "npm:^7.27.1" @@ -4447,6 +4486,7 @@ __metadata: browserslist-to-esbuild: "npm:^2.1.1" eslint: "npm:8.57.1" globals: "npm:^16.3.0" + graphql: "npm:^16.11.0" jest: "npm:^30.0.5" jest-environment-jsdom: "npm:^30.0.2" jsdom: "npm:^26.1.0" @@ -4458,6 +4498,7 @@ __metadata: react-dom: "npm:^19.1.0" react-hook-form: "npm:^7.59.0" react-test-renderer: "npm:^19.1.0" + rxjs: "npm:^7.8.2" tailwindcss: "npm:^4.1.7" three: "npm:^0.177.0" typescript: "npm:^5.8.3" @@ -4616,6 +4657,17 @@ __metadata: languageName: node linkType: hard +"@nestjs/axios@npm:^4.0.1": + version: 4.0.1 + resolution: "@nestjs/axios@npm:4.0.1" + peerDependencies: + "@nestjs/common": ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + checksum: 10c0/6290f6ceaab2ea1000ee705dfd81da8cd776eb9fb388287a74a4faecf0afd8a8cdef7f6cc3afe2a9b10e47067df027280ca1ddb75b7885ff365e2faed37b10c7 + languageName: node + linkType: hard + "@nestjs/cli@npm:^11.0.0": version: 11.0.10 resolution: "@nestjs/cli@npm:11.0.10" @@ -7579,6 +7631,15 @@ __metadata: languageName: node linkType: hard +"@types/cookie-parser@npm:^1.4.9": + version: 1.4.9 + resolution: "@types/cookie-parser@npm:1.4.9" + peerDependencies: + "@types/express": "*" + checksum: 10c0/ff7eee7b028ee3943ea92a1771d5587daf54ef038a6a24c034018956a6d0e045a16817b612c0bd53da4162c8037e1c1b8142f9f4bfe6708e20982993704fdb18 + languageName: node + linkType: hard + "@types/cookie@npm:^0.6.0": version: 0.6.0 resolution: "@types/cookie@npm:0.6.0" @@ -7699,7 +7760,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:^4.17.13": +"@types/express@npm:^4.17.13, @types/express@npm:^4.17.20": version: 4.17.23 resolution: "@types/express@npm:4.17.23" dependencies: @@ -7778,6 +7839,16 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^9, @types/jsonwebtoken@npm:^9.0.4": + version: 9.0.10 + resolution: "@types/jsonwebtoken@npm:9.0.10" + dependencies: + "@types/ms": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/0688ac8fb75f809201cb7e18a12b9d80ce539cb9dd27e1b01e11807cb1a337059e899b8ee3abc3f2c9417f02e363a3069d9eab9ef9724b1da1f0e10713514f94 + languageName: node + linkType: hard + "@types/long@npm:^4.0.0": version: 4.0.2 resolution: "@types/long@npm:4.0.2" @@ -7806,6 +7877,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 2.1.0 + resolution: "@types/ms@npm:2.1.0" + checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 + languageName: node + linkType: hard + "@types/mysql@npm:2.15.26": version: 2.15.26 resolution: "@types/mysql@npm:2.15.26" @@ -8063,6 +8141,13 @@ __metadata: languageName: node linkType: hard +"@types/validator@npm:^13.11.8": + version: 13.15.3 + resolution: "@types/validator@npm:13.15.3" + checksum: 10c0/ee1f6266724595b715a01a4f9a2fd601b292906625dbb088ee60b43b3f47cbe371a09a793f2456ef7f6a35d53c9b208897a3d1dfdc4ad1a006ff3707d82f3e22 + languageName: node + linkType: hard + "@types/webxr@npm:*, @types/webxr@npm:^0.5.2": version: 0.5.22 resolution: "@types/webxr@npm:0.5.22" @@ -8991,6 +9076,42 @@ __metadata: languageName: node linkType: hard +"@wry/caches@npm:^1.0.0": + version: 1.0.1 + resolution: "@wry/caches@npm:1.0.1" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/a7bca3377f1131d3f1080f2e39d0692c9d1ca86bfd55734786f167f46aad28a4c8e772107324e8319843fb8068fdf98abcdea376d8a589316b1f0cdadf81f8b1 + languageName: node + linkType: hard + +"@wry/context@npm:^0.7.0": + version: 0.7.4 + resolution: "@wry/context@npm:0.7.4" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/6cc8249b8ba195cda7643bffb30969e33d54a99f118a29dd12f1c34064ee0adf04253cfa0ba5b9893afde0a9588745828962877b9585106f7488e8299757638b + languageName: node + linkType: hard + +"@wry/equality@npm:^0.5.6": + version: 0.5.7 + resolution: "@wry/equality@npm:0.5.7" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/8503ff6d4eb80f303d1387e71e51da59ccfc2160fa6d464618be80946fe43a654ea73f0c5b90d659fc4dfc3e38cbbdd6650d595fe5865be476636e444470853e + languageName: node + linkType: hard + +"@wry/trie@npm:^0.5.0": + version: 0.5.0 + resolution: "@wry/trie@npm:0.5.0" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/8c8cfcac96ba4bc69dabf02740e19e613f501b398e80bacc32cd95e87228f75ecb41cd1a76a65abae9756c0f61ab3536e0da52de28857456f9381ffdf5995d3e + languageName: node + linkType: hard + "@xtuc/ieee754@npm:^1.2.0": version: 1.2.0 resolution: "@xtuc/ieee754@npm:1.2.0" @@ -9543,6 +9664,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:*": + version: 1.12.2 + resolution: "axios@npm:1.12.2" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/80b063e318cf05cd33a4d991cea0162f3573481946f9129efb7766f38fde4c061c34f41a93a9f9521f02b7c9565ccbc197c099b0186543ac84a24580017adfed + languageName: node + linkType: hard + "axios@npm:^1.9.0": version: 1.10.0 resolution: "axios@npm:1.10.0" @@ -9974,6 +10106,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:^1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10c0/fb2294e64d23c573d0dd1f1e7a466c3e978fe94a4e0f8183937912ca374619773bef8e2aceb854129d2efecbbc515bbd0cc78d2734a3e3031edb0888531bbc8e + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -10320,6 +10459,24 @@ __metadata: languageName: node linkType: hard +"class-transformer@npm:^0.5.1": + version: 0.5.1 + resolution: "class-transformer@npm:0.5.1" + checksum: 10c0/19809914e51c6db42c036166839906420bb60367df14e15f49c45c8c1231bf25ae661ebe94736ee29cc688b77101ef851a8acca299375cc52fc141b64acde18a + languageName: node + linkType: hard + +"class-validator@npm:^0.14.2": + version: 0.14.2 + resolution: "class-validator@npm:0.14.2" + dependencies: + "@types/validator": "npm:^13.11.8" + libphonenumber-js: "npm:^1.11.1" + validator: "npm:^13.9.0" + checksum: 10c0/5bb67389d38fa23d342dffdd8e2dcee8235e1906e59799df5b2050278a6d89292fcaa88167f0215e3ddd684f47dcd51b004efa7be32d8aded91ee06cb317b3b8 + languageName: node + linkType: hard + "class-variance-authority@npm:^0.7.1": version: 0.7.1 resolution: "class-variance-authority@npm:0.7.1" @@ -10660,6 +10817,16 @@ __metadata: languageName: node linkType: hard +"cookie-parser@npm:^1.4.7": + version: 1.4.7 + resolution: "cookie-parser@npm:1.4.7" + dependencies: + cookie: "npm:0.7.2" + cookie-signature: "npm:1.0.6" + checksum: 10c0/46bef553de409031b69a6074ce512d131a98e4fa12612669f1a9c3dd98d56897a31db86a3f4338d4a3a895c6f8d5cfd6fa4d99cdf588e0e8eda655efc3f384dc + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -11241,6 +11408,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10c0/ebfbf19d4b8be938f4dd4a83b8788385da353d63307ede301a9252f9f7f88672e76f2191618fd8edfc2f24679236064176fab0b78131b161ee73daa37125408c + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -13200,7 +13376,7 @@ __metadata: languageName: node linkType: hard -"graphql-tag@npm:2.12.6": +"graphql-tag@npm:2.12.6, graphql-tag@npm:^2.12.6": version: 2.12.6 resolution: "graphql-tag@npm:2.12.6" dependencies: @@ -15490,6 +15666,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.15.4": + version: 4.15.9 + resolution: "jose@npm:4.15.9" + checksum: 10c0/4ed4ddf4a029db04bd167f2215f65d7245e4dc5f36d7ac3c0126aab38d66309a9e692f52df88975d99429e357e5fd8bab340ff20baab544d17684dd1d940a0f4 + languageName: node + linkType: hard + "jpeg-js@npm:^0.4.1, jpeg-js@npm:^0.4.4": version: 0.4.4 resolution: "jpeg-js@npm:0.4.4" @@ -15681,6 +15864,24 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:^9.0.2": + version: 9.0.2 + resolution: "jsonwebtoken@npm:9.0.2" + dependencies: + jws: "npm:^3.2.2" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10c0/d287a29814895e866db2e5a0209ce730cbc158441a0e5a70d5e940eb0d28ab7498c6bf45029cc8b479639bca94056e9a7f254e2cdb92a2f5750c7f358657a131 + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -15693,6 +15894,41 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^1.4.1": + version: 1.4.2 + resolution: "jwa@npm:1.4.2" + dependencies: + buffer-equal-constant-time: "npm:^1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/210a544a42ca22203e8fc538835205155ba3af6a027753109f9258bdead33086bac3c25295af48ac1981f87f9c5f941bc8f70303670f54ea7dcaafb53993d92c + languageName: node + linkType: hard + +"jwks-rsa@npm:^3.2.0": + version: 3.2.0 + resolution: "jwks-rsa@npm:3.2.0" + dependencies: + "@types/express": "npm:^4.17.20" + "@types/jsonwebtoken": "npm:^9.0.4" + debug: "npm:^4.3.4" + jose: "npm:^4.15.4" + limiter: "npm:^1.1.5" + lru-memoizer: "npm:^2.2.0" + checksum: 10c0/94896264473c8ec0ec21b8f29fd69b760ccb58ff63e6d5328d99694dc49a9be1d6f739fa536c71ca279966874e6c77b405181ed2c567318e0f545d3e941c318e + languageName: node + linkType: hard + +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: "npm:^1.4.1" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/e770704533d92df358adad7d1261fdecad4d7b66fa153ba80d047e03ca0f1f73007ce5ed3fbc04d2eba09ba6e7e6e645f351e08e5ab51614df1b0aa4f384dfff + languageName: node + linkType: hard + "keyv@npm:^4.5.3, keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -15706,6 +15942,10 @@ __metadata: version: 0.0.0-use.local resolution: "kokomen@workspace:." dependencies: + "@types/cookie-parser": "npm:^1.4.9" + class-transformer: "npm:^0.5.1" + class-validator: "npm:^0.14.2" + cookie-parser: "npm:^1.4.7" eslint: "npm:^8.57.1" eslint-config-prettier: "npm:^10.1.5" eslint-plugin-prettier: "npm:^5.4.0" @@ -15755,6 +15995,13 @@ __metadata: languageName: node linkType: hard +"libphonenumber-js@npm:^1.11.1": + version: 1.12.23 + resolution: "libphonenumber-js@npm:1.12.23" + checksum: 10c0/56ebabdcae6cb4a4da536788c52c231858532e9cc729a0d5dd24d7cd0610f8baf09ed240b9f60fd108d73e54ea1d094f6e90f2a887c0c2c08af2bc53039753fc + languageName: node + linkType: hard + "lie@npm:3.1.1": version: 3.1.1 resolution: "lie@npm:3.1.1" @@ -15960,6 +16207,13 @@ __metadata: languageName: node linkType: hard +"limiter@npm:^1.1.5": + version: 1.1.5 + resolution: "limiter@npm:1.1.5" + checksum: 10c0/ebe2b20a820d1f67b8e1724051246434c419b2da041a7e9cd943f6daf113b8d17a52a1bd88fb79be5b624c10283ecb737f50edb5c1c88c71f4cd367108c97300 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -16015,6 +16269,13 @@ __metadata: languageName: node linkType: hard +"lodash.clonedeep@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.clonedeep@npm:4.5.0" + checksum: 10c0/2caf0e4808f319d761d2939ee0642fa6867a4bbf2cfce43276698828380756b99d4c4fa226d881655e6ac298dd453fe12a5ec8ba49861777759494c534936985 + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -16029,6 +16290,13 @@ __metadata: languageName: node linkType: hard +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10c0/7ca498b9b75bf602d04e48c0adb842dfc7d90f77bcb2a91a2b2be34a723ad24bc1c8b3683ec6b2552a90f216c723cdea530ddb11a3320e08fa38265703978f4b + languageName: node + linkType: hard + "lodash.isarguments@npm:^3.1.0": version: 3.1.0 resolution: "lodash.isarguments@npm:3.1.0" @@ -16036,6 +16304,41 @@ __metadata: languageName: node linkType: hard +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10c0/0aac604c1ef7e72f9a6b798e5b676606042401dd58e49f051df3cc1e3adb497b3d7695635a5cbec4ae5f66456b951fdabe7d6b387055f13267cde521f10ec7f7 + languageName: node + linkType: hard + +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10c0/4c3e023a2373bf65bf366d3b8605b97ec830bca702a926939bcaa53f8e02789b6a176e7f166b082f9365bfec4121bfeb52e86e9040cb8d450e64c858583f61b7 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10c0/2d01530513a1ee4f72dd79528444db4e6360588adcb0e2ff663db2b3f642d4bb3d687051ae1115751ca9082db4fdef675160071226ca6bbf5f0c123dbf0aa12d + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10c0/09eaf980a283f9eef58ef95b30ec7fee61df4d6bf4aba3b5f096869cc58f24c9da17900febc8ffd67819b4e29de29793190e88dc96983db92d84c95fa85d1c92 + languageName: node + linkType: hard + "lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -16057,6 +16360,13 @@ __metadata: languageName: node linkType: hard +"lodash.once@npm:^4.0.0": + version: 4.1.1 + resolution: "lodash.once@npm:4.1.1" + checksum: 10c0/46a9a0a66c45dd812fcc016e46605d85ad599fe87d71a02f6736220554b52ffbe82e79a483ad40f52a8a95755b0d1077fba259da8bfb6694a7abbf4a48f1fc04 + languageName: node + linkType: hard + "lodash.sortby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.sortby@npm:4.7.0" @@ -16134,6 +16444,15 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:6.0.0": + version: 6.0.0 + resolution: "lru-cache@npm:6.0.0" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10c0/cb53e582785c48187d7a188d3379c181b5ca2a9c78d2bce3e7dee36f32761d1c42983da3fe12b55cb74e1779fa94cdc2e5367c028a9b35317184ede0c07a30a9 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -16164,6 +16483,16 @@ __metadata: languageName: node linkType: hard +"lru-memoizer@npm:^2.2.0": + version: 2.3.0 + resolution: "lru-memoizer@npm:2.3.0" + dependencies: + lodash.clonedeep: "npm:^4.5.0" + lru-cache: "npm:6.0.0" + checksum: 10c0/13cf6bc9ff74cdb167078dbb66d4cf43adc802495da8f56097e6f388b4d7ccb91668beb809bdbc55b62d016c138d7c19a18c5883a2fdbcc7f508ad8a23ec7c65 + languageName: node + linkType: hard + "lru.min@npm:^1.0.0": version: 1.1.2 resolution: "lru.min@npm:1.1.2" @@ -17165,6 +17494,18 @@ __metadata: languageName: node linkType: hard +"optimism@npm:^0.18.0": + version: 0.18.1 + resolution: "optimism@npm:0.18.1" + dependencies: + "@wry/caches": "npm:^1.0.0" + "@wry/context": "npm:^0.7.0" + "@wry/trie": "npm:^0.5.0" + tslib: "npm:^2.3.0" + checksum: 10c0/1c1cd065d306de2220c6a2bdd8701cb7f9aadace36a9f16d6e02db2bee23b0291f15a1219b92cde5c66d816bd33dca876dfdcdbad04b4cf9b2a7fc5a1a221e77 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -18708,7 +19049,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.8.1": +"rxjs@npm:^7.8.1, rxjs@npm:^7.8.2": version: 7.8.2 resolution: "rxjs@npm:7.8.2" dependencies: @@ -18730,7 +19071,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -20416,7 +20757,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.3, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.3, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -21091,6 +21432,13 @@ __metadata: languageName: node linkType: hard +"validator@npm:^13.9.0": + version: 13.15.15 + resolution: "validator@npm:13.15.15" + checksum: 10c0/f5349d1fbb9cc36f9f6c5dab1880764ddad1d0d2b084e2a71e5964f7de1635d20e406611559df9a3db24828ce775cbee5e3b6dd52f0d555a61939ed7ea5990bd + languageName: node + linkType: hard + "value-or-promise@npm:^1.0.12": version: 1.0.12 resolution: "value-or-promise@npm:1.0.12"