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:
SKINMAKER 2025-02-17 07:34:12 +09:00 committed by GitHub
parent e99e661b8d
commit 160fe4ecb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1597 additions and 77 deletions

5
.gitignore vendored
View File

@ -43,4 +43,7 @@ yarn-error.log*
package-lock.json
# sub module
api-docs/
api-docs/
# Firebase
service-account.json

165
components/FCM.tsx Normal file
View 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

View File

@ -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",

View File

@ -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)
@ -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 {
@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
body: {
_captcha: string
_csrf: string
firebaseToken?: string | null
}
}
export default BotVote

View File

@ -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)
@ -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 {
@ -75,6 +84,7 @@ interface PostApiRequest extends ApiRequest {
body: {
_captcha: string
_csrf: string
firebaseToken?: string | null
}
}
export default ServerVote

View 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

View File

@ -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'))
@ -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)
@ -116,11 +128,15 @@ 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)
@ -128,7 +144,10 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
}}
/>
) : 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>
@ -136,6 +155,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
{Day(+new Date() + result.data?.retryAfter).fromNow()}
.
</h4>
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
</>
) : (
<p>{result.message}</p>

View File

@ -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 ServerIcon = dynamic(() => import('@components/ServerIcon'))
@ -30,7 +31,19 @@ const Message = dynamic(() => import('@components/Message'))
const VoteServer: NextPage<VoteServerProps> = ({ 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)
@ -90,7 +103,11 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
</Link>
<Segment className='mb-16 py-8'>
<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
text={
<span>
@ -112,11 +129,15 @@ const VoteServer: NextPage<VoteServerProps> = ({ 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 }>(
`/servers/${data.id}/vote`,
{
method: 'POST',
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
body: JSON.stringify({
_csrf: csrfToken,
_captcha: key,
firebaseToken: fcmTokenRef.current,
}),
}
)
setResult(res)
@ -124,7 +145,10 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
}}
/>
) : 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>
@ -132,6 +156,7 @@ const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken })
{Day(+new Date() + result.data?.retryAfter).fromNow()}
.
</h4>
<SetNotification id={data.id} notificationSet={result.data.notificationSet} />
</>
) : (
<p>{result.message}</p>

View 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
View File

@ -1,4 +1,7 @@
/* eslint-disable no-var */
import * as Yup from 'yup'
import { Client } from 'discord.js'
import NotificationManager from '@utils/NotificationManager'
declare global {
interface Window {
@ -8,6 +11,9 @@ declare global {
highlightBlock(e: Element): void
}
}
var kodl: Client
var serverlist: Client
var notification: NotificationManager
interface Navigator {
standalone?: boolean
}
@ -21,3 +27,5 @@ declare module 'yup' {
declare module 'difflib' {
export function unifiedDiff(before: string, after: string): string[]
}
export {}

View File

@ -1,13 +1,33 @@
import * as Discord from 'discord.js'
import NotificationManager from './NotificationManager'
export const DiscordBot = new Discord.Client({
intents: Number(process.env.DISCORD_CLIENT_INTENTS ?? 32767),
})
if (!global.kodl) {
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({
intents: [],
})
console.log('Discord Client is initializing')
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 =
'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 discordLog = async (

View 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,
})
}
}
})
}
}

View File

@ -39,6 +39,7 @@ import { sign, verify } from './Jwt'
import { areArraysEqual, camoUrl, formData, getYYMMDD, serialize } from './Tools'
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
import { markdownImage } from './Regex'
import { Notification } from './NotificationManager'
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
@ -76,7 +77,7 @@ async function getBot(id: string, topLevel = true): Promise<Bot> {
if (res) {
const discordBot = await get.discord.user.load(res.id)
if(!discordBot) {
if (!discordBot) {
return null
}
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> {
const user = await knex('users').select(['votes']).where({ id: userID })
const key = `bot:${botID}`
if (user.length === 0) return null
const date = +new Date()
const data = JSON.parse(user[0].votes)
const lastDate = data[key] || 0
if (date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
data[key] = date
const [vote] = await knex('votes').select('*').where({ user_id: userID, target: botID })
const date = new Date()
if (vote) {
const lastDate = vote.last_voted.getTime() || 0
if (date.getTime() - lastDate < VOTE_COOLDOWN)
return VOTE_COOLDOWN - (date.getTime() - lastDate)
}
await knex('bots').where({ id: botID }).increment('votes', 1)
await knex('users')
.where({ id: userID })
.update({ votes: JSON.stringify(data) })
const votes = await knex('votes')
.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(
{ _id: botID, 'voteMetrix.day': getYYMMDD() },
{ $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> {
const user = await knex('users').select(['votes']).where({ id: userID })
const key = `server:${serverID}`
if (user.length === 0) return null
const date = +new Date()
const data = JSON.parse(user[0].votes)
const lastDate = data[key] || 0
if (date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
data[key] = date
const [vote] = await knex('votes').select('*').where({ user_id: userID, target: serverID })
const date = new Date()
if (vote) {
const lastDate = vote.last_voted.getTime() || 0
if (date.getTime() - lastDate < VOTE_COOLDOWN)
return VOTE_COOLDOWN - (date.getTime() - lastDate)
}
await knex('servers').where({ id: serverID }).increment('votes', 1)
await knex('users')
.where({ id: userID })
.update({ votes: JSON.stringify(data) })
await knex('votes')
.insert({ user_id: userID, target: serverID, type: ObjectType.Server, last_voted: date })
.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 } })
// if(record.n === 0) await Servers.findByIdAndUpdate(serverID, { $push: { voteMetrix: { count: (await knex('servers').where({ id: serverID }))[0].votes } } }, { upsert: true })
return true
@ -947,6 +966,119 @@ export async function CaptchaVerify(response: string): Promise<boolean> {
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
async function getBotSubmitList() {
@ -1257,6 +1389,10 @@ export const get = {
botSubmitHistory: getBotSubmitHistory,
botSubmitStrikes: getBotSubmitStrikes,
serverOwners: fetchServerOwners,
notifications: {
user: getNotificationsByUserId,
token: getNotificationsByToken,
},
}
export const update = {

972
yarn.lock

File diff suppressed because it is too large Load Diff