Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feat: #123 실시간 알림 api 연동 #127

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 2 additions & 30 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"es2021": true,
"jest": true
},
"extends": ["next/core-web-vitals", "airbnb", "airbnb-typescript"],
"extends": ["next/core-web-vitals"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
Expand All @@ -17,36 +17,8 @@
"plugins": ["react", "@typescript-eslint"],
"rules": {
"react-hooks/exhaustive-deps": "off",
"react/jsx-props-no-spreading": "off",
"linebreak-style": 0,
"import/no-extraneous-dependencies": "off",
"import/prefer-default-export": ["off", { "target": "single" }],
"object-curly-newline": "off",
"arrow-parens": "off",
"arrow-body-style": "off",
"@typescript-eslint/no-shadow": "off",
"operator-linebreak": "off",
"react/require-default-props": "off",
"implicit-arrow-linebreak": "off",
"@typescript-eslint/naming-convention": "off",
"consistent-return": "off",
"@typescript-eslint/indent": "off",
"jsx-a11y/control-has-associated-label": "off",
"react/jsx-wrap-multilines": "off",
"react/jsx-indent": "off",
"react/no-invalid-html-attribute": "off",
"eslint-plugin-import/no-cycle": "off",
"import/no-cycle":"off",
"no-param-reassign":"off",
"@typescript-eslint/no-unused-vars": "warn", //사용안한 변수는 경고처리
"react/jsx-curly-newline": "off", // jsx안에 }를 새로운 라인에 사용할 수 있다.
"@typescript-eslint/no-use-before-define": ["warn"], // 선언하기 전에 사용 한다면 경고
"jsx-a11y/label-has-associated-control": [
2,
{
"labelAttributes": ["htmlFor"]
}
]
"react/display-name": "off"
},
"globals": {
"React": "writable"
Expand Down
10 changes: 10 additions & 0 deletions init-https.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/bash

MKCERT_INSTALLED=$(which mkcert)

if [ -z $MKCERT_INSTALLED ];then
brew install mkcert
fi

mkcert -install
mkcert localhost
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"init-https": "sh init-https.sh",
"dev": "next dev",
"build": "next build",
"build:dev": "env-cmd -f .env.development next build",
Expand Down Expand Up @@ -40,6 +41,7 @@
"dayjs": "^1.11.10",
"embla-carousel-autoplay": "^8.0.0-rc22",
"embla-carousel-react": "^8.0.0-rc17",
"event-source-polyfill": "^1.0.31",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.299.0",
"next": "14.0.3",
Expand Down Expand Up @@ -73,6 +75,7 @@
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@types/crypto-js": "^4.2.1",
"@types/event-source-polyfill": "^1.0.5",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.4",
Expand Down
40 changes: 40 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const http = require('http');
const { parse } = require('url');
const next = require('next');

const https = require('https');
const fs = require('fs');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

const PORT = 3000;

const httpsOptions = {
key: fs.readFileSync('./key.pem'),
cert: fs.readFileSync('./cert.pem'),
};

app.prepare().then(() => {
http
.createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
})
.listen(PORT, err => {
if (err) throw err;
console.log(`> Ready on http://localhost:${PORT}`);
});

// https 서버 추가
https
.createServer(httpsOptions, (req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
})
.listen(PORT + 1, err => {
if (err) throw err;
console.log(`> HTTPS: Ready on https://localhost:${PORT + 1}`);
});
});
36 changes: 28 additions & 8 deletions src/app/notification/_components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,37 @@ import Link from 'next/link';
import Image from 'next/image';

import { Megaphone } from 'lucide-react';
import type { NotificationProps } from '@/types/notification';
import { COLOR } from '@/styles/color';
import type { NotificationProps } from '@/types/notification';
import getTimeDiff from '@/utils/getTimeDiff';
import { useRouter } from 'next/navigation';
import readNotification from '../api/readNotification';
import clsx from 'clsx';

// is_read 읽음 여부에 따른 디자인 변화 필요
function Card({ ...props }: NotificationProps) {
const { url, title, content, created_at, image } = props;
const { url, title, content, created_at, image, id, is_read } = props;
const router = useRouter();
const timeDiff = getTimeDiff(created_at);
const buttonClassName = (isRead?: boolean) =>
clsx(
'w-[calc(100%-32px)] bg-white rounded-[10px] border my-2 mx-4 flex px-[10px] py-[14px] gap-3',
{
'border-purple-200 bg-[#F2EDFF]': !isRead,
'border-grey-200 bg-white': isRead,
},
);

const handleClick = (id: number) => {
router.push(url);
readNotification(id);
};

return (
<Link
href={url}
className="bg-white rounded-[10px] border border-grey-200 my-2 mx-4 flex px-[10px] py-[14px] gap-3"
<button
type="button"
onClick={() => handleClick(id)}
className={buttonClassName(is_read)}
>
{image ? (
<Image
Expand All @@ -28,12 +48,12 @@ function Card({ ...props }: NotificationProps) {
</div>
)}

<div className="relative flex flex-col text-sm text-grey-800 w-full">
<div className="relative flex flex-col text-sm text-grey-800 w-full text-left">
<span className=" font-bold leading-4">{title}</span>
<span className="leading-[22px] line-clamp-2">{content}</span>
<span className="text-xs text-grey-700 pt-1">{created_at}</span>
<span className="text-xs text-grey-700 pt-1">{timeDiff}</span>
</div>
</Link>
</button>
);
}

Expand Down
47 changes: 47 additions & 0 deletions src/app/notification/api/fetchSse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { BACK_URL } from '@/constants/api';
import { END_POINT } from '@/constants/api/end-point';
import CustomError from '@/error/CustomError';
import { getCookie } from '@/lib/cookie';
import { decrypt } from '@/utils/crypto';
import { EventSourcePolyfill } from 'event-source-polyfill';

export const fetchSse = async () => {
const { main, getSubscribe } = END_POINT.notificationController;
const accessToken = decrypt(getCookie({ name: 'access_token' }));

try {
const eventSource = new EventSourcePolyfill(
BACK_URL + main + getSubscribe,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);

eventSource.onopen = () => {
console.log('Connection established.');
};

// 서버로부터 빈 메시지가 수신되면 이를 무시합니다.
eventSource.onmessage = event => {
if (event.data.trim() === '') {
return;
}
const eventData = JSON.parse(event.data);
console.log('Received message:', eventData);
// 여기서 이벤트 데이터를 처리하거나 화면에 표시하는 등의 작업을 수행합니다.
};

// 오류 처리 로직을 추가합니다.
eventSource.onerror = error => {
console.error('EventSource error:', error);
eventSource.close();
};
} catch (error: unknown) {
if (error instanceof CustomError) {
throw new Error(error.message);
}
throw new Error(String(error));
}
};
15 changes: 15 additions & 0 deletions src/app/notification/api/readNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import CustomError from '@/error/CustomError';
import { axiosInstance } from '@/lib/axios/axios-instance';
import { END_POINT } from '@/constants/api/end-point';

const readNotification = async (id: number) => {
try {
await axiosInstance.patch(END_POINT.notificationController.read(id));
} catch (error: unknown) {
if (error instanceof CustomError) {
throw new Error(error.message);
}
}
};

export default readNotification;
2 changes: 1 addition & 1 deletion src/app/service/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function Page() {
<section className="flex justify-between items-center px-4">
<Title primary />
<Link href={CALLBACK_URL.notification}>
<Bell strokeWidth={2.5} />
<Bell strokeWidth={2} />
</Link>
</section>
<Notice />
Expand Down
4 changes: 3 additions & 1 deletion src/app/sign-in/kakao/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { encrypt } from '@/utils/crypto';
import { useRouter } from 'next/navigation';
import { getEmail } from '@/utils/getEmail';
import { useToast } from '@/app/_components/ui/use-toast';
import { SIGN_UP_ERROR_MESSAGES } from '@/constants/sign-in';
import { setTokensInCookies } from '@/utils/setTokensInCookies';
import { fetchSse } from '@/app/notification/api/fetchSse';
import { ERROR_CODE, STATUS_CODE } from '@/constants/api/statusCode';
import { SIGN_UP_ERROR_MESSAGES } from '@/constants/sign-in';

// 카카오 소셜 로그인 REDIRECT URI PAGE
function Page() {
Expand Down Expand Up @@ -43,6 +44,7 @@ function Page() {
case STATUS_CODE.ok:
router.push(service);
setId(response.data.id);
await fetchSse();
break;

case invalidEmail:
Expand Down
10 changes: 8 additions & 2 deletions src/constants/api/end-point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,20 @@ export const END_POINT = {

notificationController: {
// DEFAULT
default: '/notification',
main: '/notification',

getNotification: (page: number) => {
const url = new URLSearchParams();

url.append('page', String(page));

return `${END_POINT.notificationController.default}?${url.toString()}`;
return `${END_POINT.notificationController.main}?${url.toString()}`;
},

read: (notificationId: number) => {
return `${END_POINT.notificationController.main}/${notificationId}/read`;
},

getSubscribe: '/subscribe',
},
};
6 changes: 6 additions & 0 deletions src/types/notification/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ export interface NotificationProps {
export interface NotificationData extends Pagination {
content: NotificationProps[];
}

export interface SubscribeResponse {
id: string;
event: string;
data: string;
}