├── packages ├── frontend │ ├── README.md │ ├── src │ │ ├── pages │ │ │ ├── home │ │ │ │ ├── index.ts │ │ │ │ └── Home.tsx │ │ │ ├── login │ │ │ │ └── index.ts │ │ │ ├── stocks │ │ │ │ ├── index.ts │ │ │ │ └── components │ │ │ │ │ ├── StockInfoCard.tsx │ │ │ │ │ └── StockIndexCard.tsx │ │ │ ├── my-page │ │ │ │ ├── index.ts │ │ │ │ ├── MyPage.tsx │ │ │ │ └── AlarmInfo.tsx │ │ │ └── stock-detail │ │ │ │ ├── components │ │ │ │ ├── index.ts │ │ │ │ ├── Title.tsx │ │ │ │ ├── MetricItem.tsx │ │ │ │ └── RadioButton.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── hooks │ │ │ │ ├── useChatOrder.ts │ │ │ │ └── useChartResize.ts │ │ │ │ └── NotificationPanel.tsx │ │ ├── components │ │ │ ├── ui │ │ │ │ ├── alarm │ │ │ │ │ ├── index.ts │ │ │ │ │ └── Alarm.tsx │ │ │ │ ├── button │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── Button.stories.ts │ │ │ │ │ └── Button.tsx │ │ │ │ ├── input │ │ │ │ │ ├── index.ts │ │ │ │ │ └── Input.tsx │ │ │ │ ├── loader │ │ │ │ │ ├── index.ts │ │ │ │ │ └── Loader.tsx │ │ │ │ ├── modal │ │ │ │ │ ├── index.ts │ │ │ │ │ └── Modal.tsx │ │ │ │ └── tooltip │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── Tooltip.tsx │ │ │ │ │ └── Tooltip.stories.tsx │ │ │ ├── layouts │ │ │ │ ├── alarm │ │ │ │ │ ├── index.ts │ │ │ │ │ └── Alarm.tsx │ │ │ │ ├── search │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── SearchResults.tsx │ │ │ │ │ └── Search.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── Layout.tsx │ │ │ │ └── MenuList.tsx │ │ │ └── errors │ │ │ │ └── error.tsx │ │ ├── apis │ │ │ ├── queries │ │ │ │ ├── auth │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useGetLoginStatus.ts │ │ │ │ │ ├── usePostLogout.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── useGetTestLogin.ts │ │ │ │ ├── chat │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── usePostChatLike.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── useGetChatList.ts │ │ │ │ ├── alarm │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useGetAlarm.ts │ │ │ │ │ ├── usePostInitAlarm.ts │ │ │ │ │ ├── useGetStockAlarm.ts │ │ │ │ │ ├── usePostCreateAlarm.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── stocks │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useGetSearchStocks.ts │ │ │ │ │ ├── useGetStocksByPrice.ts │ │ │ │ │ ├── useStockQueries.ts │ │ │ │ │ └── useGetStocksPriceSeries.ts │ │ │ │ ├── user │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useGetUserInfo.ts │ │ │ │ │ ├── useGetUserTheme.ts │ │ │ │ │ ├── useGetUserStock.ts │ │ │ │ │ ├── usePostUserNickname.ts │ │ │ │ │ ├── usePatchUserTheme.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── stock-detail │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── useGetStockDetail.ts │ │ │ │ │ ├── usePostStockUser.ts │ │ │ │ │ ├── useGetStockOwnership.ts │ │ │ │ │ ├── usePostStockView.ts │ │ │ │ │ ├── useDeleteStockUser.ts │ │ │ │ │ └── schema.ts │ │ │ │ └── errorSchema.ts │ │ │ ├── utils │ │ │ │ ├── formatZodError.ts │ │ │ │ ├── delete.ts │ │ │ │ ├── get.ts │ │ │ │ ├── post.ts │ │ │ │ └── patch.ts │ │ │ └── config │ │ │ │ └── index.ts │ │ ├── contexts │ │ │ ├── login │ │ │ │ ├── index.ts │ │ │ │ ├── loginContext.ts │ │ │ │ └── loginProvider.tsx │ │ │ └── theme │ │ │ │ ├── index.ts │ │ │ │ ├── themeContext.ts │ │ │ │ └── themeProvider.tsx │ │ ├── vite-env.d.ts │ │ ├── styles │ │ │ └── theme │ │ │ │ ├── index.ts │ │ │ │ ├── darkTheme.ts │ │ │ │ ├── lightTheme.ts │ │ │ │ └── types.ts │ │ ├── assets │ │ │ ├── kakao.png │ │ │ ├── naver.png │ │ │ ├── plus.svg │ │ │ ├── send.svg │ │ │ ├── down-arrow.svg │ │ │ ├── search.svg │ │ │ ├── stock.svg │ │ │ ├── user.svg │ │ │ ├── google.svg │ │ │ ├── theme.svg │ │ │ ├── home.svg │ │ │ ├── flag.svg │ │ │ ├── small-bell.svg │ │ │ ├── bell.svg │ │ │ └── date.svg │ │ ├── utils │ │ │ ├── formatDate.ts │ │ │ ├── cn.ts │ │ │ ├── getCurrentDate.ts │ │ │ ├── checkChatWriter.ts │ │ │ ├── getHistogramColorData.ts │ │ │ └── createChartOptions.ts │ │ ├── types │ │ │ ├── menu.ts │ │ │ └── metrics.ts │ │ ├── main.tsx │ │ ├── constants │ │ │ ├── alarmOptions.ts │ │ │ ├── timeUnit.ts │ │ │ ├── chatStatus.ts │ │ │ ├── modalMessage.ts │ │ │ ├── metricItem.ts │ │ │ └── menuItems.tsx │ │ ├── sockets │ │ │ ├── config.ts │ │ │ ├── useWebsocket.ts │ │ │ └── schema.ts │ │ ├── hooks │ │ │ ├── useOutsideClick.ts │ │ │ ├── useInfiniteScroll.ts │ │ │ └── useSubscribeAlarm.ts │ │ ├── App.tsx │ │ └── routes │ │ │ └── index.tsx │ ├── postcss.config.js │ ├── public │ │ ├── favicon.ico │ │ ├── logoTitle.png │ │ ├── logoCharacter.png │ │ └── serviceWorker.js │ ├── svg.d.ts │ ├── .storybook │ │ ├── preview.ts │ │ └── main.ts │ ├── tsconfig.json │ ├── Dockerfile │ ├── vite.config.ts │ ├── .gitignore │ ├── index.html │ ├── tsconfig.node.json │ ├── tailwind.config.ts │ ├── tsconfig.app.json │ └── eslint.config.js └── backend │ ├── src │ ├── user │ │ ├── domain │ │ │ ├── theme.ts │ │ │ ├── role.ts │ │ │ ├── ouathType.ts │ │ │ └── user.entity.ts │ │ ├── constants │ │ │ └── randomNickname.ts │ │ ├── dto │ │ │ ├── userTheme.response.ts │ │ │ ├── user.request.ts │ │ │ └── user.response.ts │ │ └── user.module.ts │ ├── chat │ │ ├── domain │ │ │ ├── chatType.enum.ts │ │ │ ├── mention.entity.ts │ │ │ ├── like.entity.ts │ │ │ └── chat.entity.ts │ │ ├── dto │ │ │ ├── like.request.ts │ │ │ ├── chat.response.ts │ │ │ ├── chat.request.ts │ │ │ └── like.response.ts │ │ ├── decorator │ │ │ └── like.decorator.ts │ │ ├── chat.service.spec.ts │ │ ├── chat.module.ts │ │ └── mention.service.ts │ ├── alarm │ │ ├── dto │ │ │ ├── subscribe.response.ts │ │ │ ├── subscribe.request.ts │ │ │ ├── alarm.request.ts │ │ │ └── alarm.response.ts │ │ ├── domain │ │ │ ├── subscription.entity.ts │ │ │ └── alarm.entity.ts │ │ ├── alarm.module.ts │ │ ├── decorator │ │ │ └── wrong.decorator.ts │ │ ├── push.controller.ts │ │ └── alarm.subscriber.ts │ ├── stock │ │ ├── constants │ │ │ └── timeunit.ts │ │ ├── dto │ │ │ ├── stock.request.ts │ │ │ ├── stockView.request.ts │ │ │ ├── userStock.request.ts │ │ │ ├── stockDetail.response.ts │ │ │ └── stockIndexRate.response.ts │ │ ├── domain │ │ │ ├── kospiStock.entity.ts │ │ │ ├── FluctuationRankStock.entity.ts │ │ │ ├── userStock.entity.ts │ │ │ ├── stockDetail.entity.ts │ │ │ ├── stockLiveData.entity.ts │ │ │ └── stockData.entity.ts │ │ ├── cache │ │ │ └── stockData.cache.ts │ │ ├── decorator │ │ │ ├── stockData.decorator.ts │ │ │ └── stock.decorator.ts │ │ ├── stockDetail.service.ts │ │ └── stockRateIndex.service.ts │ ├── scraper │ │ ├── korea-stock-info │ │ │ ├── korea-stock-info.module.ts │ │ │ ├── dto │ │ │ │ └── master-download.dto.ts │ │ │ └── korea-stock-info.service.spec.ts │ │ ├── openapi │ │ │ ├── util │ │ │ │ ├── openapiCustom.error.ts │ │ │ │ └── queue.spec.ts │ │ │ ├── type │ │ │ │ ├── openapiUtil.type.ts │ │ │ │ ├── openapiPeriodData.ts │ │ │ │ └── openapiPeriodData.type.ts │ │ │ ├── config │ │ │ │ └── openapi.config.ts │ │ │ ├── constants │ │ │ │ └── query.ts │ │ │ ├── parse │ │ │ │ └── openapi.parser.ts │ │ │ └── api │ │ │ │ ├── openapiRankView.api.ts │ │ │ │ └── openapi.abstract.ts │ │ ├── scraper.module.ts │ │ └── domain │ │ │ └── openapiToken.entity.ts │ ├── common │ │ ├── dateEmbedded.entity.ts │ │ ├── decorator │ │ │ └── user.decorator.ts │ │ └── cache │ │ │ └── localCache.ts │ ├── auth │ │ ├── tester │ │ │ ├── testerAuth.service.ts │ │ │ ├── guard │ │ │ │ └── tester.guard.ts │ │ │ ├── strategy │ │ │ │ └── tester.strategy.ts │ │ │ └── testerAuth.controller.ts │ │ ├── session │ │ │ ├── session.guard.ts │ │ │ ├── session.serializer.ts │ │ │ ├── cookieParser.ts │ │ │ ├── websocketSession.service.ts │ │ │ └── webSocketSession.guard.ts │ │ ├── session.module.ts │ │ ├── google │ │ │ ├── guard │ │ │ │ └── google.guard.ts │ │ │ ├── googleAuth.controller.ts │ │ │ ├── googleAuth.service.ts │ │ │ └── strategy │ │ │ │ └── google.strategy.ts │ │ ├── auth.module.ts │ │ └── auth.controller.ts │ ├── configs │ │ ├── session.config.ts │ │ ├── swagger.config.ts │ │ ├── typeormConfig.ts │ │ └── logger.config.ts │ ├── utils │ │ └── date.ts │ ├── middlewares │ │ └── filter │ │ │ └── webSocketException.filter.ts │ ├── app.module.ts │ └── main.ts │ ├── tsconfig.build.json │ ├── nest-cli.json │ ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts │ ├── Dockerfile │ ├── tsconfig.json │ └── .gitignore ├── .husky ├── pre-commit └── commit-msg ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── bug-issue.md │ └── feature_request.md └── workflows │ ├── close-issue.yml │ └── storybook.yml ├── .prettierrc ├── commitlint.config.js ├── tsconfig.json ├── .gitignore ├── package.json └── cz-config.js /packages/frontend/README.md: -------------------------------------------------------------------------------- 1 | # juchumjuchum-fe 2 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/home/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Home'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/login/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Login'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Stocks'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/my-page/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MyPage'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/alarm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Alarm'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Input'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/loader/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Loader'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/modal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Modal'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Tooltip'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/alarm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Alarm'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/search/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Search'; 2 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useGetLoginStatus'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx eslint --fix 5 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/home/Home.tsx: -------------------------------------------------------------------------------- 1 | export const Home = () => { 2 | return
; 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/login/index.ts: -------------------------------------------------------------------------------- 1 | export * from './loginContext'; 2 | export * from './loginProvider'; 3 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from './themeContext'; 2 | export * from './themeProvider'; 3 | -------------------------------------------------------------------------------- /packages/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web17-juchumjuchum/HEAD/packages/frontend/public/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Layout'; 2 | export * from './MenuList'; 3 | export * from './Sidebar'; 4 | -------------------------------------------------------------------------------- /packages/frontend/src/styles/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from './darkTheme'; 2 | export * from './lightTheme'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /packages/frontend/public/logoTitle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web17-juchumjuchum/HEAD/packages/frontend/public/logoTitle.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/kakao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web17-juchumjuchum/HEAD/packages/frontend/src/assets/kakao.png -------------------------------------------------------------------------------- /packages/frontend/src/assets/naver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web17-juchumjuchum/HEAD/packages/frontend/src/assets/naver.png -------------------------------------------------------------------------------- /packages/frontend/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: React.FC>; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/frontend/public/logoCharacter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web17-juchumjuchum/HEAD/packages/frontend/public/logoCharacter.png -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/chat/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema'; 2 | export * from './usePostChatLike'; 3 | export * from './useGetChatList'; 4 | -------------------------------------------------------------------------------- /packages/backend/src/user/domain/theme.ts: -------------------------------------------------------------------------------- 1 | export const Theme = { 2 | light: 'light', 3 | dark: 'dark', 4 | }; 5 | 6 | export type Theme = (typeof Theme)[keyof typeof Theme]; 7 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export const formatDate = (isoString: string) => 4 | dayjs(isoString).format('YY.MM.DD HH:mm'); 5 | -------------------------------------------------------------------------------- /packages/backend/src/user/domain/role.ts: -------------------------------------------------------------------------------- 1 | export const Role = { 2 | USER: 'USER', 3 | ADMIN: 'ADMIN', 4 | } as const; 5 | 6 | export type Role = (typeof Role)[keyof typeof Role]; 7 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './TextArea'; 2 | export * from './MetricItem'; 3 | export * from './Title'; 4 | export * from './StockDetailHeader'; 5 | -------------------------------------------------------------------------------- /packages/frontend/src/types/menu.ts: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | 3 | export interface MenuSection { 4 | id: number; 5 | icon: ReactNode; 6 | text: string; 7 | path?: string; 8 | } 9 | -------------------------------------------------------------------------------- /packages/backend/src/chat/domain/chatType.enum.ts: -------------------------------------------------------------------------------- 1 | export const ChatType = { 2 | NORMAL: 'NORMAL', 3 | BROADCAST: 'BROADCAST', 4 | }; 5 | 6 | export type ChatType = (typeof ChatType)[keyof typeof ChatType]; 7 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export const cn = (...inputs: ClassValue[]) => { 5 | return twMerge(clsx(inputs)); 6 | }; 7 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | close # 2 | 3 | ## ✅ 작업 내용 4 | 5 | ## 📸 스크린샷(FE만) 6 | 7 | ## 📌 이슈 사항 8 | 9 | ## 🟢 완료 조건 10 | 11 | ## ✍ 궁금한 점 12 | 13 | ## 😎 체크 사항 14 | 15 | - [ ] label 설정 확인 16 | - [ ] 브랜치 방향 확인 17 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/alarm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema'; 2 | export * from './useGetAlarm'; 3 | export * from './useGetStockAlarm'; 4 | export * from './usePostCreateAlarm'; 5 | export * from './usePostInitAlarm'; 6 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/dto/subscribe.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class SubscribeResponse { 4 | @ApiProperty({ example: 'success', description: '성공 메시지' }) 5 | message: string; 6 | } 7 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/getCurrentDate.ts: -------------------------------------------------------------------------------- 1 | export const getCurrentDate = (date: Date) => 2 | date.toLocaleDateString('ko-KR', { 3 | year: 'numeric', 4 | month: '2-digit', 5 | day: '2-digit', 6 | weekday: 'long', 7 | }); 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Issue 3 | about: Fix Bug 4 | title: '[BUG] - ' 5 | labels: ':lady_beetle: bugfix' 6 | assignees: '' 7 | --- 8 | 9 | # Description 10 | 11 | # To-do 12 | 13 | - [ ] 14 | 15 | # ETC 16 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stocks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema'; 2 | export * from './useGetStocksByPrice'; 3 | export * from './useGetSearchStocks'; 4 | export * from './useGetStocksPriceSeries'; 5 | export * from './useStockQueries'; 6 | -------------------------------------------------------------------------------- /packages/backend/src/user/domain/ouathType.ts: -------------------------------------------------------------------------------- 1 | export const OauthType = { 2 | GOOGLE: 'google', 3 | NAVER: 'naver', 4 | KAKAO: 'kakao', 5 | LOCAL: 'local', 6 | } as const; 7 | 8 | export type OauthType = (typeof OauthType)[keyof typeof OauthType]; 9 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/utils/formatZodError.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const formatZodError = (error: z.ZodError): string => { 4 | return error.errors 5 | .map((err) => `${err.path.join('.')}: ${err.message}`) 6 | .join(', '); 7 | }; 8 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './StockDetail'; 2 | export * from './AddAlarmForm'; 3 | export * from './ChatPanel'; 4 | export * from './NotificationPanel'; 5 | export * from './StockMetricsPanel'; 6 | export * from './TradingChart'; 7 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema'; 2 | export * from './useGetUserInfo'; 3 | export * from './useGetUserStock'; 4 | export * from './usePostUserNickname'; 5 | export * from './useGetUserTheme'; 6 | export * from './usePatchUserTheme'; 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature] - ' 5 | labels: '✨feature' 6 | assignees: '' 7 | --- 8 | 9 | # Description 10 | 11 | # To-do 12 | 13 | - [ ] 14 | 15 | # ETC 16 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["src/*"] 8 | }, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/frontend/src/types/metrics.ts: -------------------------------------------------------------------------------- 1 | export interface StockMetricsPanelProps { 2 | eps?: number; 3 | high52w?: number; 4 | low52w?: number; 5 | marketCap?: number; 6 | per?: string; 7 | price?: number; 8 | change?: number; 9 | volume?: number; 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "printWidth": 80, 7 | "bracketSpacing": true, 8 | "endOfLine": "lf", 9 | "useTabs": false, 10 | "plugins": ["prettier-plugin-tailwindcss"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/backend/src/stock/constants/timeunit.ts: -------------------------------------------------------------------------------- 1 | export const TIME_UNIT = { 2 | MINUTE: 'minute', 3 | DAY: 'day', 4 | WEEK: 'week', 5 | MONTH: 'month', 6 | YEAR: 'year', 7 | } as const; 8 | 9 | export type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; 10 | -------------------------------------------------------------------------------- /packages/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "tsConfigPath": "tsconfig.build.json" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import '@/index.css'; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useGetStockDetail'; 2 | export * from './usePostStockView'; 3 | export * from './usePostStockUser'; 4 | export * from './schema'; 5 | export * from './useGetStockOwnership'; 6 | export * from './useDeleteStockUser'; 7 | -------------------------------------------------------------------------------- /packages/backend/src/user/constants/randomNickname.ts: -------------------------------------------------------------------------------- 1 | export const status = [ 2 | '신중한', 3 | '과감한', 4 | '공부하는', 5 | '성장하는', 6 | '주춤거리는', 7 | ]; 8 | export const subject = [ 9 | '병아리', 10 | '햄스터', 11 | '다람쥐', 12 | '거북이', 13 | '판다', 14 | '주린이', 15 | '투자자', 16 | ]; 17 | -------------------------------------------------------------------------------- /packages/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './index.css'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /packages/frontend/public/serviceWorker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('push', (event) => { 2 | const data = event.data 3 | ? event.data.json() 4 | : { title: '알림', body: '내용 없음' }; 5 | 6 | self.registration.showNotification(data.title, { 7 | body: data.body, 8 | // icon: 'icon.png', 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "moduleNameMapper": { 10 | "^@/(.*)$": "/../src/$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/korea-stock-info/korea-stock-info.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { KoreaStockInfoService } from './korea-stock-info.service'; 3 | 4 | @Module({ 5 | imports: [], 6 | controllers: [], 7 | providers: [KoreaStockInfoService], 8 | }) 9 | export class KoreaStockInfoModule {} 10 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "extends": "../../tsconfig.json", 8 | "compilerOptions": { 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/backend/src/common/dateEmbedded.entity.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; 2 | 3 | export class DateEmbedded { 4 | @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) 5 | createdAt: Date; 6 | 7 | @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) 8 | updatedAt: Date; 9 | } 10 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | 3 | interface TitleProps { 4 | children: ReactNode; 5 | } 6 | 7 | export const Title = ({ children }: TitleProps) => { 8 | return ( 9 |

{children}

10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/backend/src/stock/dto/stock.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class StockSearchRequest { 5 | @ApiProperty({ 6 | description: '검색할 단어', 7 | example: '삼성', 8 | }) 9 | @IsNotEmpty() 10 | @IsString() 11 | name: string; 12 | } 13 | -------------------------------------------------------------------------------- /packages/backend/src/chat/dto/like.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class LikeRequest { 5 | @ApiProperty({ 6 | required: true, 7 | type: Number, 8 | description: '좋아요를 누를 채팅의 ID', 9 | example: 1, 10 | }) 11 | @IsNumber() 12 | chatId: number; 13 | } 14 | -------------------------------------------------------------------------------- /packages/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | WORKDIR /packages 3 | COPY . . 4 | RUN yarn install --frozen-lockfile 5 | RUN yarn workspace frontend build 6 | 7 | # Nginx 서버로 빌드된 파일 서빙 8 | FROM nginx:alpine 9 | COPY --from=builder /packages/packages/frontend/dist /usr/share/nginx/html 10 | EXPOSE 8080 11 | CMD ["nginx", "-g", "daemon off;"] 12 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/korea-stock-info/dto/master-download.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class MasterDownloadDto { 4 | @IsString() 5 | baseDir!: string; 6 | 7 | @IsString() 8 | target: string; 9 | 10 | constructor(baseDir: string, target: string) { 11 | this.baseDir = baseDir; 12 | this.target = target; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | WORKDIR /packages 3 | COPY . . 4 | RUN yarn install --frozen-lockfile 5 | RUN yarn workspace backend build 6 | 7 | # 한국 시간에 맞춰 변경 8 | RUN apk add --no-cache tzdata 9 | RUN cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && echo "Asia/Seoul" > /etc/timezone 10 | 11 | EXPOSE 3000 12 | CMD ["yarn", "workspace", "backend", "start:prod"] 13 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/alarmOptions.ts: -------------------------------------------------------------------------------- 1 | export type AlarmOptionName = 'targetVolume' | 'targetPrice'; 2 | 3 | interface AlarmOption { 4 | id: number; 5 | name: AlarmOptionName; 6 | label: string; 7 | } 8 | 9 | export const ALARM_OPTIONS: AlarmOption[] = [ 10 | { id: 1, name: 'targetPrice', label: '목표가' }, 11 | { id: 2, name: 'targetVolume', label: '거래가' }, 12 | ]; 13 | -------------------------------------------------------------------------------- /packages/backend/src/auth/tester/testerAuth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserService } from '@/user/user.service'; 3 | 4 | @Injectable() 5 | export class TesterAuthService { 6 | constructor(private readonly userService: UserService) {} 7 | 8 | async attemptAuthentication() { 9 | return await this.userService.registerTester(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/checkChatWriter.ts: -------------------------------------------------------------------------------- 1 | import { ChatData } from '@/sockets/schema'; 2 | 3 | interface CheckChatWriterProps { 4 | chat: ChatData; 5 | nickname: string; 6 | subName: string; 7 | } 8 | 9 | export const checkChatWriter = ({ 10 | chat, 11 | nickname, 12 | subName, 13 | }: CheckChatWriterProps) => 14 | chat.nickname === nickname && chat.subName === subName; 15 | -------------------------------------------------------------------------------- /packages/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "./dist", 6 | "paths": { 7 | "@/*": ["src/*"] 8 | }, 9 | "strictPropertyInitialization": false 10 | }, 11 | "include": [ 12 | "src/**/*", 13 | "test/**/*", 14 | "src/scraper/openapi/type/openapiPeriodData.type.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import Lottie from 'react-lottie-player'; 2 | import loading from '@/components/lottie/loading-animation.json'; 3 | 4 | interface LoaderProps { 5 | className?: string; 6 | } 7 | 8 | export const Loader = ({ className }: LoaderProps) => { 9 | return ( 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/login/loginContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { GetLoginStatus } from '@/apis/queries/auth/schema'; 3 | 4 | interface LoginContextType extends Partial { 5 | isLoggedIn: boolean; 6 | } 7 | 8 | export const LoginContext = createContext({ 9 | isLoggedIn: false, 10 | nickname: '', 11 | subName: '', 12 | }); 13 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/hooks/useChatOrder.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | type OrderType = 'latest' | 'like'; 4 | 5 | export const useChatOrder = () => { 6 | const [order, setOrder] = useState('latest'); 7 | const handleOrderType = () => 8 | setOrder((prev) => (prev === 'latest' ? 'like' : 'latest')); 9 | 10 | return { order, handleOrderType }; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import react from '@vitejs/plugin-react'; 3 | import { defineConfig } from 'vite'; 4 | import svgr from 'vite-plugin-svgr'; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), svgr()], 9 | resolve: { 10 | alias: { 11 | '@': path.resolve(__dirname, './src'), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/backend/src/stock/dto/stockView.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class StockViewRequest { 5 | @ApiProperty({ 6 | example: '005930', 7 | description: '개별 주식 id', 8 | }) 9 | @IsString() 10 | stockId: string; 11 | 12 | constructor(stockId: string) { 13 | this.stockId = stockId; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | *storybook.log 27 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/theme/themeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { GetUserTheme } from '@/apis/queries/user'; 3 | 4 | interface ThemeContextType { 5 | theme: GetUserTheme['theme']; 6 | changeTheme: (newTheme: GetUserTheme['theme']) => void; 7 | } 8 | 9 | export const ThemeContext = createContext({ 10 | theme: 'light', 11 | changeTheme: () => {}, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/timeUnit.ts: -------------------------------------------------------------------------------- 1 | import { StockTimeSeriesRequest } from '@/apis/queries/stocks'; 2 | 3 | export const TIME_UNIT: Array<{ 4 | id: number; 5 | time: StockTimeSeriesRequest['timeunit']; 6 | label: string; 7 | }> = [ 8 | { id: 1, time: 'day', label: '일' }, 9 | { id: 2, time: 'week', label: '주' }, 10 | { id: 3, time: 'month', label: '월' }, 11 | { id: 4, time: 'year', label: '년' }, 12 | ]; 13 | -------------------------------------------------------------------------------- /packages/backend/src/user/dto/userTheme.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Theme } from '@/user/domain/theme'; 3 | 4 | export class UpdateUserThemeResponse { 5 | @ApiProperty({ 6 | description: '유저 테마', 7 | example: 'light', 8 | }) 9 | theme: Theme; 10 | 11 | @ApiProperty({ 12 | description: '테마 변경 시간', 13 | example: new Date(), 14 | }) 15 | updatedAt: Date; 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/getHistogramColorData.ts: -------------------------------------------------------------------------------- 1 | interface VolumeData { 2 | time: string; 3 | value: number; 4 | } 5 | 6 | export const getHistogramColorData = (data: VolumeData[]) => { 7 | return data.map((item, index) => { 8 | const color = 9 | index === 0 || item.value >= data[index - 1].value 10 | ? '#ff4d4d' 11 | : '#1a75ff'; 12 | 13 | return { ...item, color }; 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/backend/src/auth/session/session.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | @Injectable() 5 | export default class SessionGuard implements CanActivate { 6 | async canActivate(context: ExecutionContext): Promise { 7 | const socket: Request = context.switchToHttp().getRequest(); 8 | return !!socket.user; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/util/openapiCustom.error.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class OpenapiException extends HttpException { 4 | private error: unknown; 5 | constructor(message: string, status: HttpStatus, error?: unknown) { 6 | super(message, status); 7 | this.error = error; 8 | } 9 | 10 | public getError() { 11 | return this.error; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 주춤주춤 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/backend/src/auth/session.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MemoryStore } from 'express-session'; 3 | 4 | export const MEMORY_STORE = Symbol('memoryStore'); 5 | 6 | @Module({ 7 | providers: [ 8 | { 9 | provide: MEMORY_STORE, 10 | useFactory: () => { 11 | return new MemoryStore(); 12 | }, 13 | }, 14 | ], 15 | exports: [MEMORY_STORE], 16 | }) 17 | export class SessionModule {} 18 | -------------------------------------------------------------------------------- /packages/backend/src/configs/session.config.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import * as dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | export const sessionConfig = { 6 | secret: process.env.COOKIE_SECRET || randomUUID().toString(), 7 | resave: false, 8 | saveUninitialized: false, 9 | name: process.env.COOKIE_NAME, 10 | cookie: { 11 | maxAge: Number(process.env.COOKIE_MAX_AGE), 12 | httpOnly: true, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/scraper.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { KoreaStockInfoModule } from './korea-stock-info/korea-stock-info.module'; 3 | import { OpenapiScraperModule } from './openapi/openapi-scraper.module'; 4 | 5 | @Module({ 6 | imports: [KoreaStockInfoModule, OpenapiScraperModule], 7 | controllers: [], 8 | providers: [], 9 | exports: [OpenapiScraperModule], 10 | }) 11 | export class ScraperModule {} 12 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/chatStatus.ts: -------------------------------------------------------------------------------- 1 | export const UserStatus = { 2 | NOT_AUTHENTICATED: 'NOT_AUTHENTICATED', 3 | NOT_OWNERSHIP: 'NOT_OWNERSHIP', 4 | OWNERSHIP: 'OWNERSHIP', 5 | } as const; 6 | 7 | export type ChatStatus = keyof typeof UserStatus; 8 | 9 | export const chatPlaceholder: Record = { 10 | NOT_AUTHENTICATED: '로그인 후 입력 가능해요.', 11 | NOT_OWNERSHIP: '주식 소유자만 입력 가능해요.', 12 | OWNERSHIP: '100자 이내로 입력 가능해요.', 13 | } as const; 14 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/modalMessage.ts: -------------------------------------------------------------------------------- 1 | export type ModalMessage = 'NOT_AUTHENTICATED' | 'NOT_OWNERSHIP' | 'OWNERSHIP'; 2 | 3 | export const modalMessage = { 4 | NOT_AUTHENTICATED: { 5 | label: '내 주식 추가', 6 | message: '로그인 후 이용가능해요.\n로그인하시겠어요?', 7 | }, 8 | NOT_OWNERSHIP: { 9 | label: '내 주식 추가', 10 | message: '이 주식을 소유하시겠어요?', 11 | }, 12 | OWNERSHIP: { 13 | label: '내 주식 삭제', 14 | message: '이 주식 소유를 취소하시겠어요?', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /packages/backend/src/stock/domain/kospiStock.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; 2 | import { Stock } from './stock.entity'; 3 | 4 | @Entity() 5 | export class KospiStock { 6 | @PrimaryColumn({ name: 'stock_id' }) 7 | id: string; 8 | 9 | @Column({ name: 'is_kospi' }) 10 | isKospi: boolean; 11 | 12 | @OneToOne(() => Stock, (stock) => stock.id) 13 | @JoinColumn({ name: 'stock_id' }) 14 | stock: Stock; 15 | } 16 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/down-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/frontend/src/styles/theme/darkTheme.ts: -------------------------------------------------------------------------------- 1 | import { ChartTheme } from '.'; 2 | 3 | export const darkTheme: ChartTheme = { 4 | background: '#1a1a1a', 5 | textColor: '#ffffffe6', 6 | gridLines: '#334158', 7 | borderColor: '#485c7b', 8 | candlestick: { 9 | upColor: '#ff4d4d', 10 | downColor: '#1a75ff', 11 | borderUpColor: '#ff4d4d', 12 | borderDownColor: '#1a75ff', 13 | wickUpColor: '#737375', 14 | wickDownColor: '#737375', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /packages/frontend/src/styles/theme/lightTheme.ts: -------------------------------------------------------------------------------- 1 | import { ChartTheme } from '.'; 2 | 3 | export const lightTheme: ChartTheme = { 4 | background: '#ffffff', 5 | textColor: '#000000e6', 6 | gridLines: '#e0e3eb', 7 | borderColor: '#d6dcde', 8 | candlestick: { 9 | upColor: '#ff4d4d', 10 | downColor: '#1a75ff', 11 | borderUpColor: '#ff4d4d', 12 | borderDownColor: '#1a75ff', 13 | wickUpColor: '#737375', 14 | wickDownColor: '#737375', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import { Sidebar } from './Sidebar'; 3 | 4 | export const Layout = () => { 5 | return ( 6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/frontend/src/styles/theme/types.ts: -------------------------------------------------------------------------------- 1 | import type { DeepPartial, ChartOptions } from 'lightweight-charts'; 2 | 3 | export interface ChartTheme extends DeepPartial { 4 | background: string; 5 | textColor: string; 6 | gridLines: string; 7 | borderColor: string; 8 | candlestick: { 9 | upColor: string; 10 | downColor: string; 11 | borderUpColor: string; 12 | borderDownColor: string; 13 | wickUpColor: string; 14 | wickDownColor: string; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /packages/backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UserController } from './user.controller'; 4 | import { User } from '@/user/domain/user.entity'; 5 | import { UserService } from '@/user/user.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([User])], 9 | providers: [UserService], 10 | exports: [UserService], 11 | controllers: [UserController], 12 | }) 13 | export class UserModule {} 14 | -------------------------------------------------------------------------------- /packages/backend/src/stock/dto/userStock.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class UserStockRequest { 5 | @ApiProperty({ 6 | example: '005930', 7 | description: '주식 종목 id', 8 | }) 9 | @IsString() 10 | stockId: string; 11 | } 12 | 13 | export class UserStockDeleteRequest { 14 | @ApiProperty({ 15 | example: '005390', 16 | description: '종목 id', 17 | }) 18 | @IsString() 19 | stockId: string; 20 | } 21 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/dto/subscribe.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class SubscriptionData { 4 | @ApiProperty({ 5 | type: 'string', 6 | description: '엔드 포인트 설정', 7 | }) 8 | endpoint: string; 9 | 10 | @ApiProperty({ 11 | type: 'object', 12 | description: 'VAPID 키', 13 | properties: { 14 | p256dh: { type: 'string' }, 15 | auth: { type: 'string' }, 16 | }, 17 | }) 18 | keys: { 19 | p256dh: string; 20 | auth: string; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /packages/backend/src/configs/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export function useSwagger(app: INestApplication) { 5 | const config = new DocumentBuilder() 6 | .setTitle('juchumjuchum API') 7 | .setDescription('주춤주춤 API 문서입니다.') 8 | .setVersion('0.01') 9 | .build(); 10 | 11 | const documentFactory = () => SwaggerModule.createDocument(app, config); 12 | SwaggerModule.setup('api', app, documentFactory); 13 | } 14 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/useGetUserInfo.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@tanstack/react-query'; 2 | import { GetUserInfoSchema, type GetUserInfo } from './schema'; 3 | import { get } from '@/apis/utils/get'; 4 | 5 | const getUserInfo = () => 6 | get({ 7 | schema: GetUserInfoSchema, 8 | url: '/api/user/info', 9 | }); 10 | 11 | export const useGetUserInfo = () => { 12 | return useSuspenseQuery({ 13 | queryKey: ['userInfo'], 14 | queryFn: getUserInfo, 15 | staleTime: 1000 * 60 * 5, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/backend/src/auth/google/guard/google.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class GoogleAuthGuard extends AuthGuard('google') { 6 | constructor() { 7 | super(); 8 | } 9 | async canActivate(context: ExecutionContext) { 10 | const isActivate = (await super.canActivate(context)) as boolean; 11 | const request = context.switchToHttp().getRequest(); 12 | await super.logIn(request); 13 | return isActivate; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/backend/src/auth/tester/guard/tester.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class TestAuthGuard extends AuthGuard('local') { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | async canActivate(context: ExecutionContext) { 11 | const isActivate = (await super.canActivate(context)) as boolean; 12 | const request = context.switchToHttp().getRequest(); 13 | await super.logIn(request); 14 | return isActivate; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/auth/useGetLoginStatus.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { GetLoginStatusSchema, type GetLoginStatus } from './schema'; 3 | import { get } from '@/apis/utils/get'; 4 | 5 | const getLoginStatus = () => 6 | get({ 7 | schema: GetLoginStatusSchema, 8 | url: '/api/auth/status', 9 | }); 10 | 11 | export const useGetLoginStatus = () => { 12 | return useQuery({ 13 | queryKey: ['loginStatus'], 14 | queryFn: getLoginStatus, 15 | staleTime: 1000 * 60 * 3, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | parserPreset: { 4 | parserOpts: { 5 | headerPattern: '^(?.+):\\s(?.+)$', 6 | headerCorrespondence: ['type', 'subject'], 7 | }, 8 | }, 9 | rules: { 10 | 'type-enum': [ 11 | 2, 12 | 'always', 13 | [ 14 | '🚚 chore', 15 | '📦️ ci', 16 | '📝 docs', 17 | '✨ feat', 18 | '🐛 fix', 19 | '♻️ refactor', 20 | '💄 style', 21 | '✅ test', 22 | ], 23 | ], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/useGetUserTheme.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { UserThemeSchema, type GetUserTheme } from './schema'; 3 | import { get } from '@/apis/utils/get'; 4 | 5 | const getUserTheme = () => 6 | get({ 7 | schema: UserThemeSchema, 8 | url: '/api/user/theme', 9 | }); 10 | 11 | export const useGetUserTheme = () => { 12 | return useQuery({ 13 | queryKey: ['userTheme'], 14 | queryFn: getUserTheme, 15 | staleTime: 1000 * 60 * 30, 16 | select: (data) => data.theme, 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/backend/src/common/decorator/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createParamDecorator, 3 | ExecutionContext, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { Request } from 'express'; 7 | import { User } from '@/user/domain/user.entity'; 8 | 9 | export const GetUser = createParamDecorator( 10 | (data: unknown, ctx: ExecutionContext): User => { 11 | const request: Request = ctx.switchToHttp().getRequest(); 12 | if (!request.user) { 13 | throw new UnauthorizedException('Unauthorized'); 14 | } 15 | return request.user as User; 16 | }, 17 | ); 18 | -------------------------------------------------------------------------------- /packages/backend/src/stock/cache/stockData.cache.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { LocalCache } from '@/common/cache/localCache'; 3 | import { StockDataResponse } from '@/stock/dto/stockData.response'; 4 | 5 | @Injectable() 6 | export class StockDataCache { 7 | private readonly localCache = new LocalCache(); 8 | 9 | set(key: string, value: StockDataResponse, ttl: number = 60000) { 10 | this.localCache.set(key, value, ttl); 11 | } 12 | 13 | get(key: string): StockDataResponse | null { 14 | return this.localCache.get(key); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/errorSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const ErrorResponseSchema = z.object({ 4 | message: z.string(), 5 | error: z.string(), 6 | statusCode: z.number(), 7 | }); 8 | 9 | export const AxiosErrorSchema = z.object({ 10 | response: z.object({ 11 | data: ErrorResponseSchema, 12 | status: z.number(), 13 | statusText: z.string(), 14 | }), 15 | request: z.any().optional(), 16 | message: z.string(), 17 | }); 18 | 19 | export type ErrorResponse = z.infer; 20 | export type AxiosError = z.infer; 21 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/metricItem.ts: -------------------------------------------------------------------------------- 1 | export type MetricItemValue = { 2 | id: number; 3 | label: string; 4 | }; 5 | 6 | type MetricItem = { 7 | trading_volume: MetricItemValue[]; 8 | enterprise_value: MetricItemValue[]; 9 | }; 10 | 11 | export const METRIC_ITEM: MetricItem = { 12 | trading_volume: [ 13 | { id: 0, label: '현재가' }, 14 | { id: 1, label: '52주 최고가' }, 15 | { id: 2, label: '변동률' }, 16 | { id: 3, label: '52주 최저가' }, 17 | ], 18 | enterprise_value: [ 19 | { id: 0, label: '시가총액' }, 20 | { id: 1, label: 'EPS' }, 21 | { id: 2, label: 'PER' }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/useGetUserStock.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@tanstack/react-query'; 2 | import { 3 | GetUserStockResponseSchema, 4 | type GetUserStockResponse, 5 | } from './schema'; 6 | import { get } from '@/apis/utils/get'; 7 | 8 | const getUserStock = () => 9 | get({ 10 | schema: GetUserStockResponseSchema, 11 | url: '/api/stock/user', 12 | }); 13 | 14 | export const useGetUserStock = () => { 15 | return useSuspenseQuery({ 16 | queryKey: ['userStock'], 17 | queryFn: getUserStock, 18 | staleTime: 1000 * 60 * 3, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/backend/src/utils/date.ts: -------------------------------------------------------------------------------- 1 | export function getFormattedDate(date: Date): string { 2 | return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( 3 | 2, 4 | '0', 5 | )}-${String(date.getDate()).padStart(2, '0')}`; 6 | } 7 | 8 | export function isTodayWeekend() { 9 | const today = new Date(); 10 | const day = today.getDay(); 11 | return day === 0 || day === 6; 12 | } 13 | 14 | export function getToday() { 15 | const now = new Date(); 16 | const year = now.getFullYear(); 17 | const month = now.getMonth(); 18 | const day = now.getDate(); 19 | return new Date(year, month, day); 20 | } 21 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/stock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/alarm/useGetAlarm.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { type AlarmResponse, AlarmResponseSchema } from './schema'; 3 | import { get } from '@/apis/utils/get'; 4 | 5 | const getAlarm = () => 6 | get({ 7 | schema: AlarmResponseSchema, 8 | url: '/api/alarm/user', 9 | }); 10 | 11 | export const useGetAlarm = ({ isLoggedIn }: { isLoggedIn: boolean }) => { 12 | return useQuery({ 13 | queryKey: ['getAlarm'], 14 | queryFn: getAlarm, 15 | enabled: isLoggedIn, 16 | staleTime: 1000 * 60 * 5, 17 | select: (data) => data.reverse(), 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/type/openapiUtil.type.ts: -------------------------------------------------------------------------------- 1 | export type TR_ID = 2 | | 'FHKST03010100' 3 | | 'FHKST03010200' 4 | | 'FHKST66430300' 5 | | 'HHKDB669107C0' 6 | | 'FHPST01700000' 7 | | 'FHKST01010100' 8 | | 'FHPUP02100000' 9 | | 'FHKST03030100' 10 | | 'CTPF1002R'; 11 | 12 | export const TR_IDS: Record = { 13 | ITEM_CHART_PRICE: 'FHKST03010100', 14 | MINUTE_DATA: 'FHKST03010200', 15 | FINANCIAL_DATA: 'FHKST66430300', 16 | PRODUCTION_DETAIL: 'CTPF1002R', 17 | LIVE_DATA: 'FHKST01010100', 18 | INDEX_DATA: 'FHPUP02100000', 19 | RATE_DATA: 'FHKST03030100', 20 | FLUCTUATION_DATA: 'FHPST01700000', 21 | }; 22 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/input/Input.tsx: -------------------------------------------------------------------------------- 1 | import { type InputHTMLAttributes } from 'react'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | interface InputProps extends InputHTMLAttributes { 5 | className?: string; 6 | } 7 | 8 | export const Input = ({ 9 | placeholder, 10 | className, 11 | onChange, 12 | ...props 13 | }: InputProps) => { 14 | return ( 15 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/utils/delete.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { z } from 'zod'; 3 | import { instance } from '../config'; 4 | import { formatZodError } from './formatZodError'; 5 | 6 | interface DeleteParams { 7 | data: any; 8 | schema: z.ZodType; 9 | url: string; 10 | } 11 | 12 | export const deleteRequest = async ({ 13 | data, 14 | schema, 15 | url, 16 | }: DeleteParams): Promise => { 17 | const response = await instance.delete(url, { data }); 18 | const result = schema.safeParse(response.data); 19 | 20 | if (!result.success) { 21 | throw new Error(formatZodError(result.error)); 22 | } 23 | 24 | return data; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/config/openapi.config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | export const openApiConfig: { 6 | STOCK_URL: string | undefined; 7 | STOCK_ACCOUNT: string | undefined; 8 | STOCK_API_KEY: string | undefined; 9 | STOCK_API_PASSWORD: string | undefined; 10 | STOCK_API_TOKEN?: string; 11 | STOCK_WEBSOCKET_KEY?: string; 12 | STOCK_API_TIMEOUT?: Date; 13 | STOCK_WEBSOCKET_TIMEOUT?: Date; 14 | } = { 15 | STOCK_URL: process.env.STOCK_URL, 16 | STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, 17 | STOCK_API_KEY: process.env.STOCK_API_KEY, 18 | STOCK_API_PASSWORD: process.env.STOCK_API_PASSWORD, 19 | }; 20 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/auth/usePostLogout.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { PostLogout, PostLogoutSchema } from './schema'; 3 | import { post } from '@/apis/utils/post'; 4 | 5 | const postLogout = () => 6 | post({ 7 | schema: PostLogoutSchema, 8 | url: '/api/auth/logout', 9 | }); 10 | 11 | export const usePostLogout = () => { 12 | const queryClient = useQueryClient(); 13 | 14 | return useMutation({ 15 | mutationKey: ['logout'], 16 | mutationFn: postLogout, 17 | onSuccess: () => 18 | queryClient.invalidateQueries({ queryKey: ['loginStatus'] }), 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/domain/subscription.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { User } from '@/user/domain/user.entity'; 9 | 10 | @Entity() 11 | export class PushSubscription { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @ManyToOne(() => User, (user) => user.subscriptions, { onDelete: 'CASCADE' }) 16 | @JoinColumn({ name: 'user_id' }) 17 | user: User; 18 | 19 | @Column({ type: 'text' }) 20 | endpoint: string; 21 | 22 | @Column({ type: 'text' }) 23 | p256dh: string; 24 | 25 | @Column({ type: 'text' }) 26 | auth: string; 27 | } 28 | -------------------------------------------------------------------------------- /packages/backend/src/auth/tester/strategy/tester.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { TesterAuthService } from '@/auth/tester/testerAuth.service'; 5 | 6 | @Injectable() 7 | export class TesterStrategy extends PassportStrategy(Strategy) { 8 | constructor(private readonly testerAuthService: TesterAuthService) { 9 | super(); 10 | } 11 | 12 | async validate(username: string, password: string, done: CallableFunction) { 13 | const user = await this.testerAuthService.attemptAuthentication(); 14 | done(null, user); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/utils/get.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { z } from 'zod'; 3 | import { instance } from '../config'; 4 | import { formatZodError } from './formatZodError'; 5 | 6 | interface GetParams { 7 | params?: AxiosRequestConfig['params']; 8 | schema: z.ZodType; 9 | url: string; 10 | } 11 | 12 | export const get = async ({ 13 | params, 14 | schema, 15 | url, 16 | }: GetParams): Promise => { 17 | const { data } = await instance.get(url, { params }); 18 | const result = schema.safeParse(data); 19 | 20 | if (!result.success) { 21 | throw new Error(formatZodError(result.error)); 22 | } 23 | 24 | return data; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/utils/post.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { z } from 'zod'; 3 | import { instance } from '../config'; 4 | import { formatZodError } from './formatZodError'; 5 | 6 | interface PostParams { 7 | params?: AxiosRequestConfig['params']; 8 | schema: z.ZodType; 9 | url: string; 10 | } 11 | 12 | export const post = async ({ 13 | params, 14 | schema, 15 | url, 16 | }: PostParams): Promise => { 17 | const { data } = await instance.post(url, params); 18 | const result = schema.safeParse(data); 19 | 20 | if (!result.success) { 21 | throw new Error(formatZodError(result.error)); 22 | } 23 | 24 | return data; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/backend/src/stock/domain/FluctuationRankStock.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | import { Stock } from '@/stock/domain/stock.entity'; 10 | 11 | @Entity() 12 | export class FluctuationRankStock { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @ManyToOne(() => Stock, (stock) => stock.id) 17 | @JoinColumn({ name: 'stock_id' }) 18 | stock: Stock; 19 | 20 | @Column() 21 | isRising: boolean; 22 | 23 | @Column() 24 | rank: number; 25 | 26 | @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) 27 | createdAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/utils/patch.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { z } from 'zod'; 3 | import { instance } from '../config'; 4 | import { formatZodError } from './formatZodError'; 5 | 6 | interface PatchParams { 7 | params?: AxiosRequestConfig['params']; 8 | schema: z.ZodType; 9 | url: string; 10 | } 11 | 12 | export const patch = async ({ 13 | params, 14 | schema, 15 | url, 16 | }: PatchParams): Promise => { 17 | const { data } = await instance.patch(url, params); 18 | const result = schema.safeParse(data); 19 | 20 | if (!result.success) { 21 | throw new Error(formatZodError(result.error)); 22 | } 23 | 24 | return data; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/alarm/usePostInitAlarm.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | import { 3 | type PostInitAlarmResponse, 4 | PostInitAlarmResponseSchema, 5 | } from './schema'; 6 | import { post } from '@/apis/utils/post'; 7 | 8 | const postInitAlarm = (subscription: PushSubscription) => 9 | post({ 10 | params: subscription, 11 | schema: PostInitAlarmResponseSchema, 12 | url: '/api/push/subscribe', 13 | }); 14 | 15 | export const usePostInitAlarm = () => { 16 | return useMutation({ 17 | mutationKey: ['initAlarm'], 18 | mutationFn: (subscription: PushSubscription) => postInitAlarm(subscription), 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/auth/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const GetLoginStatusSchema = z.object({ 4 | message: z.enum(['Authenticated', 'Not Authenticated']), 5 | nickname: z.string().nullish(), 6 | subName: z.string().nullish(), 7 | }); 8 | 9 | export type GetLoginStatus = z.infer; 10 | 11 | export const GetTestLoginSchema = z.object({ 12 | password: z.string(), 13 | username: z.string(), 14 | }); 15 | 16 | export type GetTestLogin = z.infer; 17 | 18 | export const PostLogoutSchema = z.object({ 19 | message: z.string(), 20 | }); 21 | 22 | export type PostLogout = z.infer; 23 | -------------------------------------------------------------------------------- /packages/backend/src/stock/domain/userStock.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | Index, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { DateEmbedded } from '@/common/dateEmbedded.entity'; 9 | import { Stock } from '@/stock/domain/stock.entity'; 10 | import { User } from '@/user/domain/user.entity'; 11 | 12 | @Index('user_stock', ['user', 'stock'], { unique: true }) 13 | @Entity() 14 | export class UserStock { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @ManyToOne(() => User) 19 | user: User; 20 | 21 | @ManyToOne(() => Stock) 22 | stock: Stock; 23 | 24 | @Column(() => DateEmbedded, { prefix: '' }) 25 | date: DateEmbedded; 26 | } 27 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/dto/alarm.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AlarmRequest { 4 | @ApiProperty({ 5 | description: '주식 아이디', 6 | example: '005930', 7 | }) 8 | stockId: string; 9 | 10 | @ApiProperty({ 11 | description: '목표 가격', 12 | example: 100000, 13 | required: false, 14 | }) 15 | targetPrice?: number; 16 | 17 | @ApiProperty({ 18 | description: '목표 거래량', 19 | example: 1000000000, 20 | required: false, 21 | }) 22 | targetVolume?: number; 23 | 24 | @ApiProperty({ 25 | description: '알림 종료 날짜', 26 | example: '2026-12-01T00:00:00Z', 27 | required: false, 28 | }) 29 | alarmExpiredDate?: Date; 30 | } 31 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/login/loginProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | import { LoginContext } from './loginContext'; 3 | import { useGetLoginStatus } from '@/apis/queries/auth'; 4 | 5 | interface LoginProviderProps { 6 | children: ReactNode; 7 | } 8 | 9 | export const LoginProvider = ({ children }: LoginProviderProps) => { 10 | const { data: loginStatus } = useGetLoginStatus(); 11 | 12 | if (!loginStatus) return; 13 | const { message, nickname, subName } = loginStatus; 14 | 15 | return ( 16 | 19 | {children} 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/constants/query.ts: -------------------------------------------------------------------------------- 1 | const BASE_QUERY = { 2 | fid_cond_mrkt_div_code: 'J', 3 | fid_cond_scr_div_code: '20170', 4 | fid_input_iscd: '0000', 5 | fid_input_cnt_1: '0', 6 | fid_input_price_1: '', 7 | fid_input_price_2: '', 8 | fid_vol_cnt: '', 9 | fid_trgt_cls_code: '0', 10 | fid_trgt_exls_cls_code: '0', 11 | fid_div_cls_code: '0', 12 | fid_rsfl_rate1: '', 13 | fid_rsfl_rate2: '', 14 | }; 15 | 16 | export const DECREASE_STOCK_QUERY = { 17 | ...BASE_QUERY, 18 | fid_rank_sort_cls_code: '1', 19 | fid_prc_cls_code: '1', 20 | }; 21 | 22 | export const INCREASE_STOCK_QUERY = { 23 | ...BASE_QUERY, 24 | fid_rank_sort_cls_code: '0', 25 | fid_prc_cls_code: '1', 26 | }; 27 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/auth/useGetTestLogin.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { GetTestLoginSchema, type GetTestLogin } from './schema'; 3 | import { get } from '@/apis/utils/get'; 4 | 5 | const getTestLogin = ({ password, username }: GetTestLogin) => 6 | get({ 7 | schema: GetTestLoginSchema, 8 | url: '/api/auth/tester/login', 9 | params: { 10 | password, 11 | username, 12 | }, 13 | }); 14 | 15 | export const useGetTestLogin = ({ password, username }: GetTestLogin) => { 16 | return useQuery({ 17 | queryKey: ['testLogin', password, username], 18 | queryFn: () => getTestLogin({ password, username }), 19 | enabled: false, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/frontend/src/sockets/config.ts: -------------------------------------------------------------------------------- 1 | import { io } from 'socket.io-client'; 2 | 3 | const URL = 'wss://juchum.info'; 4 | 5 | export interface SocketChatType { 6 | stockId: string; 7 | pageSize?: number; 8 | } 9 | 10 | export const socketChat = ({ stockId, pageSize = 20 }: SocketChatType) => { 11 | return io(`${URL}/api/chat/realtime`, { 12 | transports: ['websocket'], 13 | reconnectionDelayMax: 10000, 14 | query: { 15 | stockId, 16 | pageSize, 17 | }, 18 | forceNew: true, 19 | autoConnect: false, 20 | }); 21 | }; 22 | 23 | export const socketStock = io(`${URL}/api/stock/realtime`, { 24 | transports: ['websocket'], 25 | reconnectionDelayMax: 10000, 26 | autoConnect: false, 27 | }); 28 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/useGetStockDetail.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@tanstack/react-query'; 2 | import { 3 | GetStockResponseSchema, 4 | type GetStockRequest, 5 | type GetStockResponse, 6 | } from './schema'; 7 | import { get } from '@/apis/utils/get'; 8 | 9 | const getStockDetail = ({ stockId }: GetStockRequest) => 10 | get({ 11 | schema: GetStockResponseSchema, 12 | url: `/api/stock/${stockId}/detail`, 13 | }); 14 | 15 | export const useGetStockDetail = ({ stockId }: GetStockRequest) => { 16 | return useSuspenseQuery({ 17 | queryKey: ['stockDetail', stockId], 18 | queryFn: () => getStockDetail({ stockId }), 19 | staleTime: 1000 * 60 * 5, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/usePostStockUser.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | import { 3 | PostStockResponseSchema, 4 | type PostStockRequest, 5 | type PostStockResponse, 6 | } from './schema'; 7 | import { post } from '@/apis/utils/post'; 8 | 9 | const postStockUser = ({ stockId }: PostStockRequest) => 10 | post({ 11 | params: { stockId }, 12 | schema: PostStockResponseSchema, 13 | url: '/api/stock/user', 14 | }); 15 | 16 | export const usePostStockUser = ({ ...options }) => { 17 | return useMutation({ 18 | mutationKey: ['addStock'], 19 | mutationFn: ({ stockId }: PostStockRequest) => postStockUser({ stockId }), 20 | ...options, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '@/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/components/MetricItem.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from '@/components/ui/tooltip'; 2 | 3 | export interface MetricItemProps { 4 | label: string; 5 | tooltip: string; 6 | value?: string | number; 7 | } 8 | 9 | export const MetricItem = ({ label, tooltip, value }: MetricItemProps) => { 10 | return ( 11 |
12 |
13 | {tooltip} 14 | 15 | {label} 16 | 17 |
18 | {value} 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "skipLibCheck": true, 17 | "strictNullChecks": true, 18 | "noImplicitAny": true, 19 | "strictBindCallApply": true, 20 | "noFallthroughCasesInSwitch": false, 21 | "strictPropertyInitialization": false, 22 | "paths": { 23 | "@/*": ["*"] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/domain/openapiToken.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryColumn } from 'typeorm'; 2 | 3 | @Entity({ name: 'openapi_token' }) 4 | export class OpenapiToken { 5 | @PrimaryColumn({ name: 'account' }) 6 | account: string; 7 | 8 | @Column({ name: 'apiUrl' }) 9 | api_url: string; 10 | 11 | @Column({ name: 'key' }) 12 | api_key: string; 13 | 14 | @Column({ name: 'password' }) 15 | api_password: string; 16 | 17 | @Column({ name: 'token', length: 512 }) 18 | api_token?: string; 19 | 20 | @Column({ name: 'tokenExpire' }) 21 | api_token_expire?: Date; 22 | 23 | @Column({ name: 'websocketKey' }) 24 | websocket_key?: string; 25 | 26 | @Column({ name: 'websocketKeyExpire' }) 27 | websocket_key_expire?: Date; 28 | } 29 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/config/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { ErrorResponse } from '@/apis/queries/errorSchema'; 3 | 4 | export const instance = axios.create({ 5 | baseURL: import.meta.env.VITE_BASE_URL, 6 | timeout: 1000, 7 | withCredentials: true, 8 | }); 9 | 10 | instance.interceptors.response.use( 11 | (response) => response, 12 | async (error: AxiosError) => { 13 | const status = error.response?.status; 14 | const { message } = error.response?.data as ErrorResponse; 15 | 16 | if (status === 400) { 17 | alert(message); 18 | } 19 | 20 | if (status === 403) { 21 | alert('로그인 후 이용 가능해요.'); 22 | location.href = '/login'; 23 | } 24 | return Promise.reject(error); 25 | }, 26 | ); 27 | -------------------------------------------------------------------------------- /packages/backend/src/chat/domain/mention.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Entity, 4 | Index, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | import { Chat } from '@/chat/domain/chat.entity'; 10 | import { User } from '@/user/domain/user.entity'; 11 | 12 | @Index('chat_user_unique', ['chat', 'user']) 13 | @Entity() 14 | export class Mention { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @ManyToOne(() => Chat, (chat) => chat.id) 19 | @JoinColumn({ name: 'chat_id' }) 20 | chat: Chat; 21 | 22 | @ManyToOne(() => User, (user) => user.id) 23 | @JoinColumn({ name: 'user_id' }) 24 | user: User; 25 | 26 | @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) 27 | createdAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["vite-plugin-svgr/client"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts", "svg.d.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /packages/backend/src/chat/domain/like.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | Entity, 4 | Index, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | import { Chat } from '@/chat/domain/chat.entity'; 10 | import { User } from '@/user/domain/user.entity'; 11 | 12 | @Index('chat_user_unique', ['chat', 'user'], { unique: true }) 13 | @Entity() 14 | export class Like { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @ManyToOne(() => Chat, (chat) => chat.id) 19 | @JoinColumn({ name: 'chat_id' }) 20 | chat: Chat; 21 | 22 | @ManyToOne(() => User, (user) => user.id) 23 | @JoinColumn({ name: 'user_id' }) 24 | user: User; 25 | 26 | @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) 27 | createdAt: Date; 28 | } 29 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const useOutsideClick = (callback: () => void) => { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | const handleClickOutside = (event: MouseEvent | TouchEvent) => { 8 | if (!ref.current?.contains(event.target as Node)) { 9 | callback(); 10 | } 11 | }; 12 | 13 | document.addEventListener('mouseup', handleClickOutside); 14 | document.addEventListener('touchend', handleClickOutside); 15 | 16 | return () => { 17 | document.removeEventListener('mouseup', handleClickOutside); 18 | document.removeEventListener('touchend', handleClickOutside); 19 | }; 20 | }, [callback]); 21 | 22 | return ref; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stocks/useGetSearchStocks.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { 3 | SearchResultsResponseSchema, 4 | type SearchResultsResponse, 5 | } from './schema'; 6 | import { get } from '@/apis/utils/get'; 7 | 8 | const getSearchStocks = (name: string) => 9 | get({ 10 | schema: SearchResultsResponseSchema, 11 | url: `/api/stock`, 12 | params: { name }, 13 | }); 14 | 15 | export const useGetSearchStocks = (name: string) => { 16 | return useQuery({ 17 | queryKey: ['stockSearch', name], 18 | queryFn: async () => { 19 | await new Promise((resolve) => setTimeout(resolve, 500)); 20 | return getSearchStocks(name); 21 | }, 22 | retry: 0, 23 | enabled: false, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/alarm.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { AlarmController } from './alarm.controller'; 4 | import { AlarmService } from './alarm.service'; 5 | import { AlarmSubscriber } from './alarm.subscriber'; 6 | import { Alarm } from './domain/alarm.entity'; 7 | import { PushSubscription } from './domain/subscription.entity'; 8 | import { PushController } from './push.controller'; 9 | import { PushService } from './push.service'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([Alarm, PushSubscription])], 13 | controllers: [AlarmController, PushController], 14 | providers: [AlarmService, PushService, AlarmSubscriber], 15 | exports: [AlarmService], 16 | }) 17 | export class AlarmModule {} 18 | -------------------------------------------------------------------------------- /packages/backend/src/user/dto/user.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; 3 | import { Theme } from '@/user/domain/theme'; 4 | 5 | export class ChangeNicknameRequest { 6 | @ApiProperty({ 7 | description: '변경할 닉네임', 8 | example: '9만전자 개미', 9 | }) 10 | @IsString() 11 | @IsNotEmpty() 12 | nickname: string; 13 | } 14 | 15 | export class ChangeThemeRequest { 16 | @ApiProperty({ 17 | description: '변경을 원하는 테마', 18 | example: 'light', 19 | enum: ['light', 'dark'], 20 | }) 21 | @IsNotEmpty() 22 | @IsEnum(Theme) 23 | theme: Theme; 24 | } 25 | 26 | export class UserThemeResponse { 27 | @ApiProperty({ 28 | description: '유저 테마', 29 | example: 'light', 30 | }) 31 | theme: Theme; 32 | } 33 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/theme.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/decorator/wrong.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common'; 2 | import { ApiBadRequestResponse } from '@nestjs/swagger'; 3 | import SessionGuard from '@/auth/session/session.guard'; 4 | 5 | export const WrongAlarmApi = () => { 6 | return applyDecorators( 7 | ApiBadRequestResponse({ 8 | description: '유효하지 않은 알람 입력값으로 인해 예외가 발생했습니다.', 9 | schema: { 10 | type: 'object', 11 | properties: { 12 | statusCode: { type: 'number', example: 400 }, 13 | message: { 14 | type: 'string', 15 | example: '알람 조건을 다시 확인해주세요.', 16 | }, 17 | error: { type: 'string', example: 'Bad Request' }, 18 | }, 19 | }, 20 | }), 21 | UseGuards(SessionGuard), 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/useGetStockOwnership.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { 3 | GetStockOwnershipResponseSchema, 4 | type GetStockRequest, 5 | type GetStockOwnershipResponse, 6 | } from './schema'; 7 | import { get } from '@/apis/utils/get'; 8 | 9 | const getOwnership = ({ stockId }: GetStockRequest) => 10 | get({ 11 | schema: GetStockOwnershipResponseSchema, 12 | url: `/api/stock/user/ownership`, 13 | params: { stockId }, 14 | }); 15 | 16 | export const useGetOwnership = ({ stockId }: GetStockRequest) => { 17 | return useQuery({ 18 | queryKey: ['stockOwnership', stockId], 19 | queryFn: () => getOwnership({ stockId }), 20 | enabled: !!stockId, 21 | staleTime: 1000 * 60 * 5, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/alarm/Alarm.tsx: -------------------------------------------------------------------------------- 1 | import Date from '@/assets/date.svg?react'; 2 | import Flag from '@/assets/flag.svg?react'; 3 | 4 | export interface AlarmProps { 5 | option: string; 6 | goalPrice: number; 7 | alarmDate: string | null; 8 | } 9 | 10 | export const Alarm = ({ option, goalPrice, alarmDate }: AlarmProps) => { 11 | return ( 12 |
13 | 14 | 15 | {option}: {goalPrice?.toLocaleString()}원 16 | 17 | 18 | 19 | 기한: {alarmDate ? alarmDate.slice(0, 10) : '무기한'} 20 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stocks/useGetStocksByPrice.ts: -------------------------------------------------------------------------------- 1 | import { keepPreviousData, useQuery } from '@tanstack/react-query'; 2 | import { 3 | GetStockListResponseSchema, 4 | type GetStockListRequest, 5 | type GetStockListResponse, 6 | } from './schema'; 7 | import { get } from '@/apis/utils/get'; 8 | 9 | const getStockByPrice = ({ limit, type }: GetStockListRequest) => 10 | get({ 11 | schema: GetStockListResponseSchema, 12 | url: `/api/stock/fluctuation`, 13 | params: { limit, type }, 14 | }); 15 | 16 | export const useGetStocksByPrice = ({ limit, type }: GetStockListRequest) => { 17 | return useQuery({ 18 | queryKey: ['stocks', limit, type], 19 | queryFn: () => getStockByPrice({ limit, type }), 20 | placeholderData: keepPreviousData, 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/usePostUserNickname.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { PostUserNickname, PostUserNicknameSchema } from './schema'; 3 | import { post } from '@/apis/utils/post'; 4 | 5 | const postUserNickname = ({ nickname }: { nickname: string }) => 6 | post({ 7 | params: { nickname }, 8 | schema: PostUserNicknameSchema, 9 | url: '/api/user/info', 10 | }); 11 | 12 | export const usePostUserNickname = ({ nickname }: { nickname: string }) => { 13 | const queryClient = useQueryClient(); 14 | 15 | return useMutation({ 16 | mutationKey: ['userNickname'], 17 | mutationFn: () => postUserNickname({ nickname }), 18 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ['userInfo'] }), 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/alarm/useGetStockAlarm.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { 3 | type AlarmResponse, 4 | StockAlarmRequest, 5 | AlarmResponseSchema, 6 | } from './schema'; 7 | import { get } from '@/apis/utils/get'; 8 | 9 | const getStockAlarm = ({ stockId }: StockAlarmRequest) => 10 | get({ 11 | schema: AlarmResponseSchema, 12 | url: `/api/alarm/stock/${stockId}`, 13 | }); 14 | 15 | export const useGetStockAlarm = ({ 16 | stockId, 17 | isLoggedIn, 18 | }: StockAlarmRequest & { isLoggedIn: boolean }) => { 19 | return useQuery({ 20 | queryKey: ['getStockAlarm', stockId], 21 | queryFn: () => getStockAlarm({ stockId }), 22 | enabled: isLoggedIn, 23 | staleTime: 1000 * 60 * 5, 24 | select: (data) => data.reverse(), 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/backend/src/auth/google/googleAuth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Res, UseGuards } from '@nestjs/common'; 2 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { Response } from 'express'; 4 | import { GoogleAuthGuard } from '@/auth/google/guard/google.guard'; 5 | 6 | @ApiTags('Auth') 7 | @Controller('auth/google') 8 | export class GoogleAuthController { 9 | @ApiOperation({ 10 | summary: '구글 로그인 전용 페이지 이동', 11 | description: '페이지를 이동하여 구글 로그인을 진행합니다.', 12 | }) 13 | @Get('/login') 14 | @UseGuards(GoogleAuthGuard) 15 | async handleLogin() { 16 | return { 17 | message: 'Google Authentication', 18 | }; 19 | } 20 | 21 | @Get('/redirect') 22 | @UseGuards(GoogleAuthGuard) 23 | async handleRedirect(@Res() response: Response) { 24 | response.redirect('/'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/usePatchUserTheme.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { 3 | type PatchUserTheme, 4 | type PatchUserThemeRequest, 5 | UserThemeSchema, 6 | } from './schema'; 7 | import { patch } from '@/apis/utils/patch'; 8 | 9 | const patchUserTheme = ({ theme }: PatchUserThemeRequest) => 10 | patch({ 11 | params: { theme }, 12 | schema: UserThemeSchema, 13 | url: '/api/user/theme', 14 | }); 15 | 16 | export const usePatchUserTheme = () => { 17 | const queryClient = useQueryClient(); 18 | 19 | return useMutation({ 20 | mutationKey: ['patchTheme'], 21 | mutationFn: ({ theme }: PatchUserThemeRequest) => patchUserTheme({ theme }), 22 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ['userTheme'] }), 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/chat/usePostChatLike.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { 3 | GetChatLikeResponseSchema, 4 | type GetChatLikeRequest, 5 | type GetChatLikeResponse, 6 | } from './schema'; 7 | import { post } from '@/apis/utils/post'; 8 | 9 | const postChatLike = ({ chatId }: GetChatLikeRequest) => 10 | post({ 11 | params: { chatId }, 12 | schema: GetChatLikeResponseSchema, 13 | url: '/api/chat/like', 14 | }); 15 | 16 | export const usePostChatLike = () => { 17 | const queryClient = useQueryClient(); 18 | return useMutation({ 19 | mutationKey: ['chatLike'], 20 | mutationFn: ({ chatId }: GetChatLikeRequest) => postChatLike({ chatId }), 21 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ['chatList'] }), 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/usePostStockView.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { 3 | PostStockResponseSchema, 4 | type PostStockRequest, 5 | type PostStockResponse, 6 | } from './schema'; 7 | import { post } from '@/apis/utils/post'; 8 | 9 | const postStockView = ({ stockId }: PostStockRequest) => 10 | post({ 11 | params: { stockId }, 12 | schema: PostStockResponseSchema, 13 | url: '/api/stock/view', 14 | }); 15 | 16 | export const usePostStockView = () => { 17 | const queryClient = useQueryClient(); 18 | 19 | return useMutation({ 20 | mutationKey: ['stockView'], 21 | mutationFn: ({ stockId }: PostStockRequest) => postStockView({ stockId }), 22 | onSuccess: () => queryClient.invalidateQueries({ queryKey: ['topViews'] }), 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/frontend/src/sockets/useWebsocket.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | export const useWebsocket = (socket: Socket) => { 5 | const [isConnected, setIsConnected] = useState(socket.connected); 6 | 7 | useEffect(() => { 8 | if (!socket.connected) { 9 | socket.connect(); 10 | } 11 | 12 | const onConnect = () => { 13 | setIsConnected(true); 14 | }; 15 | 16 | const onDisconnect = () => { 17 | setIsConnected(false); 18 | }; 19 | 20 | socket.on('connect', onConnect); 21 | socket.on('disconnect', onDisconnect); 22 | 23 | return () => { 24 | socket.off('connect', onConnect); 25 | socket.off('disconnect', onDisconnect); 26 | socket.disconnect(); 27 | }; 28 | }, [socket]); 29 | 30 | return { isConnected }; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/chat/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const GetChatLikeRequestSchema = z.object({ 4 | chatId: z.number(), 5 | }); 6 | 7 | export type GetChatLikeRequest = z.infer; 8 | 9 | export const GetChatLikeResponseSchema = z.object({ 10 | chatId: z.number(), 11 | stockId: z.string(), 12 | likeCount: z.number(), 13 | message: z.string(), 14 | date: z.string().datetime(), 15 | }); 16 | 17 | export type GetChatLikeResponse = z.infer; 18 | 19 | export const GetChatListRequestSchema = z.object({ 20 | stockId: z.string(), 21 | latestChatId: z.number().optional(), 22 | pageSize: z.number().optional(), 23 | order: z.enum(['latest', 'like']).optional(), 24 | }); 25 | 26 | export type GetChatListRequest = z.infer; 27 | -------------------------------------------------------------------------------- /packages/backend/src/middlewares/filter/webSocketException.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | Inject, 5 | WsExceptionFilter, 6 | } from '@nestjs/common'; 7 | import { WsException } from '@nestjs/websockets'; 8 | import { Socket } from 'socket.io'; 9 | import { Logger } from 'winston'; 10 | 11 | @Catch(WsException) 12 | export class WebSocketExceptionFilter implements WsExceptionFilter { 13 | constructor(@Inject('winston') private readonly logger: Logger) {} 14 | catch(exception: WsException, host: ArgumentsHost) { 15 | const client = host.switchToWs().getClient(); 16 | const data = host.switchToWs().getData(); 17 | const errorMessage = exception.message; 18 | client.emit('error', { 19 | message: errorMessage, 20 | data, 21 | }); 22 | this.logger.warn(`error occurred: ${errorMessage}`); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/backend/src/auth/session/session.serializer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportSerializer } from '@nestjs/passport'; 3 | import { User } from '@/user/domain/user.entity'; 4 | import { UserService } from '@/user/user.service'; 5 | 6 | @Injectable() 7 | export class SessionSerializer extends PassportSerializer { 8 | constructor(private readonly userService: UserService) { 9 | super(); 10 | } 11 | 12 | async serializeUser( 13 | user: User, 14 | done: (err: Error | null, userId: number) => void, 15 | ) { 16 | done(null, user.id); 17 | } 18 | 19 | async deserializeUser( 20 | userId: number, 21 | done: (err: Error | null, user: User | null) => void, 22 | ): Promise { 23 | const user = await this.userService.findUserById(userId); 24 | return user ? done(null, user) : done(null, null); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/backend/src/configs/typeormConfig.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 2 | import * as dotenv from 'dotenv'; 3 | 4 | dotenv.config(); 5 | 6 | export const typeormProductConfig: TypeOrmModuleOptions = { 7 | type: 'mysql', 8 | host: process.env.DB_HOST, 9 | port: Number(process.env.DB_PORT), 10 | username: process.env.DB_USER, 11 | password: process.env.DB_PASS, 12 | database: process.env.DB_NAME, 13 | entities: [__dirname + '/../**/*.entity.{js,ts}'], 14 | }; 15 | 16 | export const typeormDevelopConfig: TypeOrmModuleOptions = { 17 | type: 'mysql', 18 | host: process.env.DB_HOST, 19 | port: Number(process.env.DB_PORT), 20 | username: process.env.DB_USER, 21 | password: process.env.DB_PASS, 22 | database: process.env.DB_NAME, 23 | entities: [__dirname + '/../**/*.entity.{js,ts}'], 24 | //logging: true, 25 | synchronize: true, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | export interface TooltipProps { 5 | className?: string; 6 | children?: ReactNode; 7 | } 8 | 9 | export const Tooltip = ({ className, children }: TooltipProps) => { 10 | return ( 11 |
18 | {children} 19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/useDeleteStockUser.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, UseMutationOptions } from '@tanstack/react-query'; 2 | import { 3 | DeleteStockUserSchema, 4 | type DeleteStockUserRequest, 5 | type DeleteStockUser, 6 | } from './schema'; 7 | import { deleteRequest } from '@/apis/utils/delete'; 8 | 9 | const deleteStockUser = ({ stockId }: DeleteStockUserRequest) => 10 | deleteRequest({ 11 | schema: DeleteStockUserSchema, 12 | url: '/api/stock/user', 13 | data: { stockId }, 14 | }); 15 | 16 | export const useDeleteStockUser = ( 17 | options?: UseMutationOptions, 18 | ) => { 19 | return useMutation({ 20 | mutationKey: ['deleteStockUser'], 21 | mutationFn: ({ stockId }: DeleteStockUserRequest) => 22 | deleteStockUser({ stockId }), 23 | ...options, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/tooltip/Tooltip.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Tooltip, TooltipProps } from '.'; 3 | 4 | const TooltipWrapper = ({ children }: TooltipProps) => { 5 | return ( 6 |
7 | {children} 8 | 9 |
10 | ); 11 | }; 12 | 13 | const meta: Meta = { 14 | title: 'Example/Tooltip', 15 | component: TooltipWrapper, 16 | parameters: { 17 | layout: 'centered', 18 | }, 19 | tags: ['autodocs'], 20 | 21 | args: { children: 'Tooltip' }, 22 | }; 23 | 24 | export default meta; 25 | type Story = StoryObj; 26 | 27 | export const Primary: Story = { 28 | args: { 29 | children: 'Tooltip', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/backend/src/chat/decorator/like.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiCookieAuth, 5 | ApiOkResponse, 6 | ApiOperation, 7 | } from '@nestjs/swagger'; 8 | import { LikeResponse } from '@/chat/dto/like.response'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/naming-convention 11 | export function ToggleLikeApi() { 12 | return applyDecorators( 13 | ApiCookieAuth(), 14 | ApiOperation({ 15 | summary: '채팅 좋아요 토글 API', 16 | description: '채팅 좋아요를 토글한다.', 17 | }), 18 | ApiOkResponse({ 19 | description: '좋아요 성공', 20 | type: LikeResponse, 21 | }), 22 | ApiBadRequestResponse({ 23 | description: '채팅이 존재하지 않음', 24 | example: { 25 | message: 'Chat not found', 26 | error: 'Bad Request', 27 | statusCode: 400, 28 | }, 29 | }), 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/frontend/src/sockets/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const ChatDataSchema = z.object({ 4 | id: z.number(), 5 | likeCount: z.number(), 6 | message: z.string(), 7 | type: z.string(), 8 | createdAt: z.string().datetime(), 9 | liked: z.boolean(), 10 | nickname: z.string(), 11 | mentioned: z.boolean(), 12 | subName: z.string(), 13 | }); 14 | 15 | export type ChatData = z.infer; 16 | 17 | export const ChatDataResponseSchema = z.object({ 18 | chats: z.array(ChatDataSchema), 19 | hasMore: z.boolean(), 20 | }); 21 | 22 | export type ChatDataResponse = z.infer; 23 | 24 | export const ChatLikeSchema = z.object({ 25 | stockId: z.string(), 26 | chatId: z.number(), 27 | likeCount: z.number(), 28 | message: z.string(), 29 | date: z.string().datetime(), 30 | }); 31 | 32 | export type ChatLikeResponse = z.infer; 33 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useInfiniteScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | interface InfiniteScrollProps { 4 | onIntersect: () => void; 5 | hasNextPage: boolean; 6 | } 7 | 8 | export const useInfiniteScroll = ({ 9 | onIntersect, 10 | hasNextPage, 11 | }: InfiniteScrollProps) => { 12 | const ref = useRef(null); 13 | 14 | useEffect(() => { 15 | const observer = new IntersectionObserver( 16 | (entries) => { 17 | if (entries[0].isIntersecting && hasNextPage) { 18 | onIntersect(); 19 | } 20 | }, 21 | { threshold: 0.5 }, 22 | ); 23 | const instance = ref.current; 24 | 25 | if (instance) { 26 | observer.observe(instance); 27 | } 28 | 29 | return () => { 30 | if (instance) { 31 | observer.disconnect(); 32 | } 33 | }; 34 | }, [onIntersect, hasNextPage]); 35 | 36 | return { ref }; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/components/RadioButton.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from 'react'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | interface RadioButtonProps extends HTMLAttributes { 5 | name: string; 6 | children: string; 7 | selected?: boolean; 8 | } 9 | 10 | export const RadioButton = ({ 11 | id, 12 | name, 13 | children, 14 | selected, 15 | ...props 16 | }: RadioButtonProps) => { 17 | return ( 18 |
19 | 27 | 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 5 | darkMode: 'selector', 6 | theme: { 7 | extend: { 8 | backgroundColor: { 9 | 'black/4': 'rgba(0, 0, 0, 0.4)', 10 | 'white/4': 'rgba(255,255,255,0.4)', 11 | }, 12 | }, 13 | colors: { 14 | 'extra-light-gray': 'var(--extra-light-gray)', 15 | 'light-gray': 'var(--light-gray)', 16 | gray: 'var(--gray)', 17 | 'dark-gray': 'var(--dark-gray)', 18 | black: 'var(--black)', 19 | white: 'var(--white)', 20 | 'light-yellow': 'var(--light-yellow)', 21 | 'light-orange': 'var(--light-orange)', 22 | orange: 'var(--orange)', 23 | red: 'var(--red)', 24 | green: 'var(--green)', 25 | blue: 'var(--blue)', 26 | }, 27 | }, 28 | plugins: [], 29 | }; 30 | 31 | export default config; 32 | -------------------------------------------------------------------------------- /packages/backend/src/stock/domain/stockDetail.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | OneToOne, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | import { Stock } from './stock.entity'; 10 | 11 | @Entity('stock_detail') 12 | export class StockDetail { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @OneToOne(() => Stock) 17 | @JoinColumn({ name: 'stock_id' }) 18 | stock: Stock; 19 | 20 | @Column({ 21 | name: 'market_cap', 22 | type: 'bigint', 23 | unsigned: true, 24 | }) 25 | marketCap: string; 26 | 27 | @Column({ type: 'integer' }) 28 | eps: number; 29 | 30 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 31 | per: number; 32 | 33 | @Column({ type: 'integer' }) 34 | high52w: number; 35 | 36 | @Column({ type: 'integer' }) 37 | low52w: number; 38 | 39 | @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) 40 | updatedAt: Date; 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/close-issue.yml: -------------------------------------------------------------------------------- 1 | name: Close Related Issue on Merge 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | branches: [dev-be, dev-fe] 7 | 8 | jobs: 9 | close-issues: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Close related issue 14 | if: ${{ github.event.pull_request.merged == true }} 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | run: | 18 | ISSUE_NUMBERS=$(echo "${{ github.event.pull_request.body }}" | grep -o -E '([cC]lose #[0-9]+)' | grep -o '[0-9]\+') 19 | 20 | for ISSUE_NUMBER in $ISSUE_NUMBERS; do 21 | echo "Closing issue #$ISSUE_NUMBER" 22 | curl -X PATCH -H "Authorization: token $GITHUB_TOKEN" \ 23 | -H "Accept: application/vnd.github.v3+json" \ 24 | https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUMBER \ 25 | -d '{"state": "closed"}' 26 | done 27 | -------------------------------------------------------------------------------- /packages/frontend/src/constants/menuItems.tsx: -------------------------------------------------------------------------------- 1 | import Home from '@/assets/home.svg?react'; 2 | import Search from '@/assets/search.svg?react'; 3 | import Stock from '@/assets/stock.svg?react'; 4 | import Theme from '@/assets/theme.svg?react'; 5 | import User from '@/assets/user.svg?react'; 6 | import { type MenuSection } from '@/types/menu'; 7 | 8 | export const TOP_MENU_ITEMS: MenuSection[] = [ 9 | { id: 1, icon: , text: '검색' }, 10 | { id: 2, icon: , text: '홈', path: '/' }, 11 | { 12 | id: 3, 13 | icon: , 14 | text: '주식', 15 | path: '/stocks/005930', 16 | }, 17 | // { id: 4, icon: , text: '알림' }, 18 | ]; 19 | 20 | export const BOTTOM_MENU_ITEMS: MenuSection[] = [ 21 | { id: 1, icon: , text: '다크모드' }, 22 | { 23 | id: 2, 24 | icon: , 25 | text: '마이페이지', 26 | path: '/my-page', 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /packages/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "Bundler", 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | 26 | "composite": true 27 | }, 28 | "include": [ 29 | "src", 30 | "src/**/*.json", 31 | "tailwind.config.ts", 32 | "public/serviceWorker.js" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log*`` 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | 58 | # backup file 59 | .backup 60 | .bak 61 | -------------------------------------------------------------------------------- /packages/backend/src/chat/chat.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { ChatService } from '@/chat/chat.service'; 3 | import { createDataSourceMock } from '@/user/user.service.spec'; 4 | 5 | describe('ChatService 테스트', () => { 6 | test('첫 스크롤을 조회시 100개 이상 조회하면 예외가 발생한다.', async () => { 7 | const dataSource = createDataSourceMock({}); 8 | const chatService = new ChatService(dataSource as DataSource); 9 | 10 | await expect(() => 11 | chatService.scrollChat({ 12 | stockId: '005930', 13 | pageSize: 101, 14 | }), 15 | ).rejects.toThrow('pageSize should be less than 100'); 16 | }); 17 | 18 | test('100개 이상의 채팅을 조회하려 하면 예외가 발생한다.', async () => { 19 | const dataSource = createDataSourceMock({}); 20 | const chatService = new ChatService(dataSource as DataSource); 21 | 22 | await expect(() => 23 | chatService.scrollChat({ stockId: '005930', pageSize: 101 }), 24 | ).rejects.toThrow('pageSize should be less than 100'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/frontend/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | import { join, dirname } from 'path'; 4 | 5 | /** 6 | * This function is used to resolve the absolute path of a package. 7 | * It is needed in projects that use Yarn PnP or are set up within a monorepo. 8 | */ 9 | function getAbsolutePath(value: string) { 10 | return dirname(require.resolve(join(value, 'package.json'))); 11 | } 12 | const config: StorybookConfig = { 13 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 14 | addons: [ 15 | getAbsolutePath('@storybook/addon-onboarding'), 16 | getAbsolutePath('@storybook/addon-essentials'), 17 | getAbsolutePath('@chromatic-com/storybook'), 18 | getAbsolutePath('@storybook/addon-interactions'), 19 | ], 20 | framework: { 21 | name: getAbsolutePath('@storybook/react-vite'), 22 | options: {}, 23 | }, 24 | core: { 25 | builder: getAbsolutePath('@storybook/builder-vite'), 26 | }, 27 | }; 28 | export default config; 29 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { WinstonModule } from 'nest-winston'; 4 | import { KoreaStockInfoService } from './korea-stock-info.service'; 5 | import { logger } from '@/configs/logger.config'; 6 | import { Stock } from '@/stock/domain/stock.entity'; 7 | 8 | xdescribe('KoreaStockInfoService', () => { 9 | let service: KoreaStockInfoService; 10 | 11 | // 모듈을 사용하려면 직접 DB에 연결해야함 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | imports: [ 15 | TypeOrmModule.forFeature([Stock]), 16 | WinstonModule.forRoot(logger), 17 | ], 18 | providers: [KoreaStockInfoService], 19 | }).compile(); 20 | 21 | service = module.get(KoreaStockInfoService); 22 | }); 23 | 24 | it('should be defined', () => { 25 | expect(service).toBeDefined(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/frontend/src/components/errors/error.tsx: -------------------------------------------------------------------------------- 1 | import Lottie from 'react-lottie-player'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button } from '../ui/button'; 4 | import error from '@/components/lottie/error-loading.json'; 5 | import { cn } from '@/utils/cn'; 6 | 7 | interface ErrorProps { 8 | className?: string; 9 | } 10 | 11 | export const Error = ({ className }: ErrorProps) => { 12 | const navigate = useNavigate(); 13 | 14 | return ( 15 |
16 |
17 | 22 |

에러가 발생했어요. 주춤주춤 팀을 찾아주세요.

23 |
24 | 25 | 26 |
27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/backend/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { SessionModule } from '@/auth/session.module'; 4 | import { ChatController } from '@/chat/chat.controller'; 5 | import { ChatGateway } from '@/chat/chat.gateway'; 6 | import { ChatService } from '@/chat/chat.service'; 7 | import { Chat } from '@/chat/domain/chat.entity'; 8 | import { Like } from '@/chat/domain/like.entity'; 9 | import { Mention } from '@/chat/domain/mention.entity'; 10 | import { LikeService } from '@/chat/like.service'; 11 | import { MentionService } from '@/chat/mention.service'; 12 | import { StockModule } from '@/stock/stock.module'; 13 | import { UserModule } from '@/user/user.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | TypeOrmModule.forFeature([Chat, Like, Mention]), 18 | StockModule, 19 | SessionModule, 20 | UserModule, 21 | ], 22 | controllers: [ChatController], 23 | providers: [ChatGateway, ChatService, LikeService, MentionService], 24 | }) 25 | export class ChatModule {} 26 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/alarm/Alarm.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/utils/cn'; 2 | 3 | interface SearchProps { 4 | className?: string; 5 | } 6 | 7 | export const Alarm = ({ className }: SearchProps) => { 8 | return ( 9 |
10 |
11 |

알림

12 |

13 | 1개의 알림이 있어요. 14 |

15 |
16 |
17 |

오늘

18 |
19 |
20 |

21 | 삼성전자의 현재가는 00원이에요. 22 |

23 |
24 |
25 |
26 |

이전

27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/hooks/useChartResize.ts: -------------------------------------------------------------------------------- 1 | import { IChartApi } from 'lightweight-charts'; 2 | import { MutableRefObject, useEffect, useRef, type RefObject } from 'react'; 3 | 4 | interface UseChartResize { 5 | containerRef: RefObject; 6 | chart: MutableRefObject; 7 | } 8 | 9 | export const useChartResize = ({ containerRef, chart }: UseChartResize) => { 10 | const resizeObserver = useRef(); 11 | 12 | useEffect(() => { 13 | if (!containerRef.current || !chart.current) return; 14 | 15 | resizeObserver.current = new ResizeObserver((entries) => { 16 | const { width, height } = entries[0].contentRect; 17 | 18 | chart.current?.applyOptions({ width, height }); 19 | 20 | requestAnimationFrame(() => { 21 | chart.current?.timeScale().fitContent(); 22 | }); 23 | }); 24 | 25 | resizeObserver.current.observe(containerRef.current); 26 | 27 | return () => { 28 | resizeObserver.current?.disconnect(); 29 | }; 30 | }, [chart, containerRef]); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/backend/src/stock/domain/stockLiveData.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | OneToOne, 6 | JoinColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | import { Stock } from './stock.entity'; 10 | 11 | @Entity('stock_live_data') 12 | export class StockLiveData { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @Column({ name: 'current_price', type: 'decimal', precision: 15, scale: 2 }) 17 | currentPrice: number; 18 | 19 | @Column({ name: 'change_rate', type: 'decimal', precision: 5, scale: 2 }) 20 | changeRate: number; 21 | 22 | @Column() 23 | volume: number; 24 | 25 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 26 | high: number; 27 | 28 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 29 | low: number; 30 | 31 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 32 | open: number; 33 | 34 | @UpdateDateColumn() 35 | @Column({ type: 'timestamp' }) 36 | updatedAt: Date; 37 | 38 | @OneToOne(() => Stock) 39 | @JoinColumn({ name: 'stock_id' }) 40 | stock: Stock; 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | 58 | # vscode setting 59 | .vscode 60 | 61 | # remote 62 | .remote 63 | 64 | # .zip, .mst 65 | *.zip 66 | *.mst 67 | 68 | # remote 69 | .remote 70 | -------------------------------------------------------------------------------- /packages/backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; 4 | import { GoogleAuthService } from '@/auth/google/googleAuth.service'; 5 | import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; 6 | import { AuthController } from '@/auth/auth.controller'; 7 | import { SessionSerializer } from '@/auth/session/session.serializer'; 8 | import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; 9 | import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; 10 | import { TesterAuthService } from '@/auth/tester/testerAuth.service'; 11 | import { UserModule } from '@/user/user.module'; 12 | 13 | @Module({ 14 | imports: [UserModule, PassportModule.register({ session: true })], 15 | controllers: [GoogleAuthController, TesterAuthController, AuthController], 16 | providers: [ 17 | GoogleStrategy, 18 | GoogleAuthService, 19 | SessionSerializer, 20 | TesterAuthService, 21 | TesterStrategy, 22 | ], 23 | }) 24 | export class AuthModule {} 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "juchumjuchum", 3 | "version": "0.0.1", 4 | "description": "주춤할 때 곁에서 속삭여주는 똑똑한 투자 도우미, 주춤주춤", 5 | "private": true, 6 | "scripts": { 7 | "client": "yarn workspace frontend", 8 | "server": "yarn workspace backend", 9 | "prepare": "husky install" 10 | }, 11 | "workspaces": { 12 | "packages": [ 13 | "packages/**" 14 | ] 15 | }, 16 | "repository": "https://github.com/xjfcnfw3/setting_test_repo.git", 17 | "devDependencies": { 18 | "@commitlint/cli": "^19.5.0", 19 | "@commitlint/config-conventional": "^19.5.0", 20 | "commitizen": "^4.3.1", 21 | "cz-conventional-changelog": "^3.3.0", 22 | "cz-customizable": "^7.2.1", 23 | "eslint": "^8.57.1", 24 | "eslint-config-prettier": "^9.0.0", 25 | "eslint-plugin-import": "^2.31.0", 26 | "eslint-plugin-prettier": "^5.0.0", 27 | "husky": "^8.0.0", 28 | "prettier": "^3.3.3", 29 | "typescript": "^5.6.3" 30 | }, 31 | "config": { 32 | "commitizen": { 33 | "path": "./node_modules/cz-customizable" 34 | }, 35 | "cz-customizable": { 36 | "config": "cz-config.js" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/backend/src/chat/mention.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, EntityManager } from 'typeorm'; 3 | import { Chat } from '@/chat/domain/chat.entity'; 4 | import { Mention } from '@/chat/domain/mention.entity'; 5 | import { User } from '@/user/domain/user.entity'; 6 | 7 | @Injectable() 8 | export class MentionService { 9 | constructor(private readonly dataSource: DataSource) {} 10 | 11 | async createMention(chatId: number, userId: number) { 12 | return this.dataSource.transaction(async (manager) => { 13 | if (!(await this.existsChatAndUser(chatId, userId, manager))) { 14 | return null; 15 | } 16 | return await manager.save(Mention, { 17 | chat: { id: chatId }, 18 | user: { id: userId }, 19 | }); 20 | }); 21 | } 22 | 23 | async existsChatAndUser( 24 | chatId: number, 25 | userId: number, 26 | manager: EntityManager, 27 | ) { 28 | if (!(await manager.exists(User, { where: { id: userId } }))) { 29 | return false; 30 | } 31 | return await manager.exists(Chat, { 32 | where: { id: chatId }, 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/my-page/MyPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { AlarmInfo } from './AlarmInfo'; 4 | import { StockInfo } from './StockInfo'; 5 | import { UserInfo } from './UserInfo'; 6 | import { LoginContext } from '@/contexts/login'; 7 | 8 | export const MyPage = () => { 9 | const { isLoggedIn } = useContext(LoginContext); 10 | 11 | return ( 12 |
13 |

마이페이지

14 |
15 |
16 |
17 | {isLoggedIn ? ( 18 | 19 | ) : ( 20 | 24 | 로그인 25 | 26 | )} 27 |
28 | 29 |
30 | 31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/backend/src/configs/logger.config.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format } from 'winston'; 2 | import * as DailyRotateFile from 'winston-daily-rotate-file'; 3 | 4 | const { combine, timestamp, printf } = format; 5 | 6 | const logFormat = printf(({ level, message, timestamp }) => { 7 | return `[${timestamp}][${level}]: ${message}`; 8 | }); 9 | 10 | export const logger = createLogger({ 11 | format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), logFormat), 12 | transports: [ 13 | new DailyRotateFile({ 14 | level: 'error', 15 | datePattern: 'YYYY-MM-DD', 16 | filename: `logFile-error.log`, 17 | dirname: '../logs', 18 | maxFiles: '7d', 19 | maxSize: '10m', 20 | }), 21 | new DailyRotateFile({ 22 | level: 'info', 23 | datePattern: 'YYYY-MM-DD', 24 | filename: `logFile.log`, 25 | dirname: '../logs', 26 | maxFiles: '7d', 27 | maxSize: '10m', 28 | }), 29 | new DailyRotateFile({ 30 | level: 'warn', 31 | datePattern: 'YYYY-MM-DD', 32 | filename: `logFile-warn.log`, 33 | dirname: '../logs', 34 | maxFiles: '7d', 35 | maxSize: '10m', 36 | }), 37 | ], 38 | }); 39 | -------------------------------------------------------------------------------- /cz-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | types: [ 3 | { value: '✨ feat', name: '✨ feat:\t새로운 기능 추가' }, 4 | { value: '🐛 fix', name: '🐛 fix:\t기능, UI/UX 코드 수정' }, 5 | { value: '📝 docs', name: '📝 docs:\t문서 추가 혹은 업데이트' }, 6 | { value: '💄 style', name: '💄 style:\t코드 형식 추가 및 수정' }, 7 | { 8 | value: '♻️ refactor', 9 | name: '♻️ refactor:\t기능 추가와 버그 수정이 아닌 코드 수정', 10 | }, 11 | { value: '📦️ ci', name: '📦️ ci:\t배포 관련 코드 추가 및 수정' }, 12 | { value: '✅ test', name: '✅ test:\t테스트 코드 추가 및 수정' }, 13 | { value: '🚚 chore', name: '🚚 chore:\t빌드 관련 도구 수정' }, 14 | ], 15 | messages: { 16 | type: "Select the type of change that you're committing:", 17 | subject: 'Write a SHORT, IMPERATIVE tense description of the change:\n', 18 | body: 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n', 19 | footer: 20 | 'List any ISSUES CLOSED by this change (optional). E.g.: #31, #34:\n', 21 | confirmCommit: 'Are you sure you want to proceed with the commit above?', 22 | }, 23 | 24 | allowCustomScopes: false, 25 | allowBreakingChanges: ['feat', 'fix'], 26 | skipQuestions: ['body', 'scope'], 27 | subjectLimit: 100, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/push.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, UseGuards } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { SubscriptionData } from './dto/subscribe.request'; 4 | import { SubscribeResponse } from './dto/subscribe.response'; 5 | import { PushService } from './push.service'; 6 | import SessionGuard from '@/auth/session/session.guard'; 7 | import { GetUser } from '@/common/decorator/user.decorator'; 8 | import { User } from '@/user/domain/user.entity'; 9 | 10 | @Controller('/push') 11 | export class PushController { 12 | constructor(private readonly pushService: PushService) {} 13 | 14 | @Post('subscribe') 15 | @ApiOperation({ 16 | summary: '알림 서비스 초기 설정', 17 | description: '유저가 로그인할 때 알림을 받을 수 있게 초기설정한다.', 18 | }) 19 | @ApiResponse({ 20 | status: 201, 21 | description: '알림 초기설정', 22 | type: SubscribeResponse, 23 | }) 24 | @UseGuards(SessionGuard) 25 | async subscribe( 26 | @Body() subscriptionData: SubscriptionData, 27 | @GetUser() user: User, 28 | ) { 29 | const userId = user.id; 30 | 31 | return await this.pushService.createSubscription(userId, subscriptionData); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/backend/src/stock/dto/stockDetail.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { StockDetail } from '../domain/stockDetail.entity'; 3 | 4 | export class StockDetailResponse { 5 | @ApiProperty({ 6 | description: '주식의 시가 총액', 7 | example: 352510000000000, 8 | }) 9 | marketCap: number; 10 | 11 | @ApiProperty({ 12 | description: '주식의 이름', 13 | example: '삼성전자', 14 | }) 15 | name: string; 16 | 17 | @ApiProperty({ 18 | description: '주식의 EPS', 19 | example: 4091, 20 | }) 21 | eps: number; 22 | 23 | @ApiProperty({ 24 | description: '주식의 PER', 25 | example: 17.51, 26 | }) 27 | per: number; 28 | 29 | @ApiProperty({ 30 | description: '주식의 52주 최고가', 31 | example: 88000, 32 | }) 33 | high52w: number; 34 | 35 | @ApiProperty({ 36 | description: '주식의 52주 최저가', 37 | example: 53000, 38 | }) 39 | low52w: number; 40 | 41 | constructor(stockDetail: StockDetail) { 42 | this.eps = stockDetail.eps; 43 | this.per = stockDetail.per; 44 | this.high52w = stockDetail.high52w; 45 | this.low52w = stockDetail.low52w; 46 | this.marketCap = Number(stockDetail.marketCap); 47 | this.name = stockDetail.stock.name; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/backend/src/auth/google/googleAuth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { OauthUserInfo } from '@/auth/google/strategy/google.strategy'; 3 | import { UserService } from '@/user/user.service'; 4 | 5 | @Injectable() 6 | export class GoogleAuthService { 7 | constructor(private readonly userService: UserService) {} 8 | 9 | async attemptAuthentication(userInfo: OauthUserInfo) { 10 | const { email, givenName, familyName, oauthId, type } = userInfo; 11 | const user = await this.userService.findUserByOauthIdAndType(oauthId, type); 12 | if (user) { 13 | return user; 14 | } 15 | if (!email) { 16 | throw new UnauthorizedException('email is required'); 17 | } 18 | if (!givenName && !familyName) { 19 | throw new UnauthorizedException('name is required'); 20 | } 21 | return await this.userService.register({ 22 | type, 23 | nickname: this.createName(givenName, familyName), 24 | email: email as string, 25 | oauthId, 26 | }); 27 | } 28 | 29 | private createName(givenName?: string, familyName?: string) { 30 | return `${givenName ? `${givenName} ` : ''}${familyName ? familyName : ''}`.trim(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/frontend/src/utils/createChartOptions.ts: -------------------------------------------------------------------------------- 1 | import type { ChartTheme } from '@/styles/theme'; 2 | import { 3 | type DeepPartial, 4 | type HistogramStyleOptions, 5 | type SeriesOptionsCommon, 6 | CrosshairMode, 7 | ColorType, 8 | } from 'lightweight-charts'; 9 | 10 | export const createChartOptions = (theme: ChartTheme) => { 11 | const { background, textColor, gridLines, borderColor } = theme; 12 | 13 | return { 14 | layout: { 15 | background: { type: ColorType.Solid, color: background }, 16 | textColor: textColor, 17 | }, 18 | grid: { 19 | vertLines: { 20 | color: gridLines, 21 | }, 22 | horzLines: { 23 | color: gridLines, 24 | }, 25 | }, 26 | crosshair: { 27 | mode: CrosshairMode.Normal, 28 | }, 29 | timeScale: { 30 | borderColor: borderColor, 31 | }, 32 | }; 33 | }; 34 | 35 | export const createVolumeOptions = (): DeepPartial< 36 | HistogramStyleOptions & SeriesOptionsCommon 37 | > => ({ 38 | priceLineWidth: 2, 39 | priceFormat: { 40 | type: 'volume', 41 | }, 42 | priceScaleId: '', 43 | }); 44 | 45 | export const createCandlestickOptions = (theme: ChartTheme) => { 46 | return { ...theme.candlestick }; 47 | }; 48 | -------------------------------------------------------------------------------- /packages/backend/src/auth/session/cookieParser.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | import { WsException } from '@nestjs/websockets'; 3 | import * as cookie from 'cookie'; 4 | import { Socket } from 'socket.io'; 5 | import { sessionConfig } from '@/configs/session.config'; 6 | 7 | const DEFAULT_SESSION_ID = 'connect.sid'; 8 | 9 | export const websocketCookieParse = (socket: Socket) => { 10 | if (!socket.request.headers.cookie) { 11 | throw new WsException('not found cookie'); 12 | } 13 | const cookies = cookie.parse(socket.request.headers.cookie); 14 | const sid = cookies[sessionConfig.name || DEFAULT_SESSION_ID]; 15 | return getSessionIdFromCookie(sid); 16 | }; 17 | 18 | const getSessionIdFromCookie = (cookieValue: string) => { 19 | if (cookieValue?.startsWith('s:')) { 20 | const [id, signature] = cookieValue.slice(2).split('.'); 21 | const expectedSignature = crypto 22 | .createHmac('sha256', sessionConfig.secret) 23 | .update(id) 24 | .digest('base64') 25 | .replace(/=+$/, ''); 26 | 27 | if (expectedSignature === signature) { 28 | return id; 29 | } 30 | throw new WsException('Invalid cookie signature'); 31 | } 32 | throw new WsException('Invalid cookie format'); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/util/queue.spec.ts: -------------------------------------------------------------------------------- 1 | import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; 2 | 3 | describe('priorityQueue', () => { 4 | let priorityQueue: PriorityQueue; 5 | 6 | beforeEach(() => { 7 | priorityQueue = new PriorityQueue(); 8 | }); 9 | 10 | test('대량의 데이터 셋', () => { 11 | const size = 1000; 12 | const priorities: number[] = []; 13 | for (let i = 0; i < size; i++) { 14 | const priority = Math.floor(Math.random() * 10); 15 | priorities.push(priority); 16 | priorityQueue.enqueue(i, priority); 17 | } 18 | let lastPriority = -1; 19 | while (!priorityQueue.isEmpty()) { 20 | const current = priorityQueue.dequeue(); 21 | const currentPriority = priorities[current!]; 22 | expect(currentPriority).toBeGreaterThanOrEqual(lastPriority); 23 | if (currentPriority > lastPriority) { 24 | lastPriority = currentPriority; 25 | } 26 | } 27 | }); 28 | 29 | test('동일 우선순위일 경우 FIFO', () => { 30 | const size = 1000; 31 | for (let i = 0; i < size; i++) { 32 | priorityQueue.enqueue(i, 0); 33 | } 34 | for (let i = 0; i < size; i++) { 35 | expect(priorityQueue.dequeue()).toBe(i); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 2 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 3 | import { Suspense } from 'react'; 4 | import { ErrorBoundary } from 'react-error-boundary'; 5 | import { RouterProvider } from 'react-router-dom'; 6 | import { Error } from './components/errors/error'; 7 | import { Loader } from './components/ui/loader'; 8 | import { router } from './routes'; 9 | 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | throwOnError: true, 14 | }, 15 | }, 16 | }); 17 | 18 | const App = () => { 19 | return ( 20 | 21 | }> 22 | 25 | 26 |
27 | } 28 | > 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default App; 38 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/alarm/usePostCreateAlarm.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from '@tanstack/react-query'; 2 | import { 3 | type PostCreateAlarmRequest, 4 | type AlarmResponse, 5 | AlarmInfoSchema, 6 | } from './schema'; 7 | import { post } from '@/apis/utils/post'; 8 | 9 | const postCreateAlarm = ({ 10 | stockId, 11 | targetPrice, 12 | targetVolume, 13 | alarmExpiredDate, 14 | }: PostCreateAlarmRequest) => 15 | post({ 16 | params: { stockId, targetPrice, targetVolume, alarmExpiredDate }, 17 | schema: AlarmInfoSchema, 18 | url: '/api/alarm', 19 | }); 20 | 21 | export const usePostCreateAlarm = () => { 22 | const queryClient = useQueryClient(); 23 | return useMutation({ 24 | mutationKey: ['createAlarm'], 25 | mutationFn: ({ 26 | stockId, 27 | targetPrice, 28 | targetVolume, 29 | alarmExpiredDate, 30 | }: PostCreateAlarmRequest) => 31 | postCreateAlarm({ stockId, targetPrice, targetVolume, alarmExpiredDate }), 32 | onSuccess: () => { 33 | queryClient.invalidateQueries({ 34 | queryKey: ['getStockAlarm'], 35 | }); 36 | queryClient.invalidateQueries({ 37 | queryKey: ['getAlarm'], 38 | }); 39 | }, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stocks/useStockQueries.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQueries } from '@tanstack/react-query'; 2 | import { z } from 'zod'; 3 | import { 4 | GetStockListRequest, 5 | GetStockListResponseSchema, 6 | GetStockTopViewsResponse, 7 | StockIndexResponse, 8 | StockIndexSchema, 9 | } from './schema'; 10 | import { get } from '@/apis/utils/get'; 11 | 12 | interface StockQueriesProps { 13 | viewsLimit: GetStockListRequest['limit']; 14 | } 15 | 16 | const getStockIndex = () => 17 | get({ 18 | schema: z.array(StockIndexSchema), 19 | url: `/api/stock/index`, 20 | }); 21 | 22 | const getTopViews = ({ limit }: Partial) => 23 | get[]>({ 24 | schema: z.array(GetStockListResponseSchema.partial()), 25 | url: `/api/stock/topViews`, 26 | params: { limit }, 27 | }); 28 | 29 | export const useStockQueries = ({ viewsLimit }: StockQueriesProps) => { 30 | return useSuspenseQueries({ 31 | queries: [ 32 | { 33 | queryKey: ['stockIndex'], 34 | queryFn: getStockIndex, 35 | }, 36 | { 37 | queryKey: ['topViews'], 38 | queryFn: () => getTopViews({ limit: viewsLimit }), 39 | }, 40 | ], 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '../button'; 2 | import { useOutsideClick } from '@/hooks/useOutsideClick'; 3 | 4 | interface ModalProps { 5 | title: string; 6 | children: string; 7 | onClose: () => void; 8 | onConfirm: () => void; 9 | } 10 | 11 | export const Modal = ({ title, children, onClose, onConfirm }: ModalProps) => { 12 | const ref = useOutsideClick(onClose); 13 | 14 | return ( 15 |
16 |
20 |
21 |

{title}

22 |
{children}
23 |
24 |
25 | 26 | 33 |
34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/domain/alarm.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | JoinColumn, 9 | } from 'typeorm'; 10 | import { Stock } from '@/stock/domain/stock.entity'; 11 | import { User } from '@/user/domain/user.entity'; 12 | 13 | @Entity() 14 | export class Alarm { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @ManyToOne(() => User, (user) => user.alarms, { onDelete: 'CASCADE' }) 19 | @JoinColumn({ name: 'user_id' }) 20 | user: User; 21 | 22 | @ManyToOne(() => Stock, (stock) => stock.alarms, { onDelete: 'CASCADE' }) 23 | @JoinColumn({ name: 'stock_id' }) 24 | stock: Stock; 25 | 26 | @Column({ type: 'int', name: 'target_price', nullable: true }) 27 | targetPrice?: number; 28 | 29 | @Column({ 30 | type: 'decimal', 31 | precision: 15, 32 | scale: 2, 33 | name: 'target_volume', 34 | nullable: true, 35 | }) 36 | targetVolume?: number; 37 | 38 | @Column({ type: 'timestamp', name: 'alarm_date', nullable: true }) 39 | alarmExpiredDate?: Date; 40 | 41 | @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) 42 | createdAt: Date; 43 | 44 | @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) 45 | updatedAt: Date; 46 | } 47 | -------------------------------------------------------------------------------- /packages/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { ScheduleModule } from '@nestjs/schedule'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { WinstonModule } from 'nest-winston'; 6 | import { ScraperModule } from './scraper/scraper.module'; 7 | import { AuthModule } from '@/auth/auth.module'; 8 | import { SessionModule } from '@/auth/session.module'; 9 | import { ChatModule } from '@/chat/chat.module'; 10 | import { logger } from '@/configs/logger.config'; 11 | import { 12 | typeormDevelopConfig, 13 | typeormProductConfig, 14 | } from '@/configs/typeormConfig'; 15 | import { StockModule } from '@/stock/stock.module'; 16 | import { UserModule } from '@/user/user.module'; 17 | 18 | @Module({ 19 | imports: [ 20 | ConfigModule.forRoot({ cache: true, isGlobal: true }), 21 | ScheduleModule.forRoot(), 22 | WinstonModule.forRoot(logger), 23 | TypeOrmModule.forRoot( 24 | process.env.NODE_ENV === 'production' 25 | ? typeormProductConfig 26 | : typeormDevelopConfig, 27 | ), 28 | ScraperModule, 29 | StockModule, 30 | UserModule, 31 | AuthModule, 32 | ChatModule, 33 | SessionModule, 34 | ], 35 | controllers: [], 36 | providers: [], 37 | }) 38 | export class AppModule {} 39 | -------------------------------------------------------------------------------- /packages/backend/src/auth/session/websocketSession.service.ts: -------------------------------------------------------------------------------- 1 | import { MemoryStore } from 'express-session'; 2 | import { Socket } from 'socket.io'; 3 | import { websocketCookieParse } from '@/auth/session/cookieParser'; 4 | import { PassportSession } from '@/auth/session/webSocketSession.guard'; 5 | import { UserService } from '@/user/user.service'; 6 | 7 | export class WebsocketSessionService { 8 | constructor( 9 | private readonly sessionStore: MemoryStore, 10 | private readonly userService: UserService, 11 | ) {} 12 | 13 | async getAuthenticatedUser(socket: Socket) { 14 | try { 15 | const cookieValue = websocketCookieParse(socket); 16 | const session = await this.getSession(cookieValue); 17 | return session 18 | ? await this.userService.findUserById(session.passport.user) 19 | : null; 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | } catch (e) { 22 | return null; 23 | } 24 | } 25 | 26 | private getSession(cookieValue: string) { 27 | return new Promise((resolve) => { 28 | this.sessionStore.get(cookieValue, (err: Error, session) => { 29 | if (err || !session) { 30 | resolve(null); 31 | } 32 | resolve(session as PassportSession); 33 | }); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/frontend/src/contexts/theme/themeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | import { LoginContext } from '../login'; 4 | import { ThemeContext } from './themeContext'; 5 | import { 6 | GetUserTheme, 7 | useGetUserTheme, 8 | usePatchUserTheme, 9 | } from '@/apis/queries/user'; 10 | 11 | export const ThemeProvider = () => { 12 | const { isLoggedIn } = useContext(LoginContext); 13 | const { data: userTheme, isLoading } = useGetUserTheme(); 14 | const { mutate: updateTheme } = usePatchUserTheme(); 15 | 16 | const [theme, setTheme] = useState(() => { 17 | if (!isLoading && isLoggedIn && userTheme) { 18 | return userTheme; 19 | } 20 | const localTheme = localStorage.getItem('theme'); 21 | return (localTheme as GetUserTheme['theme']) || 'light'; 22 | }); 23 | 24 | document.body.classList.toggle('dark', theme === 'dark'); 25 | 26 | const changeTheme = (newTheme: GetUserTheme['theme']) => { 27 | localStorage.setItem('theme', newTheme); 28 | setTheme(newTheme); 29 | 30 | if (isLoggedIn) { 31 | updateTheme({ theme: newTheme }); 32 | } 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/search/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { usePostStockView } from '@/apis/queries/stock-detail'; 3 | import { type SearchResultsResponse } from '@/apis/queries/stocks'; 4 | import { Loader } from '@/components/ui/loader'; 5 | 6 | interface SearchResultsProps { 7 | data?: SearchResultsResponse; 8 | isLoading: boolean; 9 | isError: boolean; 10 | } 11 | 12 | export const SearchResults = ({ 13 | data, 14 | isLoading, 15 | isError, 16 | }: SearchResultsProps) => { 17 | const { mutate } = usePostStockView(); 18 | 19 | if (isLoading) { 20 | return ; 21 | } 22 | 23 | if (isError || data?.searchResults.length === 0) { 24 | return

검색 결과가 없어요.

; 25 | } 26 | 27 | if (data) { 28 | return ( 29 |
    30 | {data.searchResults.map((stock) => ( 31 | mutate({ stockId: stock.id })} 35 | reloadDocument 36 | className="text-dark-gray hover:text-orange leading-7 hover:underline" 37 | > 38 | {stock.name} 39 | 40 | ))} 41 |
42 | ); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/my-page/AlarmInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { useGetAlarm } from '@/apis/queries/alarm'; 3 | import { Alarm } from '@/components/ui/alarm'; 4 | import { LoginContext } from '@/contexts/login'; 5 | 6 | export const AlarmInfo = () => { 7 | return ( 8 |
9 |

알림

10 |
11 | 12 |
13 |
14 | ); 15 | }; 16 | 17 | const AlarmInfoContents = () => { 18 | const { isLoggedIn } = useContext(LoginContext); 19 | const { data } = useGetAlarm({ isLoggedIn }); 20 | 21 | if (!isLoggedIn) { 22 | return ( 23 |

24 | 로그인 후 이용 가능해요. 25 |

26 | ); 27 | } 28 | 29 | if (!data || data?.length === 0) { 30 | return ( 31 |

32 | 현재 설정된 알림이 없어요. 33 |

34 | ); 35 | } 36 | 37 | return data.map((alarm) => ( 38 | 44 | )); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/dto/alarm.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Alarm } from '../domain/alarm.entity'; 3 | 4 | export class AlarmResponse { 5 | @ApiProperty({ 6 | description: '알림 아이디', 7 | example: 10, 8 | }) 9 | alarmId: number; 10 | 11 | @ApiProperty({ 12 | description: '주식 코드', 13 | example: '005930', 14 | }) 15 | stockId: string; 16 | 17 | @ApiProperty({ 18 | description: '목표 주식 가격', 19 | example: 50000, 20 | nullable: true, 21 | }) 22 | targetPrice?: number; 23 | 24 | @ApiProperty({ 25 | description: '목표 주식 거래량', 26 | example: 10, 27 | nullable: true, 28 | }) 29 | targetVolume?: number; 30 | 31 | @ApiProperty({ 32 | description: '알림 만료일', 33 | example: 10, 34 | nullable: true, 35 | }) 36 | alarmExpiredDate?: Date; 37 | 38 | constructor(alarm: Alarm) { 39 | this.alarmId = alarm.id; 40 | this.stockId = alarm.stock.id; 41 | this.targetPrice = Number(alarm.targetPrice); 42 | this.targetVolume = Number(alarm.targetVolume); 43 | this.alarmExpiredDate = alarm.alarmExpiredDate; 44 | } 45 | } 46 | 47 | export class AlarmSuccessResponse { 48 | @ApiProperty({ 49 | description: '성공 메시지', 50 | example: 'success', 51 | }) 52 | message: string; 53 | constructor(message: string) { 54 | this.message = message; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/backend/src/stock/dto/stockIndexRate.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { StockLiveData } from '../domain/stockLiveData.entity'; 3 | export class StockIndexRateResponse { 4 | @ApiProperty({ description: '지표 이름', example: '원 달러 환율' }) 5 | name: string; 6 | 7 | @ApiProperty({ description: '현재 가격', example: 1400 }) 8 | currentPrice: number; 9 | 10 | @ApiProperty({ description: '거래량', example: 0 }) 11 | changeRate: number; 12 | 13 | @ApiProperty({ description: '거래량', example: 10000 }) 14 | volume: number; 15 | 16 | @ApiProperty({ description: '최고가', example: 1050 }) 17 | high: number; 18 | 19 | @ApiProperty({ description: '최저가', example: 950 }) 20 | low: number; 21 | 22 | @ApiProperty({ description: '시가', example: 980 }) 23 | open: number; 24 | 25 | @ApiProperty({ 26 | description: '마지막 업데이트 날짜', 27 | example: '2023-10-01T00:00:00Z', 28 | }) 29 | updatedAt: Date; 30 | 31 | constructor(stockLiveData: StockLiveData) { 32 | this.name = stockLiveData.stock.name; 33 | this.currentPrice = stockLiveData.currentPrice; 34 | this.changeRate = stockLiveData.changeRate; 35 | this.volume = stockLiveData.volume; 36 | this.high = stockLiveData.high; 37 | this.low = stockLiveData.low; 38 | this.open = stockLiveData.open; 39 | this.updatedAt = stockLiveData.updatedAt; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/parse/openapi.parser.ts: -------------------------------------------------------------------------------- 1 | import { stockDataKeys } from '../type/openapiLiveData.type'; 2 | 3 | export const parseMessage = (data: string) => { 4 | try { 5 | return JSON.parse(data); 6 | //eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | } catch (e) { 8 | return parseStockData(data); 9 | } 10 | }; 11 | const FIELD_LENGTH: number = stockDataKeys.length; 12 | 13 | const parseStockData = (input: string) => { 14 | const dataBlocks = input.split('|'); // 데이터 구분 15 | const results = []; 16 | const size = parseInt(dataBlocks[2]); // 데이터 건수 17 | const rawData = dataBlocks[3]; 18 | const values = rawData.split('^'); // 필드 구분자 '^' 19 | 20 | for (let i = 0; i < size; i++) { 21 | //TODO : type narrowing require 22 | const parsedData: Record = {}; 23 | parsedData['STOCK_ID'] = values[i * FIELD_LENGTH]; 24 | stockDataKeys.forEach((field: string, index: number) => { 25 | const value = values[index + FIELD_LENGTH * i]; 26 | if (!value) return (parsedData[field] = null); 27 | 28 | // 숫자형 필드 처리 29 | if (isNaN(parseInt(value))) { 30 | parsedData[field] = value; // 문자열 그대로 저장 31 | } else { 32 | parsedData[field] = parseFloat(value); // 숫자로 변환 33 | } 34 | }); 35 | results.push(parsedData); 36 | } 37 | return results; 38 | }; 39 | -------------------------------------------------------------------------------- /packages/backend/src/chat/domain/chat.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | Index, 5 | JoinColumn, 6 | ManyToOne, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | } from 'typeorm'; 10 | import { ChatType } from '@/chat/domain/chatType.enum'; 11 | import { Like } from '@/chat/domain/like.entity'; 12 | import { DateEmbedded } from '@/common/dateEmbedded.entity'; 13 | import { Stock } from '@/stock/domain/stock.entity'; 14 | import { User } from '@/user/domain/user.entity'; 15 | import { Mention } from '@/chat/domain/mention.entity'; 16 | 17 | @Entity() 18 | export class Chat { 19 | @PrimaryGeneratedColumn() 20 | id: number; 21 | 22 | @ManyToOne(() => User, (user) => user.id) 23 | @JoinColumn({ name: 'user_id' }) 24 | user: User; 25 | 26 | @ManyToOne(() => Stock, (stock) => stock.id) 27 | @JoinColumn({ name: 'stock_id' }) 28 | stock: Stock; 29 | 30 | @OneToMany(() => Like, (like) => like.chat) 31 | likes?: Like[]; 32 | 33 | @Column() 34 | message: string; 35 | 36 | @Column({ type: 'enum', enum: ChatType, default: ChatType.NORMAL }) 37 | type: ChatType = ChatType.NORMAL; 38 | 39 | @Index() 40 | @Column({ name: 'like_count', default: 0 }) 41 | likeCount: number = 0; 42 | 43 | @Column(() => DateEmbedded, { prefix: '' }) 44 | date: DateEmbedded; 45 | 46 | @OneToMany(() => Mention, (mention) => mention.chat) 47 | mentions: Mention[]; 48 | } 49 | -------------------------------------------------------------------------------- /packages/backend/src/stock/decorator/stockData.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | /* eslint-disable max-lines-per-function */ 3 | 4 | import { applyDecorators } from '@nestjs/common'; 5 | import { ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; 6 | import { StockDataResponse } from '../dto/stockData.response'; 7 | 8 | export function ApiGetStockData(summary: string, type: string) { 9 | return applyDecorators( 10 | ApiOperation({ summary }), 11 | ApiParam({ 12 | name: 'stockId', 13 | type: String, 14 | description: '주식 ID', 15 | example: '005930', 16 | }), 17 | ApiQuery({ 18 | name: 'lastStartTime', 19 | required: false, 20 | description: '마지막 시작 시간 (ISO 8601 형식)', 21 | example: '2024-04-01T00:00:00.000Z', 22 | type: String, 23 | format: 'date-time', 24 | }), 25 | ApiQuery({ 26 | name: 'timeunit', 27 | required: false, 28 | description: '시간 단위', 29 | example: 'minute', 30 | type: String, 31 | enum: ['minute', 'day', 'week', 'month', 'year'], 32 | }), 33 | ApiResponse({ 34 | status: 200, 35 | description: `주식의 ${type} 단위 데이터 성공적으로 조회`, 36 | type: StockDataResponse, 37 | }), 38 | ApiResponse({ 39 | status: 404, 40 | description: '주식 데이터가 존재하지 않음', 41 | }), 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /packages/backend/src/auth/tester/testerAuth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; 2 | import { 3 | ApiOkResponse, 4 | ApiOperation, 5 | ApiQuery, 6 | ApiTags, 7 | } from '@nestjs/swagger'; 8 | import { Request, Response } from 'express'; 9 | import { TestAuthGuard } from '@/auth/tester/guard/tester.guard'; 10 | 11 | @ApiTags('Auth') 12 | @Controller('auth/tester') 13 | export class TesterAuthController { 14 | constructor() {} 15 | 16 | @ApiOperation({ 17 | summary: '테스터 로그인 api', 18 | description: '테스터로 로그인합니다.', 19 | }) 20 | @ApiQuery({ 21 | name: 'username', 22 | required: true, 23 | description: '테스터 아이디(값만 넣으면 됨)', 24 | }) 25 | @ApiQuery({ 26 | name: 'password', 27 | required: true, 28 | description: '테스터 비밀번호(값만 넣으면 됨)', 29 | }) 30 | @Get('/login') 31 | @UseGuards(TestAuthGuard) 32 | async handleLogin(@Res() response: Response) { 33 | response.redirect('/'); 34 | } 35 | 36 | @ApiOperation({ 37 | summary: '로그인 상태 확인', 38 | description: '로그인 상태를 확인합니다.', 39 | }) 40 | @ApiOkResponse({ 41 | description: '로그인된 상태', 42 | example: { message: 'Authenticated' }, 43 | }) 44 | @Get('/status') 45 | async user(@Req() request: Request) { 46 | if (request.user) { 47 | return { message: 'Authenticated' }; 48 | } 49 | return { message: 'Not Authenticated' }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/button/Button.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Button, type ButtonProps } from '.'; 3 | 4 | const meta: Meta = { 5 | title: 'Example/Button', 6 | component: Button, 7 | parameters: { 8 | layout: 'centered', 9 | }, 10 | tags: ['autodocs'], 11 | argTypes: { 12 | backgroundColor: { 13 | control: 'select', 14 | options: ['default', 'gray', 'orange'], 15 | }, 16 | textColor: { 17 | control: 'select', 18 | options: ['default', 'white'], 19 | }, 20 | size: { 21 | control: 'select', 22 | options: ['default', 'sm'], 23 | }, 24 | }, 25 | args: { children: 'Button' }, 26 | }; 27 | 28 | export default meta; 29 | type Story = StoryObj; 30 | 31 | export const Primary: Story = { 32 | args: { 33 | children: 'Button', 34 | }, 35 | }; 36 | 37 | export const OrangeButton: Story = { 38 | args: { 39 | backgroundColor: 'orange', 40 | textColor: 'white', 41 | children: 'Button', 42 | }, 43 | }; 44 | 45 | export const GrayButton: Story = { 46 | args: { 47 | backgroundColor: 'gray', 48 | textColor: 'white', 49 | children: 'Button', 50 | }, 51 | }; 52 | 53 | export const SmallButton: Story = { 54 | args: { 55 | backgroundColor: 'orange', 56 | textColor: 'white', 57 | children: 'Button', 58 | size: 'sm', 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /packages/frontend/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom'; 2 | import { Error } from '@/components/errors/error'; 3 | import { Layout } from '@/components/layouts'; 4 | import { LoginProvider } from '@/contexts/login'; 5 | import { ThemeProvider } from '@/contexts/theme'; 6 | import { Login } from '@/pages/login'; 7 | import { MyPage } from '@/pages/my-page'; 8 | import { StockDetail } from '@/pages/stock-detail'; 9 | import { Stocks } from '@/pages/stocks'; 10 | 11 | export const router = createBrowserRouter([ 12 | { 13 | element: ( 14 | 15 | 16 | 17 | ), 18 | children: [ 19 | { 20 | element: , 21 | children: [ 22 | { 23 | path: '/', 24 | element: , 25 | }, 26 | { 27 | path: '/stocks', 28 | element: , 29 | }, 30 | { 31 | path: 'stocks/:stockId', 32 | element: , 33 | }, 34 | { 35 | path: '/my-page', 36 | element: , 37 | }, 38 | { 39 | path: '/login', 40 | element: , 41 | }, 42 | { 43 | path: '*', 44 | element: , 45 | }, 46 | ], 47 | }, 48 | ], 49 | }, 50 | ]); 51 | -------------------------------------------------------------------------------- /packages/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import * as session from 'express-session'; 4 | import * as passport from 'passport'; 5 | import { AppModule } from './app.module'; 6 | import { MEMORY_STORE } from '@/auth/session.module'; 7 | import { sessionConfig } from '@/configs/session.config'; 8 | import { useSwagger } from '@/configs/swagger.config'; 9 | 10 | const setCors = (app: INestApplication) => { 11 | app.enableCors({ 12 | origin: [ 13 | 'http://localhost:3000', 14 | 'https://juchum.info', 15 | 'http://localhost:5173', 16 | ], 17 | methods: '*', 18 | allowedHeaders: [ 19 | 'Content-Type', 20 | 'Authorization', 21 | 'X-Requested-With', 22 | 'Accept', 23 | ], 24 | credentials: true, 25 | }); 26 | }; 27 | 28 | async function bootstrap() { 29 | const app = await NestFactory.create(AppModule); 30 | const store = app.get(MEMORY_STORE); 31 | 32 | app.setGlobalPrefix('api'); 33 | app.use(session({ ...sessionConfig, store })); 34 | app.useGlobalPipes( 35 | new ValidationPipe({ 36 | transform: true, 37 | transformOptions: { enableImplicitConversion: true }, 38 | }), 39 | ); 40 | setCors(app); 41 | useSwagger(app); 42 | app.use(passport.initialize()); 43 | app.use(passport.session()); 44 | await app.listen(process.env.PORT ?? 3000); 45 | } 46 | 47 | bootstrap(); 48 | -------------------------------------------------------------------------------- /packages/frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | import importPlugin from 'eslint-plugin-import'; 7 | 8 | export default tseslint.config( 9 | { ignores: ['dist'] }, 10 | { 11 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 12 | files: ['**/*.{ts,tsx}'], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | globals: globals.browser, 16 | }, 17 | plugins: { 18 | 'react-hooks': reactHooks, 19 | 'react-refresh': reactRefresh, 20 | import: importPlugin, 21 | }, 22 | rules: { 23 | ...reactHooks.configs.recommended.rules, 24 | 'react-refresh/only-export-components': [ 25 | 'warn', 26 | { allowConstantExport: true }, 27 | ], 28 | 'import/order': [ 29 | 'error', 30 | { 31 | groups: [ 32 | 'type', 33 | 'builtin', 34 | 'external', 35 | 'internal', 36 | 'parent', 37 | 'sibling', 38 | 'index', 39 | 'unknown', 40 | ], 41 | warnOnUnassignedImports: true, 42 | alphabetize: { 43 | order: 'asc', 44 | caseInsensitive: true, 45 | }, 46 | }, 47 | ], 48 | }, 49 | }, 50 | ); 51 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/alarm/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const KeysSchema = z.object({ 4 | p256dh: z.string(), 5 | auth: z.string(), 6 | }); 7 | 8 | export const PostInitAlarmRequestSchema = z.object({ 9 | endpoint: z.string(), 10 | keys: KeysSchema, 11 | }); 12 | 13 | export type PostInitAlarmRequest = z.infer; 14 | 15 | export const PostInitAlarmResponseSchema = z.object({ 16 | message: z.string(), 17 | }); 18 | 19 | export type PostInitAlarmResponse = z.infer; 20 | 21 | export const PostCreateAlarmRequestSchema = z.object({ 22 | stockId: z.string(), 23 | targetPrice: z.number().optional(), 24 | targetVolume: z.number().optional(), 25 | alarmExpiredDate: z.string().datetime().nullish(), 26 | }); 27 | 28 | export type PostCreateAlarmRequest = z.infer< 29 | typeof PostCreateAlarmRequestSchema 30 | >; 31 | 32 | export const AlarmInfoSchema = z.object({ 33 | alarmId: z.number(), 34 | stockId: z.string(), 35 | targetPrice: z.number().nullable(), 36 | targetVolume: z.number().nullable(), 37 | alarmExpiredDate: z.string().datetime().nullable(), 38 | }); 39 | 40 | export const AlarmResponseSchema = z.array(AlarmInfoSchema); 41 | export type AlarmResponse = z.infer; 42 | 43 | export const StockAlarmRequestSchema = z.object({ 44 | stockId: z.string(), 45 | }); 46 | 47 | export type StockAlarmRequest = z.infer; 48 | -------------------------------------------------------------------------------- /packages/frontend/src/hooks/useSubscribeAlarm.ts: -------------------------------------------------------------------------------- 1 | import { usePostInitAlarm } from '@/apis/queries/alarm'; 2 | 3 | export const useSubscribeAlarm = () => { 4 | const { mutate: postInitAlarm } = usePostInitAlarm(); 5 | 6 | const subscribeAlarm = async () => { 7 | if ('Notification' in window && navigator.serviceWorker) { 8 | try { 9 | if (Notification.permission === 'default') { 10 | alert('알림 허용을 먼저 해주세요.'); 11 | const permission = await Notification.requestPermission(); 12 | if (permission === 'granted') { 13 | const registration = await navigator.serviceWorker.ready; 14 | const subscription = await registration.pushManager.subscribe({ 15 | userVisibleOnly: true, 16 | applicationServerKey: urlB64ToUint8Array( 17 | import.meta.env.VITE_VAPID_PUBLIC_KEY, 18 | ), 19 | }); 20 | 21 | postInitAlarm(subscription); 22 | } 23 | } 24 | } catch (error) { 25 | console.log('Subscription failed', error); 26 | } 27 | } 28 | }; 29 | 30 | return { subscribeAlarm }; 31 | }; 32 | 33 | const urlB64ToUint8Array = (base64String: string) => { 34 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4); 35 | const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); 36 | const rawData = window.atob(base64); 37 | return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/search/Search.tsx: -------------------------------------------------------------------------------- 1 | import { type FormEvent, useState } from 'react'; 2 | import { SearchResults } from './SearchResults'; 3 | import { useGetSearchStocks } from '@/apis/queries/stocks'; 4 | import { Button } from '@/components/ui/button'; 5 | import { Input } from '@/components/ui/input'; 6 | import { cn } from '@/utils/cn'; 7 | 8 | interface SearchProps { 9 | className?: string; 10 | } 11 | 12 | export const Search = ({ className }: SearchProps) => { 13 | const [stockName, setStockName] = useState(''); 14 | const { data, refetch, isLoading, isError } = useGetSearchStocks(stockName); 15 | 16 | const handleSubmit = (event: FormEvent) => { 17 | event.preventDefault(); 18 | if (!stockName) return; 19 | refetch(); 20 | }; 21 | 22 | return ( 23 |
24 |

검색

25 |

26 | 주식을 검색하세요. 27 |

28 |
29 | setStockName(e.target.value)} 33 | autoFocus 34 | /> 35 | 38 |
39 | 40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/type/openapiPeriodData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export type Period = 'D' | 'W' | 'M' | 'Y'; 3 | export type ChartData = { 4 | stck_bsop_date: string; 5 | stck_clpr: string; 6 | stck_oprc: string; 7 | stck_hgpr: string; 8 | stck_lwpr: string; 9 | acml_vol: string; 10 | acml_tr_pbmn: string; 11 | flng_cls_code: string; 12 | prtt_rate: string; 13 | mod_yn: string; 14 | prdy_vrss_sign: string; 15 | prdy_vrss: string; 16 | revl_issu_reas: string; 17 | }; 18 | 19 | export type ItemChartPriceQuery = { 20 | fid_cond_mrkt_div_code: 'J' | 'W'; 21 | fid_input_iscd: string; 22 | fid_input_date_1: string; 23 | fid_input_date_2: string; 24 | fid_period_div_code: Period; 25 | fid_org_adj_prc: number; 26 | }; 27 | 28 | export const isChartData = (data?: any) => { 29 | return ( 30 | data && 31 | typeof data.stck_bsop_date === 'string' && 32 | typeof data.stck_clpr === 'string' && 33 | typeof data.stck_oprc === 'string' && 34 | typeof data.stck_hgpr === 'string' && 35 | typeof data.stck_lwpr === 'string' && 36 | typeof data.acml_vol === 'string' && 37 | typeof data.acml_tr_pbmn === 'string' && 38 | typeof data.flng_cls_code === 'string' && 39 | typeof data.prtt_rate === 'string' && 40 | typeof data.mod_yn === 'string' && 41 | typeof data.prdy_vrss_sign === 'string' && 42 | typeof data.prdy_vrss === 'string' && 43 | typeof data.revl_issu_reas === 'string' 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/backend/src/stock/domain/stockData.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | JoinColumn, 7 | ManyToOne, 8 | Index, 9 | } from 'typeorm'; 10 | import { Stock } from './stock.entity'; 11 | 12 | @Index('stock_id_start_time', ['stock.id', 'startTime'], { unique: true }) 13 | export class StockData { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 18 | close: number; 19 | 20 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 21 | low: number; 22 | 23 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 24 | high: number; 25 | 26 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 27 | open: number; 28 | 29 | @Column({ type: 'decimal', precision: 15, scale: 2 }) 30 | volume: number; 31 | 32 | @Column({ type: 'timestamp', name: 'start_time' }) 33 | startTime: Date; 34 | 35 | @ManyToOne(() => Stock) 36 | @JoinColumn({ name: 'stock_id' }) 37 | stock: Stock; 38 | 39 | @CreateDateColumn({ name: 'created_at' }) 40 | createdAt: Date; 41 | } 42 | 43 | @Entity('stock_minutely') 44 | export class StockMinutely extends StockData {} 45 | @Entity('stock_daily') 46 | export class StockDaily extends StockData {} 47 | @Entity('stock_weekly') 48 | export class StockWeekly extends StockData {} 49 | @Entity('stock_monthly') 50 | export class StockMonthly extends StockData {} 51 | @Entity('stock_yearly') 52 | export class StockYearly extends StockData {} 53 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/small-bell.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: storybook deploy 2 | 3 | on: 4 | push: 5 | branches: ['dev-fe'] 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | concurrency: 15 | group: ${{ github.workflow }} 16 | cancel-in-progress: true 17 | 18 | steps: 19 | - name: Use repository source 20 | uses: actions/checkout@v3 21 | 22 | - name: Use node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 18 26 | 27 | - name: Cache node_modules 28 | id: cache 29 | uses: actions/cache@v3 30 | with: 31 | path: '**/node_modules' 32 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-node- 35 | 36 | - name: Install dependencies 37 | run: yarn install 38 | if: steps.cache.outputs.cache-hit != 'true' 39 | 40 | - name: Set PUBLIC_URL 41 | run: | 42 | PUBLIC_URL=$(echo $GITHUB_REPOSITORY | sed -r 's/^.+\/(.+)$/\/\1\//') 43 | echo PUBLIC_URL=$PUBLIC_URL > .env 44 | 45 | - name: Build storybook 46 | run: | 47 | yarn client build-storybook 48 | 49 | - name: Deploy to gh-pages branch 50 | uses: peaceiris/actions-gh-pages@v3 51 | with: 52 | github_token: ${{ secrets.GITHUB_TOKEN }} 53 | publish_dir: ./packages/frontend/storybook-static 54 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/user/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const GetUserInfoSchema = z.object({ 4 | nickname: z.string(), 5 | subName: z.string(), 6 | createdAt: z.string().datetime(), 7 | email: z.string(), 8 | type: z.string(), 9 | }); 10 | 11 | export type GetUserInfo = z.infer; 12 | 13 | export const GetUserStockSchema = z.object({ 14 | id: z.number(), 15 | stockId: z.string(), 16 | name: z.string(), 17 | isTrading: z.boolean(), 18 | groupCode: z.string(), 19 | createdAt: z.string().datetime(), 20 | }); 21 | 22 | export type GetUserStock = z.infer; 23 | 24 | export const GetUserStockResponseSchema = z.object({ 25 | userStocks: z.array(GetUserStockSchema), 26 | }); 27 | 28 | export type GetUserStockResponse = z.infer; 29 | 30 | export const PostUserNicknameSchema = z.object({ 31 | message: z.string(), 32 | date: z.string().datetime(), 33 | }); 34 | 35 | export type PostUserNickname = z.infer; 36 | 37 | export const UserThemeSchema = z.object({ 38 | theme: z.enum(['light', 'dark']), 39 | }); 40 | 41 | export type GetUserTheme = z.infer; 42 | 43 | export type PatchUserThemeRequest = z.infer; 44 | 45 | export const PatchUserThemeSchema = z.object({ 46 | theme: z.enum(['light', 'dark']), 47 | updatedAt: z.string().datetime(), 48 | }); 49 | 50 | export type PatchUserTheme = z.infer; 51 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/chat/useGetChatList.ts: -------------------------------------------------------------------------------- 1 | import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; 2 | import { GetChatListRequest } from './schema'; 3 | import { get } from '@/apis/utils/get'; 4 | import { ChatDataResponse, ChatDataResponseSchema } from '@/sockets/schema'; 5 | 6 | const getChatList = ({ 7 | stockId, 8 | latestChatId, 9 | pageSize, 10 | order, 11 | }: GetChatListRequest) => 12 | get({ 13 | schema: ChatDataResponseSchema, 14 | url: '/api/chat', 15 | params: { 16 | stockId, 17 | latestChatId, 18 | pageSize, 19 | order, 20 | }, 21 | }); 22 | 23 | export const useGetChatList = ({ 24 | stockId, 25 | latestChatId, 26 | pageSize, 27 | order, 28 | }: GetChatListRequest) => { 29 | return useInfiniteQuery({ 30 | queryKey: ['chatList', stockId, order], 31 | queryFn: ({ pageParam }) => 32 | getChatList({ 33 | stockId, 34 | latestChatId: pageParam?.latestChatId, 35 | pageSize, 36 | order, 37 | }), 38 | getNextPageParam: (lastPage) => 39 | lastPage.hasMore 40 | ? { 41 | latestChatId: lastPage.chats[lastPage.chats.length - 1].id, 42 | } 43 | : undefined, 44 | initialPageParam: { latestChatId }, 45 | select: (data) => ({ 46 | pages: [...data.pages].flatMap((page) => page.chats), 47 | pageParams: [...data.pageParams], 48 | }), 49 | staleTime: 1000 * 60 * 3, 50 | placeholderData: keepPreviousData, 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /packages/backend/src/stock/stockDetail.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { DataSource } from 'typeorm'; 3 | import { Logger } from 'winston'; 4 | import { StockDetail } from './domain/stockDetail.entity'; 5 | import { StockDetailResponse } from './dto/stockDetail.response'; 6 | 7 | @Injectable() 8 | export class StockDetailService { 9 | constructor( 10 | private readonly datasource: DataSource, 11 | @Inject('winston') private readonly logger: Logger, 12 | ) {} 13 | 14 | async getStockDetailByStockId(stockId: string): Promise { 15 | return await this.datasource.transaction(async (manager) => { 16 | const isExists = await manager.existsBy(StockDetail, { 17 | stock: { id: stockId }, 18 | }); 19 | 20 | if (!isExists) { 21 | this.logger.warn(`stock detail not found (stockId: ${stockId})`); 22 | throw new NotFoundException( 23 | `stock detail not found (stockId: ${stockId}`, 24 | ); 25 | } 26 | 27 | const result = await manager 28 | .getRepository(StockDetail) 29 | .createQueryBuilder('stockDetail') 30 | .leftJoinAndSelect('stockDetail.stock', 'stock') 31 | .where('stockDetail.stock_id = :stockId', { stockId }) 32 | .getOne(); 33 | 34 | if (!result) { 35 | throw new NotFoundException( 36 | `stock detail not found (stockId: ${stockId}`, 37 | ); 38 | } 39 | 40 | return new StockDetailResponse(result); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export type Period = 'D' | 'W' | 'M' | 'Y'; 3 | export type ChartData = { 4 | stck_bsop_date: string; 5 | stck_clpr: string; 6 | stck_oprc: string; 7 | stck_hgpr: string; 8 | stck_lwpr: string; 9 | acml_vol: string; 10 | acml_tr_pbmn: string; 11 | flng_cls_code: string; 12 | prtt_rate: string; 13 | mod_yn: string; 14 | prdy_vrss_sign: string; 15 | prdy_vrss: string; 16 | revl_issu_reas: string; 17 | }; 18 | 19 | export type ItemChartPriceQuery = { 20 | fid_cond_mrkt_div_code: 'J' | 'W'; 21 | fid_input_iscd: string; 22 | fid_input_date_1: string; 23 | fid_input_date_2: string; 24 | fid_period_div_code: Period; 25 | fid_org_adj_prc: number; 26 | }; 27 | 28 | export const isChartData = (data?: any): data is ChartData => { 29 | return ( 30 | data && 31 | typeof data === 'object' && 32 | typeof data.stck_bsop_date === 'string' && 33 | typeof data.stck_clpr === 'string' && 34 | typeof data.stck_oprc === 'string' && 35 | typeof data.stck_hgpr === 'string' && 36 | typeof data.stck_lwpr === 'string' && 37 | typeof data.acml_vol === 'string' && 38 | typeof data.acml_tr_pbmn === 'string' && 39 | typeof data.flng_cls_code === 'string' && 40 | typeof data.prtt_rate === 'string' && 41 | typeof data.mod_yn === 'string' && 42 | typeof data.prdy_vrss_sign === 'string' && 43 | typeof data.prdy_vrss === 'string' && 44 | typeof data.revl_issu_reas === 'string' 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/backend/src/chat/dto/chat.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Chat } from '@/chat/domain/chat.entity'; 3 | import { ChatType } from '@/chat/domain/chatType.enum'; 4 | 5 | export interface ChatResponse { 6 | id: number; 7 | likeCount: number; 8 | message: string; 9 | type: string; 10 | liked: boolean; 11 | nickname: string; 12 | subName: string; 13 | mentioned: boolean; 14 | createdAt: Date; 15 | } 16 | 17 | export class ChatScrollResponse { 18 | @ApiProperty({ 19 | description: '다음 페이지가 있는지 여부', 20 | example: true, 21 | }) 22 | readonly hasMore: boolean; 23 | 24 | @ApiProperty({ 25 | description: '채팅 목록', 26 | example: [ 27 | { 28 | id: 1, 29 | likeCount: 0, 30 | message: '안녕하세요', 31 | nickname: '초보 주주', 32 | type: ChatType.NORMAL, 33 | isLiked: true, 34 | createdAt: new Date(), 35 | mentioned: false, 36 | subName: '0001', 37 | }, 38 | ], 39 | }) 40 | readonly chats: ChatResponse[]; 41 | 42 | constructor(chats: Chat[], hasMore: boolean) { 43 | this.chats = chats.map((chat) => ({ 44 | id: chat.id, 45 | likeCount: chat.likeCount, 46 | message: chat.message, 47 | type: chat.type, 48 | createdAt: chat.date!.createdAt, 49 | liked: !!(chat.likes && chat.likes.length > 0), 50 | mentioned: chat.mentions && chat.mentions.length > 0, 51 | nickname: chat.user.nickname, 52 | subName: chat.user.subName, 53 | })); 54 | this.hasMore = hasMore; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/backend/src/alarm/alarm.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { 3 | DataSource, 4 | EntitySubscriberInterface, 5 | EventSubscriber, 6 | InsertEvent, 7 | } from 'typeorm'; 8 | import { Logger } from 'winston'; 9 | import { AlarmService } from './alarm.service'; 10 | import { Alarm } from './domain/alarm.entity'; 11 | import { StockMinutely } from '@/stock/domain/stockData.entity'; 12 | 13 | @Injectable() 14 | @EventSubscriber() 15 | export class AlarmSubscriber 16 | implements EntitySubscriberInterface 17 | { 18 | constructor( 19 | private readonly datasource: DataSource, 20 | private readonly alarmService: AlarmService, 21 | @Inject('winston') private readonly logger: Logger, 22 | ) { 23 | this.datasource.subscribers.push(this); 24 | } 25 | 26 | listenTo() { 27 | return StockMinutely; 28 | } 29 | 30 | async afterInsert(event: InsertEvent) { 31 | try { 32 | const stockMinutely = event.entity; 33 | const rawAlarms = await this.datasource.manager.find(Alarm, { 34 | where: { stock: { id: stockMinutely.stock.id } }, 35 | relations: ['user', 'stock'], 36 | }); 37 | const alarms = rawAlarms.filter((val) => 38 | this.alarmService.isValidAlarmCompareEntity(val, stockMinutely), 39 | ); 40 | for (const alarm of alarms) { 41 | await this.alarmService.sendPushNotification(alarm); 42 | } 43 | } catch (error) { 44 | this.logger.warn(`Failed to handle alarm afterInsert event : ${error}`); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stocks/useGetStocksPriceSeries.ts: -------------------------------------------------------------------------------- 1 | import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; 2 | import { 3 | StockTimeSeriesResponseSchema, 4 | type StockTimeSeriesRequest, 5 | type StockTimeSeriesResponse, 6 | } from './schema'; 7 | import { get } from '@/apis/utils/get'; 8 | 9 | const getStocksPriceSeries = ({ 10 | stockId, 11 | lastStartTime, 12 | timeunit, 13 | }: StockTimeSeriesRequest) => 14 | get({ 15 | schema: StockTimeSeriesResponseSchema, 16 | url: `/api/stock/${stockId}`, 17 | params: { 18 | lastStartTime, 19 | timeunit, 20 | }, 21 | }); 22 | 23 | export const useGetStocksPriceSeries = ({ 24 | stockId, 25 | lastStartTime, 26 | timeunit, 27 | }: StockTimeSeriesRequest) => { 28 | return useInfiniteQuery({ 29 | queryKey: ['stocksTimeSeries', stockId, timeunit], 30 | queryFn: ({ pageParam }) => 31 | getStocksPriceSeries({ 32 | stockId, 33 | lastStartTime: pageParam?.lastStartTime ?? lastStartTime, 34 | timeunit, 35 | }), 36 | getNextPageParam: (lastPage) => 37 | lastPage.hasMore 38 | ? { 39 | lastStartTime: lastPage.priceDtoList[0].startTime, 40 | } 41 | : undefined, 42 | initialPageParam: { lastStartTime }, 43 | select: (data) => ({ 44 | pages: [...data.pages].reverse(), 45 | pageParams: [...data.pageParams].reverse(), 46 | }), 47 | // refetchOnWindowFocus: false, 48 | staleTime: 10 * 1000, 49 | placeholderData: keepPreviousData, 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /packages/frontend/src/components/ui/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonHTMLAttributes, ReactNode } from 'react'; 2 | import { cva, VariantProps } from 'class-variance-authority'; 3 | import { cn } from '@/utils/cn'; 4 | 5 | export const ButtonVariants = cva( 6 | `display-bold14 border rounded shadow-black py-1 border-orange`, 7 | { 8 | variants: { 9 | backgroundColor: { 10 | default: 'bg-white hover:bg-orange', 11 | gray: 'bg-gray', 12 | orange: 'bg-orange hover:bg-white', 13 | }, 14 | textColor: { 15 | default: 'text-orange hover:text-white', 16 | white: 'text-white hover:text-orange', 17 | }, 18 | size: { 19 | default: 'w-24', 20 | sm: 'w-14', 21 | }, 22 | border: { 23 | gray: 'border-gray', 24 | }, 25 | }, 26 | defaultVariants: { 27 | backgroundColor: 'default', 28 | textColor: 'default', 29 | size: 'default', 30 | }, 31 | }, 32 | ); 33 | 34 | export interface ButtonProps 35 | extends ButtonHTMLAttributes, 36 | VariantProps { 37 | children: ReactNode; 38 | } 39 | 40 | export const Button = ({ 41 | type = 'button', 42 | backgroundColor, 43 | textColor, 44 | size, 45 | children, 46 | className, 47 | ...props 48 | }: ButtonProps) => { 49 | return ( 50 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stocks/components/StockInfoCard.tsx: -------------------------------------------------------------------------------- 1 | import { type GetStockTopViewsResponse } from '@/apis/queries/stocks'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | interface StockInfoCardProps extends GetStockTopViewsResponse { 5 | index: number; 6 | onClick: () => void; 7 | } 8 | 9 | export const StockInfoCard = ({ 10 | name, 11 | currentPrice, 12 | changeRate, 13 | index, 14 | onClick, 15 | }: Partial) => { 16 | return ( 17 |
24 |

{name}

25 |
26 |
27 | 등락률 28 | = 0 ? 'text-red' : 'text-blue', 32 | )} 33 | > 34 | {changeRate}% 35 | 36 |
37 |
38 | 현재가 39 | 40 | {currentPrice?.toLocaleString()}원 41 | 42 |
43 |
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/frontend/src/apis/queries/stock-detail/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const GetStockRequestSchema = z.object({ 4 | stockId: z.string(), 5 | }); 6 | 7 | export type GetStockRequest = z.infer; 8 | 9 | export const GetStockResponseSchema = z.object({ 10 | marketCap: z.number(), 11 | name: z.string(), 12 | eps: z.number(), 13 | per: z.string(), 14 | high52w: z.number(), 15 | low52w: z.number(), 16 | }); 17 | 18 | export type GetStockResponse = z.infer; 19 | 20 | export const PostStockRequestSchema = z.object({ 21 | stockId: z.string(), 22 | }); 23 | 24 | export type PostStockRequest = z.infer; 25 | 26 | export const PostStockResponseSchema = z.object({ 27 | id: z.string(), 28 | message: z.string(), 29 | date: z.string().datetime(), 30 | }); 31 | 32 | export type PostStockResponse = z.infer; 33 | 34 | export const GetStockOwnershipResponseSchema = z.object({ 35 | isOwner: z.boolean(), 36 | date: z.string().datetime(), 37 | }); 38 | 39 | export type GetStockOwnershipResponse = z.infer< 40 | typeof GetStockOwnershipResponseSchema 41 | >; 42 | 43 | export const DeleteStockUserRequestSchema = z.object({ 44 | stockId: z.string(), 45 | }); 46 | 47 | export type DeleteStockUserRequest = z.infer< 48 | typeof DeleteStockUserRequestSchema 49 | >; 50 | 51 | export const DeleteStockUserSchema = z.object({ 52 | id: z.string(), 53 | message: z.string(), 54 | date: z.string().datetime(), 55 | }); 56 | 57 | export type DeleteStockUser = z.infer; 58 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/api/openapiRankView.api.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Cron } from '@nestjs/schedule'; 3 | import { DataSource } from 'typeorm'; 4 | import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; 5 | import { OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; 6 | import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; 7 | import { Stock } from '@/stock/domain/stock.entity'; 8 | 9 | @Injectable() 10 | export class OpenapiRankViewApi { 11 | private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; 12 | 13 | constructor( 14 | private readonly datasource: DataSource, 15 | private readonly openApiLiveData: OpenapiLiveData, 16 | private readonly openApiQueue: OpenapiQueue, 17 | ) { 18 | setTimeout(() => this.getTopViewsStockLiveData(), 6000); 19 | } 20 | 21 | @Cron('*/1 9-15 * * 1-5') 22 | async getTopViewsStockLiveData() { 23 | const date = await this.findTopViewsStocks(); 24 | date.forEach((stock) => { 25 | this.openApiQueue.enqueue({ 26 | url: this.liveUrl, 27 | query: { 28 | fid_cond_mrkt_div_code: 'J', 29 | fid_input_iscd: stock.id, 30 | }, 31 | trId: TR_IDS.LIVE_DATA, 32 | callback: this.openApiLiveData.getLiveDataSaveCallback(stock.id), 33 | }); 34 | }); 35 | } 36 | 37 | private async findTopViewsStocks() { 38 | return await this.datasource.manager 39 | .getRepository(Stock) 40 | .createQueryBuilder('stock') 41 | .orderBy('stock.views', 'DESC') 42 | .limit(10) 43 | .getMany(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stocks/components/StockIndexCard.tsx: -------------------------------------------------------------------------------- 1 | import { StockIndexResponse } from '@/apis/queries/stocks'; 2 | import { cn } from '@/utils/cn'; 3 | 4 | export const StockIndexCard = ({ 5 | name, 6 | currentPrice, 7 | changeRate, 8 | high, 9 | low, 10 | open, 11 | }: Partial) => { 12 | return ( 13 |
14 |

{name}

15 |
16 | {currentPrice} 17 | = 0 ? 'text-red' : 'text-blue', 21 | )} 22 | > 23 | {changeRate}% 24 | 25 |
26 |
27 |
28 | 시가 29 | {open?.toLocaleString()} 30 |
31 |
32 | 고가 33 | {high?.toLocaleString()} 34 |
35 |
36 | 저가 37 | {low?.toLocaleString()} 38 |
39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/backend/src/chat/dto/chat.request.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 3 | 4 | export class ChatScrollQuery { 5 | @ApiProperty({ 6 | description: '종목 주식 id(종목방 id)', 7 | example: '005930', 8 | }) 9 | @IsString() 10 | stockId: string; 11 | 12 | @ApiProperty({ 13 | description: '최신 채팅 id', 14 | example: 99999, 15 | required: false, 16 | }) 17 | @IsOptional() 18 | @IsNumber() 19 | latestChatId?: number; 20 | 21 | @ApiProperty({ 22 | description: '페이지 크기', 23 | example: 20, 24 | default: 20, 25 | required: false, 26 | }) 27 | @IsOptional() 28 | @IsNumber() 29 | pageSize?: number; 30 | } 31 | 32 | export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { 33 | if (typeof object !== 'object' || object === null) { 34 | return false; 35 | } 36 | if (!('stockId' in object) || typeof object.stockId !== 'string') { 37 | return false; 38 | } 39 | if ( 40 | 'latestChatId' in object && 41 | !Number.isInteger(Number(object.latestChatId)) 42 | ) { 43 | return false; 44 | } 45 | return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); 46 | } 47 | 48 | export class SortedChatScrollQuery extends ChatScrollQuery { 49 | @ApiProperty({ 50 | description: '정렬 기준(기본은 최신 순)', 51 | example: 'latest', 52 | enum: ['latest', 'like'], 53 | required: false, 54 | }) 55 | @IsOptional() 56 | order: string; 57 | } 58 | 59 | export interface ChatMessage { 60 | room: string; 61 | content: string; 62 | nickname: string; 63 | subName: string; 64 | } 65 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/bell.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/backend/src/auth/google/strategy/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20'; 4 | import { GoogleAuthService } from '@/auth/google/googleAuth.service'; 5 | import { OauthType } from '@/user/domain/ouathType'; 6 | 7 | export interface OauthUserInfo { 8 | type: OauthType; 9 | oauthId: string; 10 | email?: string; 11 | givenName?: string; 12 | familyName?: string; 13 | } 14 | 15 | @Injectable() 16 | export class GoogleStrategy extends PassportStrategy(Strategy) { 17 | constructor(private readonly googleAuthService: GoogleAuthService) { 18 | super({ 19 | clientID: process.env.GOOGLE_CLIENT_ID, 20 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 21 | callbackURL: process.env.GOOGLE_CALLBACK_URL, 22 | scope: ['email', 'profile'], 23 | }); 24 | } 25 | 26 | authorizationParams(): { [key: string]: string } { 27 | return { 28 | access_type: 'offline', 29 | prompt: 'select_account', 30 | }; 31 | } 32 | 33 | async validate( 34 | accessToken: string, 35 | refreshToken: string, 36 | profile: Profile, 37 | done: VerifyCallback, 38 | ) { 39 | const { id, emails, name, provider } = profile; 40 | 41 | const userInfo = { 42 | type: provider.toUpperCase() as OauthType, 43 | oauthId: id, 44 | email: emails?.[0].value, 45 | givenName: name?.givenName, 46 | familyName: name?.familyName, 47 | }; 48 | const user = await this.googleAuthService.attemptAuthentication(userInfo); 49 | done(null, user); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/frontend/src/pages/stock-detail/NotificationPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { useGetStockAlarm } from '@/apis/queries/alarm'; 4 | import { Alarm } from '@/components/ui/alarm'; 5 | import { LoginContext } from '@/contexts/login'; 6 | import { cn } from '@/utils/cn'; 7 | 8 | interface NotificationPanelProps { 9 | className?: string; 10 | } 11 | 12 | export const NotificationPanel = ({ className }: NotificationPanelProps) => { 13 | return ( 14 |
20 |

알림

21 |
22 | 23 |
24 |
25 | ); 26 | }; 27 | 28 | const NotificationContents = () => { 29 | const { stockId = '' } = useParams(); 30 | const { isLoggedIn } = useContext(LoginContext); 31 | 32 | const { data } = useGetStockAlarm({ stockId, isLoggedIn }); 33 | 34 | if (!isLoggedIn) { 35 | return

로그인 후 이용 가능해요.

; 36 | } 37 | 38 | if (!data) { 39 | return

알림 정보를 불러오는 데 실패했어요.

; 40 | } 41 | 42 | if (data.length === 0) { 43 | return

현재 설정된 알림이 없어요.

; 44 | } 45 | 46 | return data.map((alarm) => ( 47 | 53 | )); 54 | }; 55 | -------------------------------------------------------------------------------- /packages/backend/src/scraper/openapi/api/openapi.abstract.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from 'typeorm'; 2 | import { openApiConfig } from '../config/openapi.config'; 3 | import { OpenapiTokenApi } from './openapiToken.api'; 4 | import { Stock } from '@/stock/domain/stock.entity'; 5 | 6 | export abstract class Openapi { 7 | constructor( 8 | protected readonly datasource: DataSource, 9 | protected readonly config: OpenapiTokenApi, 10 | protected readonly gapTime: number, 11 | ) {} 12 | protected abstract step(idx: number, stock: Stock): Promise; 13 | 14 | protected abstract getFromUrl( 15 | config: typeof openApiConfig, 16 | stockId: string, 17 | ): object; 18 | 19 | protected abstract convertResToEntity(res: object, stockId: string): object; 20 | 21 | protected abstract save(entity: object): Promise; 22 | 23 | async start() { 24 | const stock = await this.getStockId(); 25 | const len = (await this.config.configs()).length; 26 | const stockSize = Math.ceil(stock.length / len); 27 | let i = 0; 28 | while (i < len) { 29 | this.interval(i, stock.slice(i * stockSize, (i + 1) * stockSize)); 30 | i++; 31 | } 32 | } 33 | 34 | protected async interval(idx: number, stocks: Stock[]) { 35 | let time = 0; 36 | for (const stock of stocks) { 37 | setTimeout(() => this.step(idx, stock), time); 38 | time += this.gapTime; 39 | } 40 | } 41 | 42 | protected async getStockId() { 43 | const entity = Stock; 44 | const manager = this.datasource.manager; 45 | const result = await manager.find(entity, { 46 | select: { id: true }, 47 | where: { isTrading: true }, 48 | }); 49 | return result; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/backend/src/stock/decorator/stock.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { 3 | applyDecorators, 4 | DefaultValuePipe, 5 | ParseIntPipe, 6 | Query, 7 | } from '@nestjs/common'; 8 | import { 9 | ApiOkResponse, 10 | ApiOperation, 11 | ApiQuery, 12 | ApiResponse, 13 | } from '@nestjs/swagger'; 14 | import { StockRankResponses, StocksResponse } from '../dto/stock.response'; 15 | 16 | export function LimitQuery(defaultValue = 5): ParameterDecorator { 17 | return Query('limit', new DefaultValuePipe(defaultValue), ParseIntPipe); 18 | } 19 | 20 | export function ApiGetStocks(summary: string) { 21 | return applyDecorators( 22 | ApiOperation({ 23 | summary, 24 | }), 25 | ApiQuery({ 26 | name: 'limit', 27 | required: false, 28 | type: Number, 29 | description: '주식 리스트의 요소수', 30 | }), 31 | ApiResponse({ 32 | status: 200, 33 | description: `주식 리스트 데이터 성공적으로 조회`, 34 | type: [StocksResponse], 35 | }), 36 | ); 37 | } 38 | 39 | export function ApiFluctuationQuery() { 40 | return applyDecorators( 41 | ApiOperation({ 42 | summary: '등가, 등락률 기반 주식 리스트 조회 API', 43 | description: '등가, 등락률 기반 주식 리스트를 조회합니다', 44 | }), 45 | ApiQuery({ 46 | name: 'limit', 47 | required: false, 48 | description: 49 | '조회할 리스트 수(기본값: 20, 등가, 등락 모두 받으면 모든 데이터 전송)', 50 | }), 51 | ApiQuery({ 52 | name: 'type', 53 | required: false, 54 | description: '데이터 타입(기본값: increase, all, increase, decrease)', 55 | enum: ['increase', 'decrease', 'all'], 56 | }), 57 | ApiOkResponse({ 58 | description: '', 59 | type: [StockRankResponses], 60 | }), 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /packages/backend/src/user/dto/user.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { OauthType } from '@/user/domain/ouathType'; 3 | import { User } from '@/user/domain/user.entity'; 4 | 5 | interface UserResponse { 6 | nickname: string; 7 | subName: string; 8 | createdAt: Date; 9 | } 10 | 11 | export class UserSearchResult { 12 | @ApiProperty({ 13 | description: '유저 검색 결과', 14 | example: [ 15 | { 16 | nickname: 'nickname', 17 | subName: 'subName', 18 | createdAt: new Date(), 19 | }, 20 | ], 21 | }) 22 | result: UserResponse[]; 23 | 24 | constructor(users: User[]) { 25 | this.result = users.map((user) => ({ 26 | nickname: user.nickname, 27 | subName: user.subName, 28 | createdAt: user.date.createdAt, 29 | })); 30 | } 31 | } 32 | 33 | export class UserInformationResponse { 34 | @ApiProperty({ 35 | description: '유저 닉네임', 36 | example: 'nickname', 37 | }) 38 | nickname: string; 39 | 40 | @ApiProperty({ 41 | description: '유저 서브 닉네임', 42 | example: 'subName', 43 | }) 44 | subName: string; 45 | 46 | @ApiProperty({ 47 | description: '유저 생성일', 48 | example: new Date(), 49 | }) 50 | createdAt: Date; 51 | 52 | @ApiProperty({ 53 | description: '유저 이메일', 54 | example: 'test@nav.com', 55 | }) 56 | email: string; 57 | 58 | @ApiProperty({ 59 | description: '유저타입 (google: 구글 로그인, local: 테스터)', 60 | example: new Date(), 61 | }) 62 | type: OauthType; 63 | 64 | constructor(user: User) { 65 | this.nickname = user.nickname; 66 | this.subName = user.subName; 67 | this.createdAt = user.date.createdAt; 68 | this.email = user.email; 69 | this.type = user.type; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/backend/src/stock/stockRateIndex.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { DataSource } from 'typeorm'; 3 | import { Logger } from 'winston'; 4 | import { StockLiveData } from './domain/stockLiveData.entity'; 5 | import { StockIndexRateResponse } from './dto/stockIndexRate.response'; 6 | import { IndexRateGroupCode } from '@/scraper/openapi/type/openapiIndex.type'; 7 | 8 | @Injectable() 9 | export class StockRateIndexService { 10 | constructor( 11 | private readonly datasource: DataSource, 12 | @Inject('winston') private readonly logger: Logger, 13 | ) {} 14 | 15 | async getRateIndexData(groupCode: IndexRateGroupCode) { 16 | const result = await this.datasource.manager.find(StockLiveData, { 17 | where: { stock: { groupCode } }, 18 | relations: ['stock'], 19 | }); 20 | 21 | if (!result.length) { 22 | this.logger.warn(`Rate data not found for group code: ${groupCode}`); 23 | throw new NotFoundException('Rate data not found'); 24 | } 25 | return result; 26 | } 27 | 28 | async getStockRateData() { 29 | const groupCode: IndexRateGroupCode = 'RATE'; 30 | const result = await this.getRateIndexData(groupCode); 31 | return result.map((val) => new StockIndexRateResponse(val)); 32 | } 33 | async getStockIndexData() { 34 | const groupCode: IndexRateGroupCode = 'INX'; 35 | const result = await this.getRateIndexData(groupCode); 36 | return result.map((val) => new StockIndexRateResponse(val)); 37 | } 38 | async getStockRateIndexDate(): Promise { 39 | const index = await this.getStockIndexData(); 40 | const rate = await this.getStockRateData(); 41 | return [...index, ...rate]; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/backend/src/chat/dto/like.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Chat } from '@/chat/domain/chat.entity'; 3 | 4 | export class LikeResponse { 5 | @ApiProperty({ 6 | type: Number, 7 | description: '좋아요를 누른 채팅의 ID', 8 | example: 1, 9 | }) 10 | chatId: number; 11 | 12 | @ApiProperty({ 13 | type: 'string', 14 | description: '참여 중인 좀목 id', 15 | example: '005930', 16 | }) 17 | stockId: string; 18 | 19 | @ApiProperty({ 20 | type: Number, 21 | description: '채팅의 좋아요 수', 22 | example: 45, 23 | }) 24 | likeCount: number; 25 | 26 | @ApiProperty({ 27 | type: String, 28 | description: '결과 메시지', 29 | example: 'like chat', 30 | }) 31 | message: string; 32 | 33 | @ApiProperty({ 34 | type: Date, 35 | description: '좋아요를 누른 시간', 36 | example: '2021-08-01T00:00:00', 37 | }) 38 | date: Date; 39 | 40 | static createLikeResponse(chat: Chat): LikeResponse { 41 | if (!isStockId(chat.stock.id)) { 42 | throw new Error(`Stock id is undefined: ${chat.id}`); 43 | } 44 | return { 45 | stockId: chat.stock.id, 46 | chatId: chat.id, 47 | likeCount: chat.likeCount, 48 | message: 'like chat', 49 | date: chat.date.updatedAt, 50 | }; 51 | } 52 | 53 | static createUnlikeResponse(chat: Chat): LikeResponse { 54 | if (!isStockId(chat.stock.id)) { 55 | throw new Error(`Stock id is undefined: ${chat.id}`); 56 | } 57 | return { 58 | stockId: chat.stock.id, 59 | chatId: chat.id, 60 | likeCount: chat.likeCount, 61 | message: 'like cancel', 62 | date: chat.date.updatedAt, 63 | }; 64 | } 65 | } 66 | 67 | function isStockId(stockId?: string): stockId is string { 68 | return stockId !== undefined; 69 | } 70 | -------------------------------------------------------------------------------- /packages/backend/src/common/cache/localCache.ts: -------------------------------------------------------------------------------- 1 | import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; 2 | 3 | type CacheEntry = { 4 | value: T; 5 | expiredAt: number; 6 | }; 7 | 8 | type CacheQueueEntry = { 9 | value: T; 10 | expiredAt: number; 11 | }; 12 | 13 | export class LocalCache { 14 | private readonly localCache: Map> = new Map(); 15 | private readonly ttlQueue: PriorityQueue> = 16 | new PriorityQueue(); 17 | 18 | constructor(private readonly interval = 500) { 19 | setInterval(() => this.clearExpired(), interval); 20 | } 21 | 22 | get(key: K) { 23 | const entry = this.localCache.get(key); 24 | if (!entry) { 25 | return null; 26 | } 27 | if (entry.expiredAt < Date.now()) { 28 | this.localCache.delete(key); 29 | return null; 30 | } 31 | return entry.value; 32 | } 33 | 34 | async set(key: K, value: V, ttl: number) { 35 | const expiredAt = Date.now() + ttl; 36 | this.localCache.set(key, { value, expiredAt }); 37 | this.ttlQueue.enqueue({ value: key, expiredAt }, expiredAt); 38 | } 39 | 40 | async delete(key: K) { 41 | this.localCache.delete(key); 42 | } 43 | 44 | clear() { 45 | this.localCache.clear(); 46 | } 47 | 48 | private clearExpired() { 49 | const now = Date.now(); 50 | while (!this.ttlQueue.isEmpty()) { 51 | const key = this.ttlQueue.dequeue()!; 52 | if (key.expiredAt > now) { 53 | this.ttlQueue.enqueue(key, key.expiredAt); 54 | break; 55 | } 56 | if ( 57 | this.localCache.has(key.value) && 58 | this.localCache.get(key.value)!.expiredAt === key.expiredAt 59 | ) { 60 | this.localCache.delete(key.value); 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/frontend/src/assets/date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Req, Res } from '@nestjs/common'; 2 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { Request, Response } from 'express'; 4 | import { sessionConfig } from '@/configs/session.config'; 5 | import { User } from '@/user/domain/user.entity'; 6 | 7 | @ApiTags('Auth') 8 | @Controller('auth') 9 | export class AuthController { 10 | @ApiOperation({ 11 | summary: '로그아웃', 12 | description: '로그아웃을 진행한다.', 13 | }) 14 | @Post('/logout') 15 | logout(@Req() req: Request, @Res() res: Response) { 16 | req.logout((err) => { 17 | if (err) { 18 | return res 19 | .status(500) 20 | .send({ message: 'Failed to logout', error: err }); 21 | } 22 | req.session.destroy((destroyErr) => { 23 | if (destroyErr) { 24 | return res 25 | .status(500) 26 | .send({ message: 'Failed to destroy session', error: destroyErr }); 27 | } 28 | res.clearCookie(sessionConfig.name || 'connect.sid'); 29 | return res.status(200).send({ message: 'Logged out successfully' }); 30 | }); 31 | }); 32 | } 33 | 34 | @ApiOperation({ 35 | summary: '로그인 상태 확인', 36 | description: '로그인 상태를 확인합니다.', 37 | }) 38 | @ApiOkResponse({ 39 | description: '로그인된 상태', 40 | example: { message: 'Authenticated' }, 41 | }) 42 | @Get('/status') 43 | async user(@Req() request: Request) { 44 | if (request.user) { 45 | const user = request.user as User; 46 | return { 47 | message: 'Authenticated', 48 | nickname: user.nickname, 49 | subName: user.subName, 50 | }; 51 | } 52 | return { message: 'Not Authenticated', nickname: null, subName: null }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/backend/src/auth/session/webSocketSession.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Inject, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { WsException } from '@nestjs/websockets'; 8 | import { MemoryStore, SessionData } from 'express-session'; 9 | import { Socket } from 'socket.io'; 10 | import { websocketCookieParse } from '@/auth/session/cookieParser'; 11 | import { MEMORY_STORE } from '@/auth/session.module'; 12 | import { User } from '@/user/domain/user.entity'; 13 | import { UserService } from '@/user/user.service'; 14 | 15 | export interface SessionSocket extends Socket { 16 | session?: User; 17 | } 18 | 19 | export interface PassportSession extends SessionData { 20 | passport: { user: number }; 21 | } 22 | 23 | @Injectable() 24 | export class WebSocketSessionGuard implements CanActivate { 25 | constructor( 26 | @Inject(MEMORY_STORE) private readonly sessionStore: MemoryStore, 27 | private readonly userService: UserService, 28 | ) {} 29 | async canActivate(context: ExecutionContext): Promise { 30 | const socket: SessionSocket = context.switchToHttp().getRequest(); 31 | const cookieValue = websocketCookieParse(socket); 32 | const session = await this.getSession(cookieValue); 33 | const user = await this.userService.findUserById(session.passport.user); 34 | if (!user) { 35 | return false; 36 | } 37 | socket.session = user; 38 | return true; 39 | } 40 | 41 | private getSession(cookieValue: string) { 42 | return new Promise((resolve, reject) => { 43 | this.sessionStore.get(cookieValue, (err: Error, session) => { 44 | if (err || !session) { 45 | reject(new WsException('forbidden chat')); 46 | } 47 | resolve(session as PassportSession); 48 | }); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/backend/src/user/domain/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | Index, 5 | OneToMany, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { Alarm } from '@/alarm/domain/alarm.entity'; 9 | import { PushSubscription } from '@/alarm/domain/subscription.entity'; 10 | import { Mention } from '@/chat/domain/mention.entity'; 11 | import { DateEmbedded } from '@/common/dateEmbedded.entity'; 12 | import { UserStock } from '@/stock/domain/userStock.entity'; 13 | import { OauthType } from '@/user/domain/ouathType'; 14 | import { Role } from '@/user/domain/role'; 15 | 16 | @Index('nickname_sub_name', ['nickname', 'subName'], { unique: true }) 17 | @Index('type_oauth_id', ['type', 'oauthId'], { unique: true }) 18 | @Entity({ name: 'users' }) 19 | export class User { 20 | @PrimaryGeneratedColumn() 21 | id: number; 22 | 23 | @Column({ length: 50 }) 24 | nickname: string; 25 | 26 | @Column({ length: 10, default: '0001' }) 27 | subName: string; 28 | 29 | @Column({ length: 50 }) 30 | email: string; 31 | 32 | @Column({ length: 5, default: Role.USER }) 33 | role: Role = Role.USER; 34 | 35 | @Column({ length: 10, default: OauthType.LOCAL }) 36 | type: OauthType = OauthType.LOCAL; 37 | 38 | @Column('decimal', { name: 'oauth_id' }) 39 | oauthId: string; 40 | 41 | @Column({ name: 'is_light', default: true }) 42 | isLight: boolean = true; 43 | 44 | @Column(() => DateEmbedded, { prefix: '' }) 45 | date: DateEmbedded; 46 | 47 | @OneToMany(() => UserStock, (userStock) => userStock.user) 48 | userStocks: UserStock[]; 49 | 50 | @OneToMany(() => Alarm, (alarm) => alarm.user) 51 | alarms: Alarm[]; 52 | 53 | @OneToMany(() => PushSubscription, (subscription) => subscription.user) 54 | subscriptions: PushSubscription[]; 55 | 56 | @OneToMany(() => Mention, (mention) => mention.user) 57 | mentions: Mention[]; 58 | } 59 | -------------------------------------------------------------------------------- /packages/frontend/src/components/layouts/MenuList.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { type MenuSection } from '@/types/menu'; 4 | import { cn } from '@/utils/cn'; 5 | 6 | interface MenuListProps { 7 | items: MenuSection[]; 8 | isHovered: boolean; 9 | onItemClick?: (item: MenuSection) => void; 10 | } 11 | 12 | interface MenuItemProps { 13 | icon: ReactNode; 14 | text: string; 15 | isHovered: boolean; 16 | onClick?: () => void; 17 | } 18 | 19 | export const MenuList = ({ items, isHovered, onItemClick }: MenuListProps) => { 20 | const navigate = useNavigate(); 21 | 22 | const handleClick = (item: MenuSection) => { 23 | if (item.path) { 24 | navigate(item.path); 25 | } 26 | 27 | onItemClick?.(item); 28 | }; 29 | 30 | return ( 31 |
    32 | {items.map((item) => ( 33 | handleClick(item)} 39 | /> 40 | ))} 41 |
42 | ); 43 | }; 44 | 45 | const MenuItem = ({ icon, text, isHovered, onClick }: MenuItemProps) => { 46 | return ( 47 |
  • 48 | 54 |

    61 | {text} 62 |

    63 |
  • 64 | ); 65 | }; 66 | --------------------------------------------------------------------------------