Skip to content

Commit

Permalink
feat: 투표 알림 기능 (#669)
Browse files Browse the repository at this point in the history
* feat: ensure djs clients are singleton instances

* feat: add votes table

* feat: vote notification

* chore: reduce vote cooldown to 15 min

* feat: add SetNotification to server

* chore: add debug logs

* fix: do not add notification when token and voteid already exists

* feat: add loading indicator

* feat: refresh notification when voted

* feat: add opt-out

* feat: add debug log

* fix: initialize firebase app

* fix: remove app on messaging

* feat: show notifications only with service worker

* fix: state improperly used

* fix: schedule notification if notification is newly added

* chore:  remove duplicated notification

* chore: add spacing

* chore: get token if notification is granted

* chore: change vote cooldown to 12 hours

* chore: remove logging
  • Loading branch information
skinmaker1345 authored Feb 16, 2025
1 parent e99e661 commit 160fe4e
Show file tree
Hide file tree
Showing 14 changed files with 1,597 additions and 77 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ yarn-error.log*
package-lock.json

# sub module
api-docs/
api-docs/

# Firebase
service-account.json
165 changes: 165 additions & 0 deletions components/FCM.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import Fetch from '@utils/Fetch'
import { initializeApp } from 'firebase/app'
import { getMessaging, getToken as getFirebaseToken } from 'firebase/messaging'
import { useState } from 'react'
import Button from './Button'

export async function getFCMToken() {
try {
const app = initializeApp({
apiKey: 'AIzaSyDWnwXCBaP1C627gfIBQxyZbmNnAU_b_1Q',
authDomain: 'koreanlist-e95d9.firebaseapp.com',
projectId: 'koreanlist-e95d9',
storageBucket: 'koreanlist-e95d9.firebasestorage.app',
messagingSenderId: '716438516411',
appId: '1:716438516411:web:cddd6c7cc3b0571fa4af9e',
})

const worker = await navigator.serviceWorker.register('/vote-notification-sw.js')

const messaging = getMessaging(app)
const token = await getFirebaseToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_FCM_VAPID_KEY,
serviceWorkerRegistration: worker,
})
return token
} catch (e) {
return null
}
}

function SetNotification({ id, notificationSet }: { id: string; notificationSet: boolean }) {
const [state, setState] = useState(notificationSet ? 1 : 0)
const [hold, setHold] = useState(false)

const getToken = async () => {
if (!('serviceWorker' in navigator)) {
setState(4)
return 'NO_SERVICE_WORKER'
}

if (!('Notification' in window)) {
setState(4)
return 'NO_NOTIFICATION'
}

const p = await Notification.requestPermission()
if (p !== 'granted') {
setState(5)
return 'PERMISSION_DENIED'
}

const token = await getFCMToken()

if (!token) {
setState(4)
return
}

const result = await Fetch('/users/notification', {
method: 'POST',
body: JSON.stringify({
token,
targetId: id,
}),
})

if (result.code === 200) {
setState(2)
} else {
setState(4)
}
}
const components = {
0: (
<>
<p className='whitespace-pre-line text-lg font-normal'>
12시간 후에 이 기기로 알림을 받으려면 아래 버튼을 눌러주세요.
</p>
<Button
disabled={hold}
onClick={() => {
setHold(true)
getToken()
.then(() => {
setHold(false)
})
.catch(() => {
setState(4)
})
}}
>
<>
<i className='far fa-bell' /> {hold ? '설정 중...' : '알림 설정'}
</>
</Button>
</>
),
1: (
<>
<p className='whitespace-pre-line text-lg font-normal'>
이 기기로 알림을 수신하고 있습니다. 알림을 해제하려면 아래 버튼을 눌러주세요.
</p>
<Button
disabled={hold}
onClick={() => {
setHold(true)
getFCMToken()
.then(async (token) => {
await Fetch('/users/notification', {
method: 'DELETE',
body: JSON.stringify({
token,
targetId: id,
}),
})
setHold(false)
setState(3)
})
.catch(() => {
setState(4)
})
}}
>
<>
<i className='far fa-bell-slash mr-1' /> {hold ? '설정 중...' : '알림 해제'}
</>
</Button>
</>
),
2: (
<>
<p className='whitespace-pre-line text-lg font-normal'>알림이 설정되었습니다.</p>
</>
),
3: (
<>
<p className='whitespace-pre-line text-lg font-normal'>알림이 해제되었습니다.</p>
</>
),
4: (
<>
<p className='whitespace-pre-line text-lg font-normal'>
알림을 설정할 수 없습니다. 사용하는 브라우저를 점검해주세요. {'\n'}
iOS 사용자는 Safari 브라우저에서 한국 디스코드 리스트를 홈 화면에 추가해야 합니다.
</p>
</>
),
5: (
<>
<p className='whitespace-pre-line text-lg font-normal'>
알림이 허용되지 않았습니다. 브라우저 설정에서 알림을 허용해주세요.
</p>
</>
),
}
return (
components[state] ?? (
<p className='whitespace-pre-line text-lg font-normal'>
알림을 설정할 수 없습니다. 사용하는 브라우저를 점검해주세요.
</p>
)
)
}

export default SetNotification
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"emoji-mart": "3.0.1",
"erlpack": "0.1.4",
"express-rate-limit": "^5.3.0",
"firebase": "^11.2.0",
"firebase-admin": "^13.0.2",
"formik": "2.4.2",
"generate-license-file": "1.1.0",
"josa": "3.0.1",
Expand Down
14 changes: 12 additions & 2 deletions pages/api/v2/bots/[id]/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ const BotVote = RequestHandler()
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })

const vote = await put.voteBot(user, bot.id)

const token = req.body.firebaseToken
let notificationSet = false

if (token) {
const noti = await get.notifications.token(token, bot.id)
notificationSet = !!noti
}

if (vote === null) return ResponseWrapper(res, { code: 401 })
else if (vote === true) {
get.bot.clear(req.query.id)
Expand All @@ -55,8 +64,8 @@ const BotVote = RequestHandler()
},
timestamp: Date.now(),
})
return ResponseWrapper(res, { code: 200 })
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
return ResponseWrapper(res, { code: 200, data: { notificationSet } })
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote, notificationSet } })
})

interface ApiRequest extends NextApiRequest {
Expand All @@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
body: {
_captcha: string
_csrf: string
firebaseToken?: string | null
}
}
export default BotVote
14 changes: 12 additions & 2 deletions pages/api/v2/servers/[id]/vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ const ServerVote = RequestHandler()
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })

const vote = await put.voteServer(user, server.id)

const token = req.body.firebaseToken
let notificationSet = false

if (token) {
const result = await get.notifications.token(token, server.id)
notificationSet = !!result
}

if (vote === null) return ResponseWrapper(res, { code: 401 })
else if (vote === true) {
get.server.clear(req.query.id)
Expand All @@ -55,8 +64,8 @@ const ServerVote = RequestHandler()
},
timestamp: Date.now(),
})
return ResponseWrapper(res, { code: 200 })
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
return ResponseWrapper(res, { code: 200, data: { notificationSet } })
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote, notificationSet } })
})

interface ApiRequest extends NextApiRequest {
Expand All @@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
body: {
_captcha: string
_csrf: string
firebaseToken?: string | null
}
}
export default ServerVote
49 changes: 49 additions & 0 deletions pages/api/v2/users/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { addNotification, get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'

const Notification = RequestHandler()
.get(async (req, res) => {
const token = req.query.token as string
const target = req.query.target as string
const user = await get.Authorization(req.cookies.token)
if (!user) return res.status(401).json({ code: 401 })

const result = token
? await get.notifications.token(token, target)
: await get.notifications.user(user)

if (!result) return res.status(400).json({ code: 400 })

return res.status(200).json({ code: 200, data: result })
})
.post(async (req, res) => {
const user = await get.Authorization(req.cookies.token)
if (!user) return res.status(401).json({ code: 401 })

const { token, targetId } = req.body

if (!token || !targetId)
return res.status(400).json({ code: 400, message: 'Either token or targetId is missing' })

const result = await addNotification({ token, targetId, userId: user })
if (typeof result === 'string') return res.status(400).json({ code: 400, message: result })

return res.status(200).json({ code: 200 })
})
.delete(async (req, res) => {
const user = await get.Authorization(req.cookies.token)

if (!user) return res.status(401).json({ code: 401 })

const { token, targetId } = req.body

if (!token) return res.status(400).json({ code: 400 })

const result = global.notification.removeNotification({ userId: user, targetId, token })

if (!result) return res.status(400).json({ code: 400 })

return res.status(200).json({ code: 200 })
})

export default Notification
30 changes: 25 additions & 5 deletions pages/bots/[id]/vote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import { ParsedUrlQuery } from 'querystring'
import NotFound from 'pages/404'
import { getToken } from '@utils/Csrf'
import Captcha from '@components/Captcha'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Fetch from '@utils/Fetch'
import Day from '@utils/Day'
import { getJosaPicker } from 'josa'
import { KoreanbotsEndPoints } from '@utils/Constants'
import { NextSeo } from 'next-seo'
import SetNotification, { getFCMToken } from '@components/FCM'

const Container = dynamic(() => import('@components/Container'))
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
Expand All @@ -30,7 +31,18 @@ const Message = dynamic(() => import('@components/Message'))

const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
const [votingStatus, setVotingStatus] = useState(0)
const [result, setResult] = useState<ResponseProps<{ retryAfter?: number }>>(null)
const [result, setResult] =
useState<ResponseProps<{ retryAfter?: number; notificationSet: boolean }>>(null)
const fcmTokenRef = useRef<string | null>('')

useEffect(() => {
if ('Notification' in window && Notification.permission === 'granted') {
getFCMToken().then((token) => {
fcmTokenRef.current = token
})
}
}, [])

const router = useRouter()
if (!data?.id) return <NotFound />
if (!user)
Expand Down Expand Up @@ -116,26 +128,34 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
<Captcha
dark={theme === 'dark'}
onVerify={async (key) => {
const res = await Fetch<{ retryAfter: number } | unknown>(
const res = await Fetch<{ retryAfter: number; notificationSet: boolean }>(
`/bots/${data.id}/vote`,
{
method: 'POST',
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
body: JSON.stringify({
_csrf: csrfToken,
_captcha: key,
firebaseToken: fcmTokenRef.current,
}),
}
)
setResult(res)
setVotingStatus(2)
}}
/>
) : result.code === 200 ? (
<h2 className='text-2xl font-bold'>해당 봇에 투표했습니다!</h2>
<>
<h2 className='text-2xl font-bold'>해당 봇에 투표했습니다!</h2>
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
</>
) : result.code === 429 ? (
<>
<h2 className='text-2xl font-bold'>이미 해당 봇에 투표하였습니다.</h2>
<h4 className='text-md mt-1'>
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
있습니다.
</h4>
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
</>
) : (
<p>{result.message}</p>
Expand Down
Loading

0 comments on commit 160fe4e

Please sign in to comment.