refactor(push): unify notification payload structure and handling
Refactor the notification handling logic to use a unified `topic` based payload structure. This change replaces the flat `NotificationData` interface with a typed `topic` and `data` separation, enabling stricter type checking for different notification types (e.g., `SERVER_PUSH`). Key changes: - Updated `NotificationData` type to include `topic` and `data` fields, referencing `TopicPayload`. - Modified `sw.js` to parse incoming `MessagePayload` using `fromMessagePayload`, ensuring the topic is `SERVER_PUSH` before extracting `title` and `options`. - Renamed internal types in `utils/common.ts` to `ServerSendTopicPayload` and `Topics` for clarity. - Updated `firebase-admin.ts` to wrap data in a `payload` JSON string before sending to Firebase Cloud Messaging, and added logic to clean up unregistered tokens. - Added backward compatibility support for older clients by sending a secondary legacy-formatted message when a `SERVER_PUSH` topic is detected. - Moved `NOTIFICATION_TOKEN_KEY` constant to `utils/constants.ts` and updated related hooks to use it for consistency. - Updated `logger.ts` to comment out file logging during development/testing to reduce disk I/O. This refactor improves type safety, reduces code duplication in payload handling, and ensures better compatibility between the web client and the service worker.
This commit is contained in:
parent
0f3b981b0f
commit
defd95c218
@ -46,11 +46,6 @@ export const BindKaiqiuAccount = () => {
|
|||||||
});
|
});
|
||||||
}, [modal]);
|
}, [modal]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (isBindReq.data?.isBinded === undefined) {
|
|
||||||
return (
|
|
||||||
<Skeleton.Input active />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
if (isBindReq.data?.isBinded) {
|
if (isBindReq.data?.isBinded) {
|
||||||
return (
|
return (
|
||||||
<Button type="text" onClick={() => navigate(`/profile/${isBindReq.data?.uid}`)}>
|
<Button type="text" onClick={() => navigate(`/profile/${isBindReq.data?.uid}`)}>
|
||||||
@ -63,6 +58,11 @@ export const BindKaiqiuAccount = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isBindReq.loading) {
|
||||||
|
return (
|
||||||
|
<Skeleton.Input active />
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -69,6 +69,10 @@ export const ClubEvenList = (props: Props) => {
|
|||||||
<Divider>
|
<Divider>
|
||||||
{paginationControlVisible ? (
|
{paginationControlVisible ? (
|
||||||
<Space>
|
<Space>
|
||||||
|
<Typography.Text type="secondary">显示已结束的活动</Typography.Text>
|
||||||
|
<Switch checked={showFinishedEvents} onChange={() => setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" />
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
loading={requestEvents.loading}
|
loading={requestEvents.loading}
|
||||||
type="link"
|
type="link"
|
||||||
@ -77,10 +81,7 @@ export const ClubEvenList = (props: Props) => {
|
|||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Typography.Text type="secondary">显示已结束的活动</Typography.Text>
|
)}
|
||||||
<Switch checked={showFinishedEvents} onChange={() => setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" />
|
|
||||||
</Space>
|
|
||||||
) : null}
|
|
||||||
</Divider>
|
</Divider>
|
||||||
<Flex wrap vertical gap={12} justify="center" align="center">
|
<Flex wrap vertical gap={12} justify="center" align="center">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -4,14 +4,13 @@ import dayjs from "dayjs";
|
|||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
import { BellFilled, BellOutlined, EyeOutlined } from "@ant-design/icons";
|
import { BellFilled, BellOutlined, EyeOutlined } from "@ant-design/icons";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import type { TimerType } from "antd/lib/statistic/Timer";
|
import type { TimerType } from "antd/lib/statistic/Timer";
|
||||||
import { useLogto } from "@logto/react";
|
import { useLogto } from "@logto/react";
|
||||||
import { useAuthHeaders } from "@/hooks/useAuthHeaders";
|
import { useAuthHeaders } from "@/hooks/useAuthHeaders";
|
||||||
import { useRequest } from "ahooks";
|
import { useRequest } from "ahooks";
|
||||||
import { WebScoketContext } from "@/context/WebsocketContext";
|
import useAutoLogin from "@/hooks/useAutoLogin";
|
||||||
import { getEventSubKey } from "@/utils/common";
|
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@ -23,6 +22,7 @@ interface EventCardProps {
|
|||||||
export function EventCard(props: EventCardProps) {
|
export function EventCard(props: EventCardProps) {
|
||||||
const { eventInfo: e } = props;
|
const { eventInfo: e } = props;
|
||||||
const day = useMemo(() => dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]);
|
const day = useMemo(() => dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]);
|
||||||
|
const { isAuthExpired } = useAutoLogin();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const handleView = useCallback(() => {
|
const handleView = useCallback(() => {
|
||||||
navigate(`/event/${e.matchId}`);
|
navigate(`/event/${e.matchId}`);
|
||||||
@ -64,10 +64,8 @@ export function EventCard(props: EventCardProps) {
|
|||||||
const statistic = getStatisticProps();
|
const statistic = getStatisticProps();
|
||||||
setMessageFormat(statistic.format);
|
setMessageFormat(statistic.format);
|
||||||
setStatisticType(statistic.type);
|
setStatisticType(statistic.type);
|
||||||
// console.debug('format: %s', day.format(statistic.format), statistic);
|
|
||||||
}, [getStatisticProps])
|
}, [getStatisticProps])
|
||||||
const { isAuthenticated } = useLogto();
|
const { isAuthenticated } = useLogto();
|
||||||
const { messageSender } = useContext(WebScoketContext);
|
|
||||||
const headers = useAuthHeaders();
|
const headers = useAuthHeaders();
|
||||||
const isSubscried = useRequest(async () => {
|
const isSubscried = useRequest(async () => {
|
||||||
return fetch(`/api/subscribe-event/${e.matchId}`, {
|
return fetch(`/api/subscribe-event/${e.matchId}`, {
|
||||||
@ -93,14 +91,12 @@ export function EventCard(props: EventCardProps) {
|
|||||||
setSubloading(true);
|
setSubloading(true);
|
||||||
await subReq.runAsync();
|
await subReq.runAsync();
|
||||||
await isSubscried.runAsync();
|
await isSubscried.runAsync();
|
||||||
messageSender?.('SUB', { topic: getEventSubKey(e.matchId) });
|
|
||||||
setSubloading(false);
|
setSubloading(false);
|
||||||
}, [e, subReq, isSubscried]);
|
}, [e, subReq, isSubscried]);
|
||||||
const handleUnSub = useCallback(async () => {
|
const handleUnSub = useCallback(async () => {
|
||||||
setSubloading(true);
|
setSubloading(true);
|
||||||
await unSubReq.runAsync();
|
await unSubReq.runAsync();
|
||||||
await isSubscried.runAsync();
|
await isSubscried.runAsync();
|
||||||
messageSender?.('UNSUB', { topic: getEventSubKey(e.matchId) });
|
|
||||||
setSubloading(false);
|
setSubloading(false);
|
||||||
}, [e, isSubscried, unSubReq]);
|
}, [e, isSubscried, unSubReq]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -113,8 +109,10 @@ export function EventCard(props: EventCardProps) {
|
|||||||
}, [updateMessageFormat]);
|
}, [updateMessageFormat]);
|
||||||
const isSubBtnDisabled = useMemo(() => {
|
const isSubBtnDisabled = useMemo(() => {
|
||||||
if (!isAuthenticated) return true;
|
if (!isAuthenticated) return true;
|
||||||
|
if (isAuthExpired) return true;
|
||||||
if (subLoading) return true;
|
if (subLoading) return true;
|
||||||
if (e.isFinished && !isSubscried.data) return true;
|
if (e.isProcessing) return true;
|
||||||
|
if (e.isFinished) return true;
|
||||||
return false;
|
return false;
|
||||||
}, [isAuthenticated, subLoading, isSubscried]);
|
}, [isAuthenticated, subLoading, isSubscried]);
|
||||||
return (
|
return (
|
||||||
@ -130,7 +128,7 @@ export function EventCard(props: EventCardProps) {
|
|||||||
icon={isSubscried.data ? <BellFilled style={{ color: 'yellow' }} /> :<BellOutlined />}
|
icon={isSubscried.data ? <BellFilled style={{ color: 'yellow' }} /> :<BellOutlined />}
|
||||||
onClick={isSubscried.data ? handleUnSub : handleSub}
|
onClick={isSubscried.data ? handleUnSub : handleSub}
|
||||||
>
|
>
|
||||||
{isSubscried.data ? '取消提醒' : '提醒我'}
|
{isAuthenticated ? isSubscried.data ? '取消关注' : '关注人员变动' : '登录后关注'}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { Outlet, useNavigation } from "react-router";
|
|||||||
import { HydrateFallback } from "@/components/HydrateFallback";
|
import { HydrateFallback } from "@/components/HydrateFallback";
|
||||||
import { AppBar } from "@/components/AppBar";
|
import { AppBar } from "@/components/AppBar";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { WebScoketContext } from "@/context/WebsocketContext";
|
|
||||||
import { Alert } from "antd";
|
import { Alert } from "antd";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { initServiceWorker } from "@/utils/firebase-frontend";
|
import { initServiceWorker } from "@/utils/firebase-frontend";
|
||||||
|
import { useFirebaseNotificationProcessor } from "@/hooks/useFirebaseNotificationProcessor";
|
||||||
|
import { useAutoUpdateNotificationToken } from "@/hooks/useAutoUpdateNotificationToken";
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
padding-bottom: 90px;
|
padding-bottom: 90px;
|
||||||
@ -14,15 +15,17 @@ const StyledContainer = styled.div`
|
|||||||
export const AppBarLayout = () => {
|
export const AppBarLayout = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const loading = navigation.state === 'loading';
|
const loading = navigation.state === 'loading';
|
||||||
|
useFirebaseNotificationProcessor();
|
||||||
|
useAutoUpdateNotificationToken();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initServiceWorker();
|
initServiceWorker();
|
||||||
}, []);
|
}, []);
|
||||||
return loading ? <HydrateFallback /> : (<StyledContainer>
|
return loading ? <HydrateFallback /> : (<StyledContainer>
|
||||||
<WebScoketContext.Provider value={{}}>
|
<>
|
||||||
<Alert.ErrorBoundary>
|
<Alert.ErrorBoundary>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Alert.ErrorBoundary>
|
</Alert.ErrorBoundary>
|
||||||
<AppBar />
|
<AppBar />
|
||||||
</WebScoketContext.Provider>
|
</>
|
||||||
</StyledContainer>);
|
</StyledContainer>);
|
||||||
}
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Button, Drawer, Flex, Form, Switch, Typography } from "antd";
|
import { Button, Drawer, Flex, Form, Skeleton, Switch, Typography } from "antd";
|
||||||
import { useNotificationControl } from "@/hooks/useNotificationControl";
|
import { useNotificationControl } from "@/hooks/useNotificationControl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import useAutoLogin from "@/hooks/useAutoLogin";
|
||||||
|
|
||||||
const NotificationPermissionControl = () => {
|
const NotificationPermissionControl = () => {
|
||||||
const {
|
const {
|
||||||
@ -11,6 +12,7 @@ const NotificationPermissionControl = () => {
|
|||||||
handleEnableNotificationClick,
|
handleEnableNotificationClick,
|
||||||
} = useNotificationControl();
|
} = useNotificationControl();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { isAuthExpired, autoSignIn } = useAutoLogin();
|
||||||
const showEnableNotification = useCallback(() => {
|
const showEnableNotification = useCallback(() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
@ -20,7 +22,7 @@ const NotificationPermissionControl = () => {
|
|||||||
{isSupported ? (
|
{isSupported ? (
|
||||||
<Switch
|
<Switch
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!isSupported}
|
disabled={!isSupported || isAuthExpired}
|
||||||
value={isNotificationEnable}
|
value={isNotificationEnable}
|
||||||
onChange={checked => {
|
onChange={checked => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@ -29,14 +31,17 @@ const NotificationPermissionControl = () => {
|
|||||||
handleDisableNotificationClick();
|
handleDisableNotificationClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
unCheckedChildren={isAuthExpired ? '登录已过期' : ''}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<Skeleton loading={loading}>
|
||||||
<Flex align="center" justify="center" wrap>
|
<Flex align="center" justify="center" wrap>
|
||||||
<Typography.Text type='secondary'>
|
<Typography.Text type='secondary'>
|
||||||
当前模式不支持通知功
|
当前模式不支持通知功
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Button type="link" onClick={showEnableNotification}>查看开启方法</Button>
|
<Button type="link" onClick={showEnableNotification}>查看开启方法</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</Skeleton>
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Drawer
|
<Drawer
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const PermissionControlPanel = () => {
|
|||||||
<>
|
<>
|
||||||
<Button block icon={<NotificationOutlined />} onClick={() => setOpen(true)}>权限设置</Button>
|
<Button block icon={<NotificationOutlined />} onClick={() => setOpen(true)}>权限设置</Button>
|
||||||
<Drawer
|
<Drawer
|
||||||
|
forceRender
|
||||||
open={open}
|
open={open}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
title={'权限设置'}
|
title={'权限设置'}
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { createContext } from "react";
|
|
||||||
import type { WsWebSendTopicPayload, WsWebSendTopics } from "@/utils/common";
|
|
||||||
|
|
||||||
interface MessageSender {
|
|
||||||
messageSender?: <T extends WsWebSendTopics>(topic: T, data: WsWebSendTopicPayload[T]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WebScoketContext = createContext<MessageSender>({});
|
|
||||||
@ -10,21 +10,21 @@ export const useAuthHeaders = (): HeadersInit | undefined => {
|
|||||||
const { autoSignIn } = useAutoLogin();
|
const { autoSignIn } = useAutoLogin();
|
||||||
const app = App.useApp();
|
const app = App.useApp();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (!isAuthenticated) return;
|
||||||
getAccessToken(LOGTO_RESOURCE).then(token => {
|
getAccessToken(LOGTO_RESOURCE)
|
||||||
if (!token) {
|
.then(token => {
|
||||||
|
if (token) {
|
||||||
|
setHeaders({ Authorization: `Bearer ${token}` })
|
||||||
|
return;
|
||||||
|
}
|
||||||
app.notification.warning({
|
app.notification.warning({
|
||||||
key: 'use-auth-headers-login-expired',
|
key: 'use-auth-headers-login-expired',
|
||||||
title: '登陆已过期',
|
title: '登陆已过期',
|
||||||
actions: [
|
actions: [
|
||||||
<Button onClick={() => autoSignIn() }>重新登陆</Button>
|
<Button key='auto-signin' type="primary" onClick={() => autoSignIn() }>重新登陆</Button>
|
||||||
]
|
],
|
||||||
})
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHeaders({ Authorization: `Bearer ${token}` })
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
35
src/hooks/useAutoUpdateNotificationToken.ts
Normal file
35
src/hooks/useAutoUpdateNotificationToken.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNotificationToken } from "@/hooks/useNotificationToken";
|
||||||
|
import { useNotificationTokenAPI } from "@/hooks/useNotificationTokenAPI";
|
||||||
|
import { NOTIFICATION_TOKEN_KEY } from "@/utils/constants";
|
||||||
|
|
||||||
|
export const useAutoUpdateNotificationToken = () => {
|
||||||
|
const [token] = useNotificationToken();
|
||||||
|
const {
|
||||||
|
isTokenSetedReq,
|
||||||
|
updateTokenReq,
|
||||||
|
unsetTokenReq,
|
||||||
|
} = useNotificationTokenAPI();
|
||||||
|
const [isNotificationEnable, setIsNotificationEnable] = useState<boolean>(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
isTokenSetedReq.runAsync(token).then(res => {
|
||||||
|
setIsNotificationEnable(res);
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
if (!isNotificationEnable) return;
|
||||||
|
const oldToken = localStorage.getItem(NOTIFICATION_TOKEN_KEY);
|
||||||
|
if (!oldToken) return;
|
||||||
|
console.debug('Try to auto update token, token changed: %s', oldToken !== token);
|
||||||
|
if (oldToken === token) return;
|
||||||
|
unsetTokenReq
|
||||||
|
.runAsync(oldToken)
|
||||||
|
.then(() => updateTokenReq.runAsync(token))
|
||||||
|
.then(() => {
|
||||||
|
}).then(() => {
|
||||||
|
console.debug('Auto udpate token', token);
|
||||||
|
});
|
||||||
|
}, [updateTokenReq, unsetTokenReq, isNotificationEnable, token]);
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { onMessage } from "firebase/messaging";
|
import { onMessage } from "firebase/messaging";
|
||||||
import { App } from "antd";
|
import { App } from "antd";
|
||||||
import type { NotificationData } from "../types";
|
|
||||||
import { getFirebaseApp, getFirebaseMessaging } from "../utils/firebase-frontend";
|
import { getFirebaseApp, getFirebaseMessaging } from "../utils/firebase-frontend";
|
||||||
|
import { fromMessagePayload, type TopicPayload } from "@/utils/common";
|
||||||
|
|
||||||
export const useFirebaseNotificationProcessor = () => {
|
export const useFirebaseNotificationProcessor = () => {
|
||||||
const { notification } = App.useApp();
|
const { notification } = App.useApp();
|
||||||
@ -11,18 +11,23 @@ export const useFirebaseNotificationProcessor = () => {
|
|||||||
const messaging = getFirebaseMessaging();
|
const messaging = getFirebaseMessaging();
|
||||||
if (!app || !messaging) return;
|
if (!app || !messaging) return;
|
||||||
const unsubscribe = onMessage(messaging, async (payload) => {
|
const unsubscribe = onMessage(messaging, async (payload) => {
|
||||||
const data = payload.data as NotificationData;
|
const msg = fromMessagePayload(payload);
|
||||||
|
if (!msg) return;
|
||||||
|
switch (msg.topic) {
|
||||||
|
case "SERVER_PUSH": {
|
||||||
|
const data = msg.data as TopicPayload['SERVER_PUSH'];
|
||||||
notification.success({
|
notification.success({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.body,
|
description: data.options?.body,
|
||||||
icon: data.icon,
|
// icon: data.options?.icon ? ,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!data.url) return;
|
if (!data.options?.data?.url) return;
|
||||||
open(data.url, '_blank'); // 打开通知
|
open(data.options?.data?.url, '_blank'); // 打开通知
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => unsubscribe(); // 组件卸载时取消监听
|
return () => unsubscribe(); // 组件卸载时取消监听
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { ensureTopicData, fromServerMessage } from "@/utils/common";
|
|
||||||
import { EVENT_WS_MESSAGE } from "@/utils/constants";
|
|
||||||
import { App } from "antd";
|
|
||||||
|
|
||||||
export const useServerMessageHandler = () => {
|
|
||||||
const { notification } = App.useApp();
|
|
||||||
const processCustomEvent = useCallback(async (msg: string) => {
|
|
||||||
const { topic, data } = fromServerMessage(msg);
|
|
||||||
console.debug('Handle ws message, topic: %s', topic, data);
|
|
||||||
switch (topic) {
|
|
||||||
case "MEMBER_CHANGE": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "MY_CLIENT_ONLINE": {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "DEBUG_MSG":
|
|
||||||
break;
|
|
||||||
case "EVENT_MEMBER_CHANGE":
|
|
||||||
case "MSG": {
|
|
||||||
const msgData = ensureTopicData<'MSG'>(data);
|
|
||||||
const hasPermission = await Notification.requestPermission();
|
|
||||||
if (hasPermission === 'granted') {
|
|
||||||
const options = {
|
|
||||||
body: `有新的消息`,
|
|
||||||
icon: "https://example.com/icon.png", // 通知图标
|
|
||||||
badge: "https://example.com/badge.png", // 移动端状态栏图标
|
|
||||||
tag: `MSG`, // 相同 tag 的通知会覆盖,防止刷屏
|
|
||||||
renotify: true // 覆盖旧通知时是否再次振动/提醒
|
|
||||||
};
|
|
||||||
new Notification("新消息提醒", options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notification.info({
|
|
||||||
key: 'MSG',
|
|
||||||
title: 'You have a new message',
|
|
||||||
description: `from: ${msgData?.name}`
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "SERVER_PUSH": {
|
|
||||||
const { title, options = {} } = ensureTopicData<'SERVER_PUSH'>(data) ?? {};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
const { message } = (e as CustomEvent).detail;
|
|
||||||
processCustomEvent(message);
|
|
||||||
};
|
|
||||||
window.addEventListener(EVENT_WS_MESSAGE, handler);
|
|
||||||
return () => window.removeEventListener(EVENT_WS_MESSAGE, handler);
|
|
||||||
}, [processCustomEvent]);
|
|
||||||
};
|
|
||||||
@ -1,49 +1,19 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useAuthHeaders } from "./useAuthHeaders";
|
import { isNotificationSupport } from "@/utils/front";
|
||||||
import { useRequest } from "ahooks";
|
|
||||||
import { isNotificationSupport } from "../utils/front";
|
|
||||||
import { getFirebaseToken } from "../utils/firebase-frontend";
|
|
||||||
import { App } from "antd";
|
import { App } from "antd";
|
||||||
|
import { useNotificationToken } from "@/hooks/useNotificationToken";
|
||||||
|
import { useNotificationTokenAPI } from "@/hooks/useNotificationTokenAPI";
|
||||||
|
|
||||||
const MSG_KEY = 'NOTIFICATION_CONTROL_MSG';
|
const MSG_KEY = 'NOTIFICATION_CONTROL_MSG';
|
||||||
|
|
||||||
export const useNotificationControl = () => {
|
export const useNotificationControl = () => {
|
||||||
const [token, setToken] = useState<string>();
|
const [token] = useNotificationToken();
|
||||||
const headers = useAuthHeaders();
|
const {
|
||||||
const updateTokenReq = useRequest(async () => {
|
loading,
|
||||||
if (!token || !headers) return;
|
isTokenSetedReq,
|
||||||
return fetch('/api/notification-token', {
|
updateTokenReq,
|
||||||
headers,
|
unsetTokenReq,
|
||||||
method: 'PUT',
|
} = useNotificationTokenAPI();
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
}, { manual: true, refreshDeps: [headers, token] });
|
|
||||||
const unsetTokenReq = useRequest(async () => {
|
|
||||||
if (!token || !headers) return;
|
|
||||||
return fetch('/api/notification-token', {
|
|
||||||
headers,
|
|
||||||
method: 'DELETE',
|
|
||||||
body: JSON.stringify({ token }),
|
|
||||||
});
|
|
||||||
}, { manual: true, refreshDeps: [headers, token] });
|
|
||||||
const isTokenSetedReq = useRequest<Boolean, [string]>(async (token) => {
|
|
||||||
if (!token || !headers) return false;
|
|
||||||
return fetch(`/api/notification-token?token=${token}`, {
|
|
||||||
headers,
|
|
||||||
method: 'GET',
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => data.isSubscribed);
|
|
||||||
}, { manual: true, refreshDeps: [headers, token]});
|
|
||||||
const loading = useMemo(() => {
|
|
||||||
return updateTokenReq.loading || unsetTokenReq.loading || isTokenSetedReq.loading;
|
|
||||||
}, [updateTokenReq, unsetTokenReq, isTokenSetedReq]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isNotificationSupport()) return;
|
|
||||||
getFirebaseToken()
|
|
||||||
.then(token => setToken(token))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
isTokenSetedReq.runAsync(token);
|
isTokenSetedReq.runAsync(token);
|
||||||
@ -64,19 +34,25 @@ export const useNotificationControl = () => {
|
|||||||
}
|
}
|
||||||
if (permission === 'granted') {
|
if (permission === 'granted') {
|
||||||
message.open({ key: MSG_KEY, type: 'success', content: '已开启通知' });
|
message.open({ key: MSG_KEY, type: 'success', content: '已开启通知' });
|
||||||
await updateTokenReq.runAsync();
|
await updateTokenReq.runAsync(token);
|
||||||
await isTokenSetedReq.runAsync(token);
|
await isTokenSetedReq.runAsync(token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [updateTokenReq, isTokenSetedReq, token]);
|
}, [updateTokenReq, isTokenSetedReq, token]);
|
||||||
const handleDisableNotificationClick = useCallback(async () => {
|
const handleDisableNotificationClick = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
await unsetTokenReq.runAsync();
|
await unsetTokenReq.runAsync(token);
|
||||||
await isTokenSetedReq.runAsync(token);
|
await isTokenSetedReq.runAsync(token);
|
||||||
message.open({ key: MSG_KEY, type: 'success', content: '已取消通知' });
|
message.open({ key: MSG_KEY, type: 'success', content: '已取消通知' });
|
||||||
}, [unsetTokenReq, isTokenSetedReq, token]);
|
}, [unsetTokenReq, isTokenSetedReq, token]);
|
||||||
const isNotificationEnable = useMemo(() => {
|
const isNotificationEnable = useMemo(() => {
|
||||||
return Boolean(isTokenSetedReq.data);
|
return Boolean(isTokenSetedReq.data);
|
||||||
}, [isTokenSetedReq]);
|
}, [isTokenSetedReq]);
|
||||||
return { loading, isSupported, handleDisableNotificationClick, handleEnableNotificationClick, isNotificationEnable };
|
return {
|
||||||
|
loading,
|
||||||
|
isSupported,
|
||||||
|
isNotificationEnable,
|
||||||
|
handleDisableNotificationClick,
|
||||||
|
handleEnableNotificationClick,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
16
src/hooks/useNotificationToken.ts
Normal file
16
src/hooks/useNotificationToken.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { getFirebaseToken } from "@/utils/firebase-frontend";
|
||||||
|
import { isNotificationSupport } from "@/utils/front";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const useNotificationToken = () => {
|
||||||
|
const [token, setToken] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNotificationSupport()) return;
|
||||||
|
getFirebaseToken()
|
||||||
|
.then(token => {
|
||||||
|
setToken(token);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
return [token];
|
||||||
|
}
|
||||||
44
src/hooks/useNotificationTokenAPI.ts
Normal file
44
src/hooks/useNotificationTokenAPI.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useRequest } from "ahooks";
|
||||||
|
import { useAuthHeaders } from "./useAuthHeaders";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { NOTIFICATION_TOKEN_KEY } from "@/utils/constants";
|
||||||
|
|
||||||
|
export const useNotificationTokenAPI = () => {
|
||||||
|
const headers = useAuthHeaders();
|
||||||
|
const updateTokenReq = useRequest(async (token: string) => {
|
||||||
|
if (!token || !headers) return;
|
||||||
|
return fetch('/api/notification-token', {
|
||||||
|
headers,
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}).then(() => {
|
||||||
|
localStorage.setItem(NOTIFICATION_TOKEN_KEY, token);
|
||||||
|
});
|
||||||
|
}, { manual: true, refreshDeps: [headers] });
|
||||||
|
const unsetTokenReq = useRequest(async (token: string) => {
|
||||||
|
if (!token || !headers) return;
|
||||||
|
return fetch('/api/notification-token', {
|
||||||
|
headers,
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
}, { manual: true, refreshDeps: [headers] });
|
||||||
|
const isTokenSetedReq = useRequest<boolean, [string]>(async (token) => {
|
||||||
|
if (!token || !headers) return false;
|
||||||
|
return fetch(`/api/notification-token?token=${token}`, {
|
||||||
|
headers,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => data.isSubscribed);
|
||||||
|
}, { manual: true, refreshDeps: [headers]});
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
return updateTokenReq.loading || unsetTokenReq.loading || isTokenSetedReq.loading;
|
||||||
|
}, [updateTokenReq, unsetTokenReq, isTokenSetedReq]);
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
isTokenSetedReq,
|
||||||
|
updateTokenReq,
|
||||||
|
unsetTokenReq,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,20 @@
|
|||||||
import { logger } from '@/utils/logger';
|
|
||||||
import { verifyLogtoToken, xcxApi } from "@/utils/server";
|
|
||||||
import ics from 'ics';
|
import ics from 'ics';
|
||||||
import index from "@/index.html";
|
|
||||||
import { getUidScore } from "@/services/uidScoreStore";
|
|
||||||
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "@/services/favPlayerService";
|
|
||||||
import { BattleService } from "@/services/BattleService";
|
|
||||||
import { KaiqiuService } from "@/services/KaiqiuService";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from 'dayjs/plugin/utc';
|
import utc from 'dayjs/plugin/utc';
|
||||||
import timezone from 'dayjs/plugin/timezone';
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
import type { IEventInfo, WsPaylaod } from "@/types";
|
import { logger } from '@/utils/logger';
|
||||||
|
import type { IEventInfo, NotificationData } from "@/types";
|
||||||
import { EventSubscribeService } from "@/services/EventSubscribeService";
|
import { EventSubscribeService } from "@/services/EventSubscribeService";
|
||||||
import { WebSocketService } from "@/services/WebsocketService";
|
|
||||||
import type { JWTPayload } from "jose";
|
|
||||||
import { prisma } from "@/prisma/db";
|
import { prisma } from "@/prisma/db";
|
||||||
import xprisma from "@/dao/xprisma";
|
import xprisma from "@/dao/xprisma";
|
||||||
import { EventWatchSchedule } from "@/schedules/EventWatchSchedule";
|
import { EventWatchSchedule } from "@/schedules/EventWatchSchedule";
|
||||||
import { sendNotification } from "@/utils/firebase-admin";
|
import { sendNotification } from "@/utils/firebase-admin";
|
||||||
|
import { verifyLogtoToken, xcxApi } from "@/utils/server";
|
||||||
|
import { getUidScore } from "@/services/uidScoreStore";
|
||||||
|
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "@/services/favPlayerService";
|
||||||
|
import { BattleService } from "@/services/BattleService";
|
||||||
|
import { KaiqiuService } from "@/services/KaiqiuService";
|
||||||
|
import index from "@/index.html";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
@ -30,7 +28,6 @@ const server = Bun.serve({
|
|||||||
'/assets/*': async (req) => {
|
'/assets/*': async (req) => {
|
||||||
const pathname = new URL(req.url).pathname;
|
const pathname = new URL(req.url).pathname;
|
||||||
const filepath = `.${pathname}`;
|
const filepath = `.${pathname}`;
|
||||||
logger.debug('Read file: %s', filepath);
|
|
||||||
if (!/^\/assets/.test(pathname)) return new Response(null, { status: 404 });
|
if (!/^\/assets/.test(pathname)) return new Response(null, { status: 404 });
|
||||||
const file = await Bun.file(filepath);
|
const file = await Bun.file(filepath);
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
@ -42,21 +39,15 @@ const server = Bun.serve({
|
|||||||
const build = await Bun.build({
|
const build = await Bun.build({
|
||||||
entrypoints: ["./src/sw.ts"],
|
entrypoints: ["./src/sw.ts"],
|
||||||
target: "browser",
|
target: "browser",
|
||||||
// 如果你需要压缩,可以开启
|
|
||||||
minify: process.env.NODE_ENV === "production",
|
minify: process.env.NODE_ENV === "production",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!build.success) {
|
if (!build.success) {
|
||||||
return new Response("Build Error", { status: 500 });
|
return new Response("Build Error", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取编译后的第一个输出文件(即 sw.js)
|
|
||||||
const blob = build.outputs[0];
|
const blob = build.outputs[0];
|
||||||
|
|
||||||
return new Response(blob, {
|
return new Response(blob, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/javascript",
|
"Content-Type": "application/javascript",
|
||||||
// 开发环境下禁用缓存,确保 SW 能及时更新
|
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Service-Worker-Allowed": "/",
|
"Service-Worker-Allowed": "/",
|
||||||
},
|
},
|
||||||
@ -93,7 +84,13 @@ const server = Bun.serve({
|
|||||||
const [hasOldToken] = await Promise.all([
|
const [hasOldToken] = await Promise.all([
|
||||||
prisma.notificationToken.count({ where }).then(num => num > 0),
|
prisma.notificationToken.count({ where }).then(num => num > 0),
|
||||||
]);
|
]);
|
||||||
await sendNotification(token, { title: '通知已注册!', url: 'https://tt.ksr.la/user-center' });
|
const notification: NotificationData = { topic: 'SERVER_PUSH', data: {
|
||||||
|
title: '通知已注册!',
|
||||||
|
options: {
|
||||||
|
data: { url: 'https://tt.ksr.la/user-center' },
|
||||||
|
}
|
||||||
|
} };
|
||||||
|
await sendNotification(token, notification);
|
||||||
if (hasOldToken) {
|
if (hasOldToken) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
@ -373,7 +370,9 @@ const server = Bun.serve({
|
|||||||
'/api/account/bind': {
|
'/api/account/bind': {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
const { sub } = await verifyLogtoToken(req.headers);
|
const { sub } = await verifyLogtoToken(req.headers);
|
||||||
if (!sub) return Response.json(false);
|
if (!sub) return Response.json({
|
||||||
|
isBinded: false,
|
||||||
|
});
|
||||||
const record = await prisma.userBind.findFirst({
|
const record = await prisma.userBind.findFirst({
|
||||||
where: { logto_uid: sub },
|
where: { logto_uid: sub },
|
||||||
});
|
});
|
||||||
@ -401,35 +400,6 @@ const server = Bun.serve({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'/ws': {
|
|
||||||
async GET(req, server) {
|
|
||||||
const token = new URL(req.url).searchParams.get('token');
|
|
||||||
if (!token) return new Response('Not valid token', { status: 401 });
|
|
||||||
const user = await verifyLogtoToken(token) as Required<JWTPayload>;
|
|
||||||
if (!user.sub) return new Response('Not valid token', { status: 401 });
|
|
||||||
logger.debug('ws connect', user.sub);
|
|
||||||
server.upgrade(req, {
|
|
||||||
data: { user },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
data: {} as WsPaylaod,
|
|
||||||
open(ws) {
|
|
||||||
WebSocketService.addConnection(ws);
|
|
||||||
},
|
|
||||||
message(ws, message) {
|
|
||||||
try {
|
|
||||||
WebSocketService.processMessage(ws, message);
|
|
||||||
} catch(e) {
|
|
||||||
logger.error('Parse message error', e, message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close(ws, code, reason) {
|
|
||||||
logger.debug('close ws', code, reason)
|
|
||||||
WebSocketService.removeConnection(ws);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
development: process.env.NODE_ENV !== "production" && {
|
development: process.env.NODE_ENV !== "production" && {
|
||||||
@ -444,4 +414,4 @@ const server = Bun.serve({
|
|||||||
const eventSchedule = new EventWatchSchedule();
|
const eventSchedule = new EventWatchSchedule();
|
||||||
eventSchedule.start();
|
eventSchedule.start();
|
||||||
|
|
||||||
logger.info(`🚀 Server running at ${server.url}`, server);
|
console.debug(`🚀 Server running at ${server.url}`);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useAppVersion } from "@/hooks/useAppVersion";
|
|||||||
import { BindKaiqiuAccount } from "@/components/BindKaiqiuAccount";
|
import { BindKaiqiuAccount } from "@/components/BindKaiqiuAccount";
|
||||||
import { ChangeBackground } from "@/components/ChangeBackground";
|
import { ChangeBackground } from "@/components/ChangeBackground";
|
||||||
import PermissionControlPanel from "@/components/PermissionControlPanel";
|
import PermissionControlPanel from "@/components/PermissionControlPanel";
|
||||||
|
import { useAuthHeaders } from "@/hooks/useAuthHeaders";
|
||||||
|
|
||||||
enum modifyRoutes {
|
enum modifyRoutes {
|
||||||
username = '/account/username',
|
username = '/account/username',
|
||||||
@ -40,7 +41,7 @@ export const UserCenter = () => {
|
|||||||
const { signIn, isAuthenticated, signOut, getIdTokenClaims } = useLogto();
|
const { signIn, isAuthenticated, signOut, getIdTokenClaims } = useLogto();
|
||||||
const { autoSignIn } = useAutoLogin();
|
const { autoSignIn } = useAutoLogin();
|
||||||
const [user, setUser] = useState<IdTokenClaims>();
|
const [user, setUser] = useState<IdTokenClaims>();
|
||||||
const navigate = useNavigate();
|
useAuthHeaders();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const handleSignIn = useCallback(() => {
|
const handleSignIn = useCallback(() => {
|
||||||
signIn(AUTH_CALLBACK_URL);
|
signIn(AUTH_CALLBACK_URL);
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import * as nodeSchedule from 'node-schedule';
|
import * as nodeSchedule from 'node-schedule';
|
||||||
import { EventSubscribeService } from '../services/EventSubscribeService';
|
import { EventSubscribeService } from '@/services/EventSubscribeService';
|
||||||
import type { EventDetail, Player } from '../types';
|
import type { EventDetail, NotificationData, Player } from '@/types';
|
||||||
import { redis } from '../utils/server';
|
import { redis } from '@/utils/server';
|
||||||
import { diffArrays } from '../utils/common';
|
import { diffArrays } from '@/utils/common';
|
||||||
import { sendNotification } from '../utils/firebase-admin';
|
import { sendNotification } from '@/utils/firebase-admin';
|
||||||
import { prisma } from '../prisma/db';
|
import { prisma } from '@/prisma/db';
|
||||||
import type { Schedule } from './schedule';
|
import type { Schedule } from '@/schedules/schedule';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
@ -69,9 +69,19 @@ export class EventWatchSchedule implements Schedule {
|
|||||||
const tokens = await prisma.notificationToken
|
const tokens = await prisma.notificationToken
|
||||||
.findMany({ where: { logto_uid: { in: uids }}, select: { token: true } })
|
.findMany({ where: { logto_uid: { in: uids }}, select: { token: true } })
|
||||||
.then(v => v.map(e => e.token));
|
.then(v => v.map(e => e.token));
|
||||||
await Promise.all(tokens.map(token => sendNotification(token, { title, body, url })));
|
const payload: NotificationData = {
|
||||||
|
topic: 'SERVER_PUSH',
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
options: { body, data: { url }},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await Promise.all(tokens.map(token => sendNotification(token, payload)));
|
||||||
}
|
}
|
||||||
start() {
|
start() {
|
||||||
|
if (process.env.NODE_ENV !== 'product') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const job = nodeSchedule.scheduleJob('event-watch', {
|
const job = nodeSchedule.scheduleJob('event-watch', {
|
||||||
minute: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
|
minute: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
|
||||||
hour: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
|
hour: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { prisma } from "../prisma/db";
|
import { prisma } from "../prisma/db";
|
||||||
import type { EventDetail } from "../types";
|
import type { EventDetail, NotificationData } from "../types";
|
||||||
import { KaiqiuService } from "./KaiqiuService";
|
import { KaiqiuService } from "./KaiqiuService";
|
||||||
|
import { broadcast } from "@/utils/firebase-admin";
|
||||||
|
|
||||||
export class EventSubscribeService {
|
export class EventSubscribeService {
|
||||||
public static async sub(user: string, event: string) {
|
public static async sub(user: string, event: string) {
|
||||||
@ -12,6 +13,18 @@ export class EventSubscribeService {
|
|||||||
logger.error('Subscribe event faild', { user, event, e });
|
logger.error('Subscribe event faild', { user, event, e });
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
const { title } = await KaiqiuService.getMatchSummary(event);
|
||||||
|
const payload = {
|
||||||
|
topic: 'SERVER_PUSH',
|
||||||
|
data: {
|
||||||
|
title: '订阅成功',
|
||||||
|
options: {
|
||||||
|
body: title,
|
||||||
|
data: { url: `https://tt.ksr.la/event/${event}` },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} as NotificationData;
|
||||||
|
broadcast(user, payload);
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
public static async unSub(user: string, event: string) {
|
public static async unSub(user: string, event: string) {
|
||||||
|
|||||||
@ -225,7 +225,7 @@ export class KaiqiuService {
|
|||||||
public static async login(username: string, password: string) {
|
public static async login(username: string, password: string) {
|
||||||
const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref');
|
const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref');
|
||||||
const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
|
const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
|
||||||
logger.debug('Bind user', { cookies, username, password });
|
logger.debug('Bind user', { username });
|
||||||
let $ = cheerio.load(await loginPageRes.text());
|
let $ = cheerio.load(await loginPageRes.text());
|
||||||
const loginsubmit = $('#loginsubmit').val()?.toString() || '';
|
const loginsubmit = $('#loginsubmit').val()?.toString() || '';
|
||||||
const formhash = $('#loginform > input[type=hidden]').val()?.toString() || '';
|
const formhash = $('#loginform > input[type=hidden]').val()?.toString() || '';
|
||||||
|
|||||||
@ -1,154 +0,0 @@
|
|||||||
import { ensureTopicData, fromCustomMessage, getEventSubKey, toWebProcessMessage, type WsServerSendTopicPayload, type WsServerSendTopics, type WsTopicPayload } from "../utils/common";
|
|
||||||
import { EventSubscribeService } from "./EventSubscribeService";
|
|
||||||
import type { WsPaylaod } from "../types";
|
|
||||||
|
|
||||||
const publicTopics: WsServerSendTopics[] = [
|
|
||||||
'MEMBER_CHANGE',
|
|
||||||
'MY_CLIENT_ONLINE',
|
|
||||||
'DEBUG_MSG',
|
|
||||||
'EVENT_MEMBER_CHANGE',
|
|
||||||
"MSG",
|
|
||||||
];
|
|
||||||
|
|
||||||
type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>;
|
|
||||||
|
|
||||||
export class WebSocketService {
|
|
||||||
static #connections = new Set<BunServerWebSocket>();
|
|
||||||
static #userSubTopics = new Map<string, Set<string>>();
|
|
||||||
static #userClients = new Map<string, Set<BunServerWebSocket>>();
|
|
||||||
|
|
||||||
static async addConnection(ws: BunServerWebSocket) {
|
|
||||||
this.#connections.add(ws);
|
|
||||||
const user = ws.data.user;
|
|
||||||
let isNewMember = false;
|
|
||||||
if (!this.#userClients.has(user.sub)) {
|
|
||||||
this.#userClients.set(user.sub, new Set());
|
|
||||||
isNewMember = true;
|
|
||||||
}
|
|
||||||
this.#userClients.get(user.sub)?.add(ws);
|
|
||||||
await this.#initSubscribe(ws);
|
|
||||||
if (isNewMember) {
|
|
||||||
this.broadcast('MEMBER_CHANGE', this.#userClients.size ?? 0);
|
|
||||||
}
|
|
||||||
this.userClientsBroadcast(
|
|
||||||
ws.data.user.sub,
|
|
||||||
'MY_CLIENT_ONLINE',
|
|
||||||
this.#userClients.get(user.sub)?.size ?? 0,
|
|
||||||
);
|
|
||||||
this.userClientsBroadcast(
|
|
||||||
ws.data.user.sub,
|
|
||||||
'DEBUG_MSG',
|
|
||||||
`SubscribeKeys:\n${[...(this.#userSubTopics.get(ws.data.user.sub) ?? [])].join('|')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static async #initSubscribe(ws: BunServerWebSocket) {
|
|
||||||
const user = ws.data.user;
|
|
||||||
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => getEventSubKey(v)));
|
|
||||||
// this.userSub(ws, user.sub, publicTopics);
|
|
||||||
this.userSub(ws, user.sub, subEvets);
|
|
||||||
// this.userSub(ws, user.sub, [`MSG:${user.sub}`]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) {
|
|
||||||
ws.publish(topic, message);
|
|
||||||
if (withSelf) {
|
|
||||||
ws.send(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async userClientsBroadcast<T extends WsServerSendTopics>(user: string, topic: T, data: WsServerSendTopicPayload[T]) {
|
|
||||||
this.#userClients.get(user)?.forEach(ws => {
|
|
||||||
if (!ws.isSubscribed(topic)) return;
|
|
||||||
ws.send(JSON.stringify({ topic, data }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeConnection(ws: BunServerWebSocket) {
|
|
||||||
this.#connections.delete(ws);
|
|
||||||
console.debug('Someone disconnected. User: ', ws.data.user.sub);
|
|
||||||
this.#userClients.get(ws.data.user.sub)?.delete(ws);
|
|
||||||
if (this.#userClients.get(ws.data.user.sub)?.size === 0) {
|
|
||||||
this.#userClients.delete(ws.data.user.sub);
|
|
||||||
this.publish(ws, 'MEMBER_CHANGE', toWebProcessMessage('MEMBER_CHANGE', this.#userClients.size));
|
|
||||||
}
|
|
||||||
this.userClientsBroadcast(
|
|
||||||
ws.data.user.sub,
|
|
||||||
'MY_CLIENT_ONLINE',
|
|
||||||
this.#userClients.get(ws.data.user.sub)?.size ?? 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static userSub(ws: BunServerWebSocket, user: string, topics: string[]) {
|
|
||||||
if (!this.#userSubTopics.has(user)) {
|
|
||||||
this.#userSubTopics.set(user, new Set<string>());
|
|
||||||
}
|
|
||||||
if (!topics.length) return;
|
|
||||||
topics.forEach(topic => {
|
|
||||||
this.#userSubTopics.get(user)?.add(topic);
|
|
||||||
ws.subscribe(topic);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static userNotificate<T extends WsServerSendTopics>(user: string, topic: T, data: WsTopicPayload[T]) {
|
|
||||||
const connections = this.#userClients.get(user);
|
|
||||||
if (!connections?.size) return;
|
|
||||||
connections.forEach(con => {
|
|
||||||
if (con.isSubscribed(topic)) {
|
|
||||||
con.sendText(toWebProcessMessage(topic, data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static broadcast<T extends WsServerSendTopics>(topic: T, data: WsTopicPayload[T], checkTopic?: T | string) {
|
|
||||||
console.debug('broadcast', { topic, data });
|
|
||||||
this.#connections.values().forEach((con, i) => {
|
|
||||||
const isSubscribed = con.isSubscribed(topic || checkTopic);
|
|
||||||
console.debug(`CheckTopicSubscribed`, {
|
|
||||||
[topic]: con.isSubscribed(topic),
|
|
||||||
...checkTopic ? { [checkTopic]: con.isSubscribed(checkTopic) } : {},
|
|
||||||
});
|
|
||||||
if (isSubscribed) {
|
|
||||||
console.debug('Send broadcast to [%s]: %s', i + 1, con.data.user.sub, { topic, data });
|
|
||||||
con.send(toWebProcessMessage(topic, data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static processMessage(ws: BunServerWebSocket, message: string | Buffer<ArrayBuffer>) {
|
|
||||||
const { clientTopic: action, data } = fromCustomMessage(message.toString());
|
|
||||||
if (!action) return;
|
|
||||||
console.debug('Recieve action: %s', action, message);
|
|
||||||
switch (action) {
|
|
||||||
case 'SUB': {
|
|
||||||
const topic = ensureTopicData<'SUB'>(data)?.topic ?? '';
|
|
||||||
ws.subscribe(topic);
|
|
||||||
if (!this.#userSubTopics.get(ws.data.user.sub)) {
|
|
||||||
this.#userSubTopics.set(ws.data.user.sub, new Set());
|
|
||||||
}
|
|
||||||
this.#userSubTopics.get(ws.data.user.sub)?.add(topic);
|
|
||||||
console.debug('Subs', this.#userSubTopics.get(ws.data.user.sub));
|
|
||||||
this.userClientsBroadcast(ws.data.user.sub, 'DEBUG_MSG', `Some client subscribed to: ${topic}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'UNSUB': {
|
|
||||||
const topic = ensureTopicData<'UNSUB'>(data)?.topic ?? '';
|
|
||||||
ws.unsubscribe(topic);
|
|
||||||
if (!this.#userSubTopics.get(ws.data.user.sub)) {
|
|
||||||
this.#userSubTopics.set(ws.data.user.sub, new Set());
|
|
||||||
}
|
|
||||||
this.#userSubTopics.get(ws.data.user.sub)?.delete(topic);
|
|
||||||
console.debug('Subs', this.#userSubTopics.get(ws.data.user.sub));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'MSG': {
|
|
||||||
const msgData = ensureTopicData<'MSG'>(data);
|
|
||||||
if (!msgData) return;
|
|
||||||
this.broadcast('MSG', msgData, `MSG:${msgData.channel}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
src/sw.ts
19
src/sw.ts
@ -5,6 +5,7 @@ import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw';
|
|||||||
import { firebaseConfig } from "./utils/firebase";
|
import { firebaseConfig } from "./utils/firebase";
|
||||||
import type { NotificationData } from './types';
|
import type { NotificationData } from './types';
|
||||||
import type { MessagePayload } from 'firebase/messaging';
|
import type { MessagePayload } from 'firebase/messaging';
|
||||||
|
import { fromMessagePayload, type TopicPayload } from './utils/common';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
@ -25,15 +26,21 @@ self.addEventListener('activate', (event) => {
|
|||||||
|
|
||||||
// 核心:处理后台消息
|
// 核心:处理后台消息
|
||||||
onBackgroundMessage(messaging, (payload: MessagePayload) => {
|
onBackgroundMessage(messaging, (payload: MessagePayload) => {
|
||||||
const data = payload.data as NotificationData;
|
|
||||||
console.debug('[sw.js] 收到后台消息: ', JSON.stringify(payload));
|
console.debug('[sw.js] 收到后台消息: ', JSON.stringify(payload));
|
||||||
const notificationTitle = data.title;
|
const msgPayload = fromMessagePayload<'SERVER_PUSH'>(payload);
|
||||||
|
if (!msgPayload) return;
|
||||||
|
const { topic, data } = msgPayload;
|
||||||
|
if (topic !== 'SERVER_PUSH') return;
|
||||||
|
const topicPayload = data as TopicPayload['SERVER_PUSH'];
|
||||||
|
const notificationTitle = topicPayload.title;
|
||||||
|
const options = topicPayload.options;
|
||||||
const notificationOptions = {
|
const notificationOptions = {
|
||||||
body: data.body,
|
...options,
|
||||||
icon: data.icon, // 你的图标
|
// body: data.body,
|
||||||
data: { url: data.url } // 携带点击跳转的 URL
|
// icon: data.icon, // 你的图标
|
||||||
|
// data: { url: data.url } // 携带点击跳转的 URL
|
||||||
};
|
};
|
||||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('sw', self);
|
console.log('sw', self);
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { TopicPayload, Topics } from '@/utils/common';
|
||||||
import type { JWTPayload } from 'jose';
|
import type { JWTPayload } from 'jose';
|
||||||
|
|
||||||
export * from './profile';
|
export * from './profile';
|
||||||
@ -106,8 +107,6 @@ export type WsPaylaod = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationData = {
|
export type NotificationData = {
|
||||||
title: string;
|
topic: Topics;
|
||||||
body?: string;
|
data: TopicPayload[Topics];
|
||||||
icon?: string;
|
|
||||||
url?: string;
|
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
|
import type { NotificationData } from "@/types";
|
||||||
|
import type { MessagePayload } from "firebase/messaging";
|
||||||
import { chunk } from "lodash";
|
import { chunk } from "lodash";
|
||||||
import type { Player } from "../types";
|
|
||||||
|
|
||||||
export const KAIQIU_BASE_URL = `https://kaiqiuwang.cc`;
|
export const KAIQIU_BASE_URL = `https://kaiqiuwang.cc`;
|
||||||
export const LOGTO_DOMAIN = 'https://logto.ksr.la';
|
export const LOGTO_DOMAIN = 'https://logto.ksr.la';
|
||||||
@ -88,15 +89,7 @@ export function calculate(winerScore: number, loserScore: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsServerSendTopicPayload = {
|
export type ServerSendTopicPayload = {
|
||||||
MEMBER_CHANGE: number;
|
|
||||||
MY_CLIENT_ONLINE: number;
|
|
||||||
DEBUG_MSG: string;
|
|
||||||
EVENT_MEMBER_CHANGE: {
|
|
||||||
event: string;
|
|
||||||
memberNum: number;
|
|
||||||
};
|
|
||||||
MSG: { channel: string; name: string; avatar: string; message: string };
|
|
||||||
SERVER_PUSH: {
|
SERVER_PUSH: {
|
||||||
title: string;
|
title: string;
|
||||||
options?: NotificationOptions & {
|
options?: NotificationOptions & {
|
||||||
@ -107,21 +100,12 @@ export type WsServerSendTopicPayload = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsServerSendTopics = keyof WsServerSendTopicPayload;
|
export type ServerSendTopics = keyof ServerSendTopicPayload;
|
||||||
|
|
||||||
export type WsWebSendTopicPayload = {
|
export type TopicPayload = ServerSendTopicPayload;
|
||||||
UNKONOW: undefined;
|
export type Topics = keyof TopicPayload;
|
||||||
SUB: { topic: string };
|
|
||||||
UNSUB: { topic: string };
|
|
||||||
MSG: { channel: string; name: string; avatar: string; message: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WsWebSendTopics = keyof WsWebSendTopicPayload;
|
export function ensureTopicData<T extends Topics>(data: any): TopicPayload[T] | undefined {
|
||||||
|
|
||||||
export type WsTopicPayload = WsServerSendTopicPayload & WsWebSendTopicPayload;
|
|
||||||
export type WsTopics = keyof WsTopicPayload;
|
|
||||||
|
|
||||||
export function ensureTopicData<T extends WsTopics>(data: any): WsTopicPayload[T] | undefined {
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,42 +114,12 @@ export function getEventSubKey(eventId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toWebProcessMessage(
|
export function toWebProcessMessage(
|
||||||
topic: WsTopics,
|
topic: Topics,
|
||||||
data: WsTopicPayload[WsTopics],
|
data: TopicPayload[Topics],
|
||||||
) {
|
) {
|
||||||
return JSON.stringify({ topic, data });
|
return JSON.stringify({ topic, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromCustomMessage(message: string): {
|
|
||||||
clientTopic: WsWebSendTopics;
|
|
||||||
data?: WsWebSendTopicPayload[WsWebSendTopics];
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const { topic: clientTopic, data } = JSON.parse(message);
|
|
||||||
return { clientTopic, data };
|
|
||||||
} catch(e) {
|
|
||||||
return {
|
|
||||||
clientTopic: 'UNKONOW',
|
|
||||||
data: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromServerMessage(message: string): {
|
|
||||||
topic: WsServerSendTopics;
|
|
||||||
data?: WsServerSendTopicPayload[WsServerSendTopics];
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const { topic, data } = JSON.parse(message);
|
|
||||||
return { topic, data };
|
|
||||||
} catch(e) {
|
|
||||||
return {
|
|
||||||
topic: 'DEBUG_MSG',
|
|
||||||
data: `${e}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function diffArrays(array: Set<string>, compare: Set<string>) {
|
export function diffArrays(array: Set<string>, compare: Set<string>) {
|
||||||
const deletedSet = new Set(array);
|
const deletedSet = new Set(array);
|
||||||
@ -182,3 +136,18 @@ export function diffArrays(array: Set<string>, compare: Set<string>) {
|
|||||||
newMemberList: newSet.values().toArray(),
|
newMemberList: newSet.values().toArray(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fromMessagePayload<T extends Topics>(payload: MessagePayload) {
|
||||||
|
console.debug('收到消息: ', payload);
|
||||||
|
if ((!payload.data)) return null;
|
||||||
|
try {
|
||||||
|
const { topic, data }: NotificationData = JSON.parse(payload.data?.payload ?? '{}');
|
||||||
|
return {
|
||||||
|
topic,
|
||||||
|
data: data as TopicPayload[T],
|
||||||
|
};
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Not a valid payload');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,4 +3,5 @@ export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR';
|
|||||||
export const STORE_PAGE_LIST_KEY = 'events-page-keys';
|
export const STORE_PAGE_LIST_KEY = 'events-page-keys';
|
||||||
export const MATCH_RESULT_MAP_KEY = 'match-result-map';
|
export const MATCH_RESULT_MAP_KEY = 'match-result-map';
|
||||||
export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE';
|
export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE';
|
||||||
|
export const NOTIFICATION_TOKEN_KEY = 'NOTIFICATION_TOKEN';
|
||||||
export const VAPI_PUBLIC_KEY = 'BEdP0MXtxreY9jH5fXorkMLHVDOW2N3UgFcOzHUEjDf9HxhLQkjuzHq05kMEs0Lpvez4xpTiKmBYcWwwzJyuN2c';
|
export const VAPI_PUBLIC_KEY = 'BEdP0MXtxreY9jH5fXorkMLHVDOW2N3UgFcOzHUEjDf9HxhLQkjuzHq05kMEs0Lpvez4xpTiKmBYcWwwzJyuN2c';
|
||||||
@ -1,8 +1,11 @@
|
|||||||
|
|
||||||
import admin from "firebase-admin";
|
import admin from "firebase-admin";
|
||||||
import { getMessaging } from "firebase-admin/messaging";
|
import { getMessaging } from "firebase-admin/messaging";
|
||||||
|
import type { NotificationData } from "@/types";
|
||||||
|
import type { TopicPayload } from "@/utils/common";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import { prisma } from "@/prisma/db";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { NotificationData } from "../types";
|
|
||||||
|
|
||||||
var serviceAccount = require(path.resolve(__dirname, '..', 'private', 'my-kaiqiuwang-firebase-adminsdk-fbsvc-8712e14c10.json'));
|
var serviceAccount = require(path.resolve(__dirname, '..', 'private', 'my-kaiqiuwang-firebase-adminsdk-fbsvc-8712e14c10.json'));
|
||||||
|
|
||||||
@ -10,11 +13,44 @@ admin.initializeApp({
|
|||||||
credential: admin.credential.cert(serviceAccount)
|
credential: admin.credential.cert(serviceAccount)
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function sendNotification(token: string, data: NotificationData, dryRun?: boolean) {
|
export async function sendNotification(token: string, payload: NotificationData, dryRun?: boolean) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const messageId = await getMessaging().send({
|
const messaging = await getMessaging();
|
||||||
|
const messageid = await messaging.send({
|
||||||
token,
|
token,
|
||||||
data: data as any,
|
data: {
|
||||||
}, dryRun);
|
payload: JSON.stringify(payload),
|
||||||
return messageId;
|
},
|
||||||
|
}, dryRun).catch(async error => {
|
||||||
|
if (error.code === 'messaging/registration-token-not-registered') {
|
||||||
|
const result = await prisma.notificationToken.deleteMany({
|
||||||
|
where: { token },
|
||||||
|
});
|
||||||
|
logger.error('Delete notification token', { token, result });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.debug('sendNotification', { payload, dryRun, messageid });
|
||||||
|
if (!messageid) return;
|
||||||
|
if (payload.topic === 'SERVER_PUSH') {
|
||||||
|
// 旧版本通知
|
||||||
|
const { title, options } = payload.data as TopicPayload['SERVER_PUSH'];
|
||||||
|
const data = {
|
||||||
|
title,
|
||||||
|
body: options?.body ?? '',
|
||||||
|
url: options?.data?.url ?? '',
|
||||||
|
};
|
||||||
|
logger.debug('sendNotification fix old push', { data });
|
||||||
|
messaging.send({ token, data }).catch(error => {
|
||||||
|
logger.error('Send notification error', { error, token });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function broadcast(logto_uid: string, payload: NotificationData, dryRun?: boolean) {
|
||||||
|
const tokens = await prisma.notificationToken.findMany({
|
||||||
|
where: { logto_uid },
|
||||||
|
select: { token: true },
|
||||||
|
}).then(values => values.map(e => e.token));
|
||||||
|
await Promise.all(tokens.map(token => sendNotification(token, payload, dryRun)));
|
||||||
|
logger.debug('broadcast notification', { logto_uid });
|
||||||
}
|
}
|
||||||
@ -5,6 +5,6 @@ export const logger = winston.createLogger({
|
|||||||
format: winston.format.json(),
|
format: winston.format.json(),
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.Console({ format: winston.format.json()}),
|
new winston.transports.Console({ format: winston.format.json()}),
|
||||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
// new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user