mirror of
https://github.com/koreanbots/core.git
synced 2025-12-15 06:10:22 +00:00
feat: 투표 알림 기능 (#669)
* 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
This commit is contained in:
parent
e99e661b8d
commit
160fe4ecb3
5
.gitignore
vendored
5
.gitignore
vendored
@ -43,4 +43,7 @@ yarn-error.log*
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# sub module
|
# sub module
|
||||||
api-docs/
|
api-docs/
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
service-account.json
|
||||||
165
components/FCM.tsx
Normal file
165
components/FCM.tsx
Normal file
@ -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
|
||||||
@ -34,6 +34,8 @@
|
|||||||
"emoji-mart": "3.0.1",
|
"emoji-mart": "3.0.1",
|
||||||
"erlpack": "0.1.4",
|
"erlpack": "0.1.4",
|
||||||
"express-rate-limit": "^5.3.0",
|
"express-rate-limit": "^5.3.0",
|
||||||
|
"firebase": "^11.2.0",
|
||||||
|
"firebase-admin": "^13.0.2",
|
||||||
"formik": "2.4.2",
|
"formik": "2.4.2",
|
||||||
"generate-license-file": "1.1.0",
|
"generate-license-file": "1.1.0",
|
||||||
"josa": "3.0.1",
|
"josa": "3.0.1",
|
||||||
|
|||||||
@ -41,6 +41,15 @@ const BotVote = RequestHandler()
|
|||||||
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||||
|
|
||||||
const vote = await put.voteBot(user, bot.id)
|
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 })
|
if (vote === null) return ResponseWrapper(res, { code: 401 })
|
||||||
else if (vote === true) {
|
else if (vote === true) {
|
||||||
get.bot.clear(req.query.id)
|
get.bot.clear(req.query.id)
|
||||||
@ -55,8 +64,8 @@ const BotVote = RequestHandler()
|
|||||||
},
|
},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
return ResponseWrapper(res, { code: 200 })
|
return ResponseWrapper(res, { code: 200, data: { notificationSet } })
|
||||||
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote, notificationSet } })
|
||||||
})
|
})
|
||||||
|
|
||||||
interface ApiRequest extends NextApiRequest {
|
interface ApiRequest extends NextApiRequest {
|
||||||
@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
|
|||||||
body: {
|
body: {
|
||||||
_captcha: string
|
_captcha: string
|
||||||
_csrf: string
|
_csrf: string
|
||||||
|
firebaseToken?: string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default BotVote
|
export default BotVote
|
||||||
|
|||||||
@ -41,6 +41,15 @@ const ServerVote = RequestHandler()
|
|||||||
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||||
|
|
||||||
const vote = await put.voteServer(user, server.id)
|
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 })
|
if (vote === null) return ResponseWrapper(res, { code: 401 })
|
||||||
else if (vote === true) {
|
else if (vote === true) {
|
||||||
get.server.clear(req.query.id)
|
get.server.clear(req.query.id)
|
||||||
@ -55,8 +64,8 @@ const ServerVote = RequestHandler()
|
|||||||
},
|
},
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
return ResponseWrapper(res, { code: 200 })
|
return ResponseWrapper(res, { code: 200, data: { notificationSet } })
|
||||||
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
|
} else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote, notificationSet } })
|
||||||
})
|
})
|
||||||
|
|
||||||
interface ApiRequest extends NextApiRequest {
|
interface ApiRequest extends NextApiRequest {
|
||||||
@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
|
|||||||
body: {
|
body: {
|
||||||
_captcha: string
|
_captcha: string
|
||||||
_csrf: string
|
_csrf: string
|
||||||
|
firebaseToken?: string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export default ServerVote
|
export default ServerVote
|
||||||
|
|||||||
49
pages/api/v2/users/notification.ts
Normal file
49
pages/api/v2/users/notification.ts
Normal file
@ -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
|
||||||
@ -12,12 +12,13 @@ import { ParsedUrlQuery } from 'querystring'
|
|||||||
import NotFound from 'pages/404'
|
import NotFound from 'pages/404'
|
||||||
import { getToken } from '@utils/Csrf'
|
import { getToken } from '@utils/Csrf'
|
||||||
import Captcha from '@components/Captcha'
|
import Captcha from '@components/Captcha'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Fetch from '@utils/Fetch'
|
import Fetch from '@utils/Fetch'
|
||||||
import Day from '@utils/Day'
|
import Day from '@utils/Day'
|
||||||
import { getJosaPicker } from 'josa'
|
import { getJosaPicker } from 'josa'
|
||||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||||
import { NextSeo } from 'next-seo'
|
import { NextSeo } from 'next-seo'
|
||||||
|
import SetNotification, { getFCMToken } from '@components/FCM'
|
||||||
|
|
||||||
const Container = dynamic(() => import('@components/Container'))
|
const Container = dynamic(() => import('@components/Container'))
|
||||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||||
@ -30,7 +31,18 @@ const Message = dynamic(() => import('@components/Message'))
|
|||||||
|
|
||||||
const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
||||||
const [votingStatus, setVotingStatus] = useState(0)
|
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()
|
const router = useRouter()
|
||||||
if (!data?.id) return <NotFound />
|
if (!data?.id) return <NotFound />
|
||||||
if (!user)
|
if (!user)
|
||||||
@ -116,11 +128,15 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
|||||||
<Captcha
|
<Captcha
|
||||||
dark={theme === 'dark'}
|
dark={theme === 'dark'}
|
||||||
onVerify={async (key) => {
|
onVerify={async (key) => {
|
||||||
const res = await Fetch<{ retryAfter: number } | unknown>(
|
const res = await Fetch<{ retryAfter: number; notificationSet: boolean }>(
|
||||||
`/bots/${data.id}/vote`,
|
`/bots/${data.id}/vote`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
|
body: JSON.stringify({
|
||||||
|
_csrf: csrfToken,
|
||||||
|
_captcha: key,
|
||||||
|
firebaseToken: fcmTokenRef.current,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
setResult(res)
|
setResult(res)
|
||||||
@ -128,7 +144,10 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : result.code === 200 ? (
|
) : 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 ? (
|
) : result.code === 429 ? (
|
||||||
<>
|
<>
|
||||||
<h2 className='text-2xl font-bold'>이미 해당 봇에 투표하였습니다.</h2>
|
<h2 className='text-2xl font-bold'>이미 해당 봇에 투표하였습니다.</h2>
|
||||||
@ -136,6 +155,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
|
|||||||
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
|
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
|
||||||
있습니다.
|
있습니다.
|
||||||
</h4>
|
</h4>
|
||||||
|
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p>{result.message}</p>
|
<p>{result.message}</p>
|
||||||
|
|||||||
@ -12,12 +12,13 @@ import { ParsedUrlQuery } from 'querystring'
|
|||||||
import NotFound from 'pages/404'
|
import NotFound from 'pages/404'
|
||||||
import { getToken } from '@utils/Csrf'
|
import { getToken } from '@utils/Csrf'
|
||||||
import Captcha from '@components/Captcha'
|
import Captcha from '@components/Captcha'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Fetch from '@utils/Fetch'
|
import Fetch from '@utils/Fetch'
|
||||||
import Day from '@utils/Day'
|
import Day from '@utils/Day'
|
||||||
import { getJosaPicker } from 'josa'
|
import { getJosaPicker } from 'josa'
|
||||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||||
import { NextSeo } from 'next-seo'
|
import { NextSeo } from 'next-seo'
|
||||||
|
import SetNotification, { getFCMToken } from '@components/FCM'
|
||||||
|
|
||||||
const Container = dynamic(() => import('@components/Container'))
|
const Container = dynamic(() => import('@components/Container'))
|
||||||
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
|
||||||
@ -30,7 +31,19 @@ const Message = dynamic(() => import('@components/Message'))
|
|||||||
|
|
||||||
const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken }) => {
|
const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken }) => {
|
||||||
const [votingStatus, setVotingStatus] = useState(0)
|
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()
|
const router = useRouter()
|
||||||
if (!data?.id) return <NotFound />
|
if (!data?.id) return <NotFound />
|
||||||
if (!user)
|
if (!user)
|
||||||
@ -90,7 +103,11 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
|||||||
</Link>
|
</Link>
|
||||||
<Segment className='mb-16 py-8'>
|
<Segment className='mb-16 py-8'>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<ServerIcon id={data.id} className='mx-auto mb-4 h-52 w-52 rounded-full bg-white' hash={data.icon} />
|
<ServerIcon
|
||||||
|
id={data.id}
|
||||||
|
className='mx-auto mb-4 h-52 w-52 rounded-full bg-white'
|
||||||
|
hash={data.icon}
|
||||||
|
/>
|
||||||
<Tag
|
<Tag
|
||||||
text={
|
text={
|
||||||
<span>
|
<span>
|
||||||
@ -112,11 +129,15 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
|||||||
<Captcha
|
<Captcha
|
||||||
dark={theme === 'dark'}
|
dark={theme === 'dark'}
|
||||||
onVerify={async (key) => {
|
onVerify={async (key) => {
|
||||||
const res = await Fetch<{ retryAfter: number } | unknown>(
|
const res = await Fetch<{ retryAfter: number; notificationSet: boolean }>(
|
||||||
`/servers/${data.id}/vote`,
|
`/servers/${data.id}/vote`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
|
body: JSON.stringify({
|
||||||
|
_csrf: csrfToken,
|
||||||
|
_captcha: key,
|
||||||
|
firebaseToken: fcmTokenRef.current,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
setResult(res)
|
setResult(res)
|
||||||
@ -124,7 +145,10 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : result.code === 200 ? (
|
) : 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 ? (
|
) : result.code === 429 ? (
|
||||||
<>
|
<>
|
||||||
<h2 className='text-2xl font-bold'>이미 해당 서버에 투표하였습니다.</h2>
|
<h2 className='text-2xl font-bold'>이미 해당 서버에 투표하였습니다.</h2>
|
||||||
@ -132,6 +156,7 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
|
|||||||
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
|
{Day(+new Date() + result.data?.retryAfter).fromNow()} 다시 투표하실 수
|
||||||
있습니다.
|
있습니다.
|
||||||
</h4>
|
</h4>
|
||||||
|
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p>{result.message}</p>
|
<p>{result.message}</p>
|
||||||
|
|||||||
41
public/vote-notification-sw.js
Normal file
41
public/vote-notification-sw.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js')
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/8.10.0/firebase-messaging.js')
|
||||||
|
|
||||||
|
firebase.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 messaging = firebase.messaging()
|
||||||
|
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
type: 'vote-available',
|
||||||
|
name: target.name,
|
||||||
|
imageUrl: image ?? undefined,
|
||||||
|
url: `/${isBot ? 'bots' : 'servers'}/${noti.target_id}`,
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
messaging.onBackgroundMessage(function (payload) {
|
||||||
|
if (payload.data.type !== 'vote-available') return
|
||||||
|
const notificationTitle = '투표 알림'
|
||||||
|
const notificationOptions = {
|
||||||
|
body: `${payload.data.name}의 투표가 가능합니다.`,
|
||||||
|
icon: payload.data.imageUrl,
|
||||||
|
}
|
||||||
|
notificationOptions.data = payload.data
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
event.notification.close()
|
||||||
|
clients.openWindow(event.notification.data.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.registration.showNotification(notificationTitle, notificationOptions)
|
||||||
|
})
|
||||||
8
types/global.d.ts
vendored
8
types/global.d.ts
vendored
@ -1,4 +1,7 @@
|
|||||||
|
/* eslint-disable no-var */
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
|
import { Client } from 'discord.js'
|
||||||
|
import NotificationManager from '@utils/NotificationManager'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -8,6 +11,9 @@ declare global {
|
|||||||
highlightBlock(e: Element): void
|
highlightBlock(e: Element): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var kodl: Client
|
||||||
|
var serverlist: Client
|
||||||
|
var notification: NotificationManager
|
||||||
interface Navigator {
|
interface Navigator {
|
||||||
standalone?: boolean
|
standalone?: boolean
|
||||||
}
|
}
|
||||||
@ -21,3 +27,5 @@ declare module 'yup' {
|
|||||||
declare module 'difflib' {
|
declare module 'difflib' {
|
||||||
export function unifiedDiff(before: string, after: string): string[]
|
export function unifiedDiff(before: string, after: string): string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@ -1,13 +1,33 @@
|
|||||||
import * as Discord from 'discord.js'
|
import * as Discord from 'discord.js'
|
||||||
|
import NotificationManager from './NotificationManager'
|
||||||
|
|
||||||
export const DiscordBot = new Discord.Client({
|
if (!global.kodl) {
|
||||||
intents: Number(process.env.DISCORD_CLIENT_INTENTS ?? 32767),
|
global.kodl = new Discord.Client({
|
||||||
})
|
intents: Number(process.env.DISCORD_CLIENT_INTENTS ?? 32767),
|
||||||
|
})
|
||||||
|
global.serverlist = new Discord.Client({
|
||||||
|
intents: [],
|
||||||
|
})
|
||||||
|
|
||||||
export const ServerListDiscordBot = new Discord.Client({
|
console.log('Discord Client is initializing')
|
||||||
intents: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
global.kodl.on('ready', async () => {
|
||||||
|
console.log('Discord Client is ready')
|
||||||
|
await getMainGuild().members.fetch()
|
||||||
|
console.log(`Fetched ${getMainGuild().members.cache.size} Members`)
|
||||||
|
})
|
||||||
|
|
||||||
|
global.kodl.login(process.env.DISCORD_TOKEN ?? '')
|
||||||
|
global.serverlist.login(process.env.DISCORD_SERVERLIST_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.notification) {
|
||||||
|
global.notification = new NotificationManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DiscordBot = global.kodl as Discord.Client
|
||||||
|
|
||||||
|
export const ServerListDiscordBot = global.serverlist as Discord.Client
|
||||||
const dummyURL =
|
const dummyURL =
|
||||||
'https://discord.com/api/webhooks/123123123123123123/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf'
|
'https://discord.com/api/webhooks/123123123123123123/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf'
|
||||||
|
|
||||||
@ -40,15 +60,6 @@ export const webhookClients = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
DiscordBot.on('ready', async () => {
|
|
||||||
console.log('Discord Client is ready')
|
|
||||||
await getMainGuild().members.fetch()
|
|
||||||
console.log(`Fetched ${getMainGuild().members.cache.size} Members`)
|
|
||||||
})
|
|
||||||
|
|
||||||
DiscordBot.login(process.env.DISCORD_TOKEN ?? '')
|
|
||||||
ServerListDiscordBot.login(process.env.DISCORD_SERVERLIST_TOKEN)
|
|
||||||
|
|
||||||
export const getMainGuild = () => DiscordBot.guilds.cache.get(process.env.GUILD_ID ?? '')
|
export const getMainGuild = () => DiscordBot.guilds.cache.get(process.env.GUILD_ID ?? '')
|
||||||
|
|
||||||
export const discordLog = async (
|
export const discordLog = async (
|
||||||
|
|||||||
114
utils/NotificationManager.ts
Normal file
114
utils/NotificationManager.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { initializeApp } from 'firebase-admin/app'
|
||||||
|
import { KoreanbotsEndPoints, VOTE_COOLDOWN } from './Constants'
|
||||||
|
import { get, getNotifications, removeNotification as removeNotificationData } from './Query'
|
||||||
|
import { ObjectType } from '@types'
|
||||||
|
import { messaging } from 'firebase-admin'
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
vote_id: number
|
||||||
|
token: string
|
||||||
|
user_id: string
|
||||||
|
target_id: string
|
||||||
|
type: ObjectType
|
||||||
|
last_voted: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class NotificationManager {
|
||||||
|
private timeouts: Record<`${string}:${string}:${string}`, NodeJS.Timeout> = {}
|
||||||
|
private firebaseApp: ReturnType<typeof initializeApp>
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.initVotes()
|
||||||
|
this.firebaseApp = initializeApp()
|
||||||
|
console.log('NotificationManager initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setNotification(token: string, userId: string, targetId: string) {
|
||||||
|
const noti = await get.notifications.token(token, targetId)
|
||||||
|
|
||||||
|
if (!noti) return false
|
||||||
|
await this.scheduleNotification(noti)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a service. This removes the timeout and the notification data.
|
||||||
|
*/
|
||||||
|
public async removeNotification({
|
||||||
|
userId,
|
||||||
|
targetId,
|
||||||
|
token,
|
||||||
|
}: {
|
||||||
|
userId: string
|
||||||
|
targetId: string
|
||||||
|
token: string
|
||||||
|
}): ReturnType<typeof removeNotificationData> {
|
||||||
|
clearTimeout(this.timeouts[`${userId}:${targetId}:${token}`])
|
||||||
|
return await removeNotificationData({ targetId, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
public async scheduleNotification(noti: Notification) {
|
||||||
|
const time = noti.last_voted.getTime() + VOTE_COOLDOWN + 1000 * 60 - Date.now()
|
||||||
|
|
||||||
|
if (time <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${noti.user_id}:${noti.target_id}:${noti.token}`
|
||||||
|
|
||||||
|
this.timeouts[key] = setTimeout(() => {
|
||||||
|
this.pushNotification(noti)
|
||||||
|
clearTimeout(this.timeouts[key])
|
||||||
|
}, time)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async refresh(userId: string, targetId: string) {
|
||||||
|
const notifications = await getNotifications(userId, targetId)
|
||||||
|
|
||||||
|
for (const noti of notifications) {
|
||||||
|
clearTimeout(this.timeouts[`${userId}:${targetId}:${noti.token}`])
|
||||||
|
this.scheduleNotification(noti)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initVotes() {
|
||||||
|
const res = await getNotifications()
|
||||||
|
for (const noti of res) {
|
||||||
|
this.scheduleNotification(noti)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushNotification(noti: Notification) {
|
||||||
|
const isBot = noti.type === ObjectType.Bot
|
||||||
|
const target = isBot
|
||||||
|
? await get.bot.load(noti.target_id)
|
||||||
|
: await get.server.load(noti.target_id)
|
||||||
|
|
||||||
|
const image =
|
||||||
|
process.env.KOREANBOTS_URL +
|
||||||
|
('avatar' in target
|
||||||
|
? KoreanbotsEndPoints.CDN.avatar(target.id, { size: 256 })
|
||||||
|
: KoreanbotsEndPoints.CDN.icon(target.id, { size: 256 }))
|
||||||
|
|
||||||
|
await messaging()
|
||||||
|
.send({
|
||||||
|
token: noti.token,
|
||||||
|
data: {
|
||||||
|
type: 'vote-available',
|
||||||
|
name: target.name,
|
||||||
|
imageUrl: image ?? undefined,
|
||||||
|
url: `/${isBot ? 'bots' : 'servers'}/${noti.target_id}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if ('code' in e) {
|
||||||
|
if (e.code === 'messaging/registration-token-not-registered') {
|
||||||
|
this.removeNotification({
|
||||||
|
userId: noti.user_id,
|
||||||
|
token: noti.token,
|
||||||
|
targetId: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
182
utils/Query.ts
182
utils/Query.ts
@ -39,6 +39,7 @@ import { sign, verify } from './Jwt'
|
|||||||
import { areArraysEqual, camoUrl, formData, getYYMMDD, serialize } from './Tools'
|
import { areArraysEqual, camoUrl, formData, getYYMMDD, serialize } from './Tools'
|
||||||
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
|
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
|
||||||
import { markdownImage } from './Regex'
|
import { markdownImage } from './Regex'
|
||||||
|
import { Notification } from './NotificationManager'
|
||||||
|
|
||||||
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
|
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
|
||||||
|
|
||||||
@ -76,7 +77,7 @@ async function getBot(id: string, topLevel = true): Promise<Bot> {
|
|||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
const discordBot = await get.discord.user.load(res.id)
|
const discordBot = await get.discord.user.load(res.id)
|
||||||
if(!discordBot) {
|
if (!discordBot) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (Number(discordBot.discriminator) === 0) {
|
if (Number(discordBot.discriminator) === 0) {
|
||||||
@ -528,18 +529,32 @@ async function getWebhook(id: string, type: 'bots' | 'servers'): Promise<Webhook
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function voteBot(userID: string, botID: string): Promise<number | boolean> {
|
async function voteBot(userID: string, botID: string): Promise<number | boolean> {
|
||||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
const [vote] = await knex('votes').select('*').where({ user_id: userID, target: botID })
|
||||||
const key = `bot:${botID}`
|
const date = new Date()
|
||||||
if (user.length === 0) return null
|
if (vote) {
|
||||||
const date = +new Date()
|
const lastDate = vote.last_voted.getTime() || 0
|
||||||
const data = JSON.parse(user[0].votes)
|
if (date.getTime() - lastDate < VOTE_COOLDOWN)
|
||||||
const lastDate = data[key] || 0
|
return VOTE_COOLDOWN - (date.getTime() - lastDate)
|
||||||
if (date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
|
}
|
||||||
data[key] = date
|
|
||||||
await knex('bots').where({ id: botID }).increment('votes', 1)
|
await knex('bots').where({ id: botID }).increment('votes', 1)
|
||||||
await knex('users')
|
|
||||||
.where({ id: userID })
|
const votes = await knex('votes')
|
||||||
.update({ votes: JSON.stringify(data) })
|
.select('id')
|
||||||
|
.where({ user_id: userID, target: botID, type: ObjectType.Bot })
|
||||||
|
if (votes.length === 0) {
|
||||||
|
await knex('votes').insert({
|
||||||
|
user_id: userID,
|
||||||
|
target: botID,
|
||||||
|
type: ObjectType.Bot,
|
||||||
|
last_voted: date,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await knex('votes').where({ id: votes[0].id }).update({ last_voted: date })
|
||||||
|
}
|
||||||
|
|
||||||
|
global.notification.refresh(userID, botID)
|
||||||
|
|
||||||
const record = await Bots.updateOne(
|
const record = await Bots.updateOne(
|
||||||
{ _id: botID, 'voteMetrix.day': getYYMMDD() },
|
{ _id: botID, 'voteMetrix.day': getYYMMDD() },
|
||||||
{ $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } }
|
{ $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } }
|
||||||
@ -554,18 +569,22 @@ async function voteBot(userID: string, botID: string): Promise<number | boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function voteServer(userID: string, serverID: string): Promise<number | boolean> {
|
async function voteServer(userID: string, serverID: string): Promise<number | boolean> {
|
||||||
const user = await knex('users').select(['votes']).where({ id: userID })
|
const [vote] = await knex('votes').select('*').where({ user_id: userID, target: serverID })
|
||||||
const key = `server:${serverID}`
|
const date = new Date()
|
||||||
if (user.length === 0) return null
|
if (vote) {
|
||||||
const date = +new Date()
|
const lastDate = vote.last_voted.getTime() || 0
|
||||||
const data = JSON.parse(user[0].votes)
|
if (date.getTime() - lastDate < VOTE_COOLDOWN)
|
||||||
const lastDate = data[key] || 0
|
return VOTE_COOLDOWN - (date.getTime() - lastDate)
|
||||||
if (date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
|
}
|
||||||
data[key] = date
|
|
||||||
await knex('servers').where({ id: serverID }).increment('votes', 1)
|
await knex('servers').where({ id: serverID }).increment('votes', 1)
|
||||||
await knex('users')
|
await knex('votes')
|
||||||
.where({ id: userID })
|
.insert({ user_id: userID, target: serverID, type: ObjectType.Server, last_voted: date })
|
||||||
.update({ votes: JSON.stringify(data) })
|
.onConflict(['user_id', 'target', 'type'])
|
||||||
|
.merge({ last_voted: date })
|
||||||
|
|
||||||
|
global.notification.refresh(userID, serverID)
|
||||||
|
|
||||||
// const record = await Servers.updateOne({ _id: serverID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
|
// const record = await Servers.updateOne({ _id: serverID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
|
||||||
// if(record.n === 0) await Servers.findByIdAndUpdate(serverID, { $push: { voteMetrix: { count: (await knex('servers').where({ id: serverID }))[0].votes } } }, { upsert: true })
|
// if(record.n === 0) await Servers.findByIdAndUpdate(serverID, { $push: { voteMetrix: { count: (await knex('servers').where({ id: serverID }))[0].votes } } }, { upsert: true })
|
||||||
return true
|
return true
|
||||||
@ -947,6 +966,119 @@ export async function CaptchaVerify(response: string): Promise<boolean> {
|
|||||||
return res.success
|
return res.success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FCM
|
||||||
|
|
||||||
|
export async function addNotification({
|
||||||
|
token,
|
||||||
|
userId,
|
||||||
|
targetId,
|
||||||
|
}: {
|
||||||
|
token: string
|
||||||
|
userId: string
|
||||||
|
targetId: string
|
||||||
|
}) {
|
||||||
|
const [vote] = await knex('votes').select('id').where({ user_id: userId, target: targetId })
|
||||||
|
|
||||||
|
if (!vote) {
|
||||||
|
return 'Vote does not exist'
|
||||||
|
}
|
||||||
|
|
||||||
|
const voteId = vote.id
|
||||||
|
|
||||||
|
const { length } = await knex('notifications').select('vote_id').where({ token, vote_id: voteId })
|
||||||
|
|
||||||
|
if (length === 0) {
|
||||||
|
await knex('notifications').insert({ token, vote_id: voteId })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Notification added to database', token, userId, targetId)
|
||||||
|
|
||||||
|
global.notification.setNotification(token, userId, targetId)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeNotification({
|
||||||
|
token,
|
||||||
|
targetId,
|
||||||
|
}: {
|
||||||
|
token: string
|
||||||
|
targetId: string | null
|
||||||
|
}) {
|
||||||
|
if (targetId === null) {
|
||||||
|
await knex('notifications').delete().where({ token })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex('notifications')
|
||||||
|
.delete()
|
||||||
|
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||||
|
.where({
|
||||||
|
'notifications.token': token,
|
||||||
|
'votes.target': targetId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We consider that multiple devices could listen to the same topic.
|
||||||
|
* @param userId
|
||||||
|
* @param targetId
|
||||||
|
*/
|
||||||
|
export async function getNotifications(userId?: string, targetId?: string) {
|
||||||
|
const q = knex('notifications')
|
||||||
|
.select([
|
||||||
|
'notifications.*',
|
||||||
|
'votes.user_id as user_id',
|
||||||
|
'votes.target as target_id',
|
||||||
|
'votes.type as type',
|
||||||
|
'votes.last_voted as last_voted',
|
||||||
|
])
|
||||||
|
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||||
|
|
||||||
|
if (userId) q.where('votes.user_id', userId)
|
||||||
|
if (targetId) q.where('votes.target', targetId)
|
||||||
|
|
||||||
|
const res = await q
|
||||||
|
|
||||||
|
return res as Notification[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationsByToken(token: string, targetId?: string) {
|
||||||
|
const q = knex('notifications')
|
||||||
|
.select([
|
||||||
|
'notifications.*',
|
||||||
|
'votes.user_id as user_id',
|
||||||
|
'votes.target as target_id',
|
||||||
|
'votes.type as type',
|
||||||
|
'votes.last_voted as last_voted',
|
||||||
|
])
|
||||||
|
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||||
|
.where('notifications.token', token)
|
||||||
|
|
||||||
|
if (targetId) q.where('votes.target', targetId)
|
||||||
|
|
||||||
|
const [res] = await q
|
||||||
|
|
||||||
|
return res as Notification
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationsByUserId(userId: string) {
|
||||||
|
const res = await knex('notifications')
|
||||||
|
.select([
|
||||||
|
'notifications.*',
|
||||||
|
'votes.user_id as user_id',
|
||||||
|
'votes.target as target_id',
|
||||||
|
'votes.type as type',
|
||||||
|
'votes.last_voted as last_voted',
|
||||||
|
])
|
||||||
|
.leftJoin('votes', 'votes.id', 'notifications.vote_id')
|
||||||
|
.where('votes.user_id', userId)
|
||||||
|
|
||||||
|
return res as Notification[]
|
||||||
|
}
|
||||||
|
|
||||||
// Private APIs
|
// Private APIs
|
||||||
|
|
||||||
async function getBotSubmitList() {
|
async function getBotSubmitList() {
|
||||||
@ -1257,6 +1389,10 @@ export const get = {
|
|||||||
botSubmitHistory: getBotSubmitHistory,
|
botSubmitHistory: getBotSubmitHistory,
|
||||||
botSubmitStrikes: getBotSubmitStrikes,
|
botSubmitStrikes: getBotSubmitStrikes,
|
||||||
serverOwners: fetchServerOwners,
|
serverOwners: fetchServerOwners,
|
||||||
|
notifications: {
|
||||||
|
user: getNotificationsByUserId,
|
||||||
|
token: getNotificationsByToken,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const update = {
|
export const update = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user