From d2581c8f501fb783a0d672f2a3dbff6da39e2f86 Mon Sep 17 00:00:00 2001 From: ptyoiy <56474564+ptyoiy@users.noreply.github.com> Date: Wed, 3 Jul 2024 17:03:25 +0900 Subject: [PATCH] feat: code spliting to common_method, add scheduler & handler for fcm --- adminPage/components/dashboard/Dashboard.tsx | 3 +- adminPage/handlers/event.ts | 244 ++++++++++-------- adminPage/handlers/notice.ts | 110 ++++---- adminPage/index.ts | 4 +- .../alarm => common_method}/fcmUtils.ts | 17 +- common_method/fcm_schedule.ts | 131 ++++++++++ common_method/index.ts | 47 ++++ .../post_schedule.ts | 64 ++--- .../user_information.ts | 4 +- .../common_method => common_method}/utils.ts | 2 +- .../validator.ts | 2 +- controllers/alarm/alarm.ts | 28 +- controllers/alarm/index.ts | 5 +- controllers/common_method/index.ts | 9 - controllers/event/event_bookmark.ts | 3 +- controllers/event/event_like.ts | 2 +- controllers/event/event_search.ts | 5 +- controllers/jwt/jwt.ts | 2 +- controllers/linktree/linktree.ts | 2 +- controllers/notice/notice_like.ts | 2 +- controllers/notice/notice_read.ts | 4 +- controllers/notice/notice_search.ts | 2 +- controllers/user/major.ts | 4 +- controllers/user/user.ts | 2 +- redis/caching.ts | 23 +- routes/alarm.ts | 3 +- 26 files changed, 488 insertions(+), 236 deletions(-) rename {controllers/alarm => common_method}/fcmUtils.ts (77%) create mode 100644 common_method/fcm_schedule.ts create mode 100644 common_method/index.ts rename redis/schedule.ts => common_method/post_schedule.ts (55%) rename {controllers/common_method => common_method}/user_information.ts (97%) rename {controllers/common_method => common_method}/utils.ts (86%) rename {controllers/common_method => common_method}/validator.ts (95%) delete mode 100644 controllers/common_method/index.ts diff --git a/adminPage/components/dashboard/Dashboard.tsx b/adminPage/components/dashboard/Dashboard.tsx index 5c9a042..5dd92d3 100644 --- a/adminPage/components/dashboard/Dashboard.tsx +++ b/adminPage/components/dashboard/Dashboard.tsx @@ -11,6 +11,7 @@ import { useCurrentAdmin, useTranslation } from "adminjs"; import React, { useEffect, useRef } from "react"; import { useNavigate } from "react-router"; import { useLog } from "./hook"; + type BoxType = { variant: string; title: string; @@ -103,7 +104,6 @@ export const Dashboard = (props) => { const lines = log.split('\n'); const logRef = useRef(null); const maxLineNumberLength = String(lines.length).length; - useEffect(() => { // logRef가 현재 가리키는 요소의 스크롤을 맨 밑으로 설정 if (logRef.current && log) { @@ -146,7 +146,6 @@ export const Dashboard = (props) => { {box.subtitle} - ))} diff --git a/adminPage/handlers/event.ts b/adminPage/handlers/event.ts index 6042fc5..e352841 100644 --- a/adminPage/handlers/event.ts +++ b/adminPage/handlers/event.ts @@ -1,28 +1,31 @@ -import { ActionHandler, Filter, SortSetter, flat, populator } from "adminjs"; -import Event from "../../models/events.js"; -import { redisClient } from "../../redis/connect.js"; -import { EventActionQueryParameters } from "./index.js"; -import { IEvent } from "../../models/types.js"; -import { delEventSchedule, setEventSchedule } from "../../redis/schedule.js"; +import { ActionHandler, Filter, SortSetter, flat, populator } from 'adminjs'; import { - cacheYearMonthData, - calculateMonthsBetween, -} from "../../redis/caching.js"; + delEventToExpiredSchedule, + delEventsPushForBookmarkSchedule, + delEventsPushForEntireSchedule, + sendFCM, + setEventToExpiredSchedule, + setEventsPushForBookmarkSchedule, + setEventsPushForEntireSchedule, +} from '../../common_method/index.js'; +import Event from '../../models/events.js'; +import { IEvent } from '../../models/types.js'; +import { cacheYearMonthData, calculateMonthsBetween } from '../../redis/caching.js'; +import { redisClient } from '../../redis/connect.js'; +import { EventActionQueryParameters } from './index.js'; const list: ActionHandler = async (request, response, context) => { const { query } = request; // 요청 url의 query 부분 추출 const { resource, _admin, currentAdmin } = context; // db table const { role } = currentAdmin; - const unflattenQuery = flat.unflatten( - query || {} - ) as EventActionQueryParameters; - let { page, perPage, type = "ongoing" } = unflattenQuery; - const isOngoing = type == "ongoing"; + const unflattenQuery = flat.unflatten(query || {}) as EventActionQueryParameters; + let { page, perPage, type = 'ongoing' } = unflattenQuery; + const isOngoing = type == 'ongoing'; // 진행중인 행사 탭에서는 시작일 내림차순 정렬 // 종료된 행사 탭에서는 종료일 내림차순 정렬 const { - sortBy = isOngoing ? "start" : "end", - direction = "desc", + sortBy = isOngoing ? 'start' : 'end', + direction = 'desc', filters = { major_advisor: role }, } = unflattenQuery; @@ -39,16 +42,12 @@ const list: ActionHandler = async (request, response, context) => { const firstProperty = listProperties.find((p) => p.isSortable()); let sort; if (firstProperty) { - sort = SortSetter( - { sortBy, direction }, - firstProperty.name(), - resource.decorate().options - ); + sort = SortSetter({ sortBy, direction }, firstProperty.name(), resource.decorate().options); } // 진행중인 행사 탭이면 expired == false인 데이터만 // 종료된 행사 탭이면 expired == true인 데이터만 가져오기 const filter = await new Filter( - { ...filters, expired: isOngoing ? "false" : "true" }, + { ...filters, expired: isOngoing ? 'false' : 'true' }, resource ).populate(context); const records = await resource.find( @@ -81,91 +80,122 @@ const list: ActionHandler = async (request, response, context) => { * @param action after함수를 사용할 action * @returns action 실행 후 호출할 hook 함수 */ -const after = (action: "edit" | "new") => async (originalResponse, request, context) => { - const isPost = request.method === "post"; - const isEdit = context.action.name === action; - const { - currentAdmin: { role }, - } = context; - const hasRecord: IEvent = originalResponse?.record?.params; - const hasError = Object.keys(originalResponse.record.errors).length; - // checking if object doesn't have any errors or is a edit action - if (isPost && isEdit && hasRecord && !hasError) { - const { id, start, end } = hasRecord; - const promises = []; - // 학과는 로그인한 관리자의 것으로 적용 - if (role != "관리자") { - hasRecord.major_advisor = role; - } - // redis 캐싱 - const redisKeyEach = `event:${id}`; - const redisKeyAll = "allEvents"; - promises.push(redisClient.set(redisKeyEach, JSON.stringify(hasRecord))); - // end날이 아직 안 지났다면 - if (end > new Date()) { - // end날 이후 종료 상태로 변경하는 스케쥴 등록 - const recordModel = await Event.findOne({ - where: { id }, - }); - setEventSchedule(recordModel); - } - // 캐싱 - const allEvents = await Event.findAll({ - order: [["start", "ASC"]], +const after = (action: 'edit' | 'new') => async (originalResponse, request, context) => { + const isPost = request.method === 'post'; + const isEdit = context.action.name === action; + const { + currentAdmin: { role }, + } = context; + const hasRecord: IEvent = originalResponse?.record?.params; + const hasError = Object.keys(originalResponse.record.errors).length; + // checking if object doesn't have any errors or is a edit action + if (isPost && isEdit && hasRecord && !hasError) { + const { id, start, end, title, content } = hasRecord; + const promises = []; // 병렬로 처리 시키기 위한 비동기 함수 배열 + const currentDate = new Date(); + // 학과는 로그인한 관리자의 것으로 적용 + if (role != '관리자') { + hasRecord.major_advisor = role; + } + + // #1. 행사 종료 스케쥴 등록 + if (end > currentDate) { + // end날짜가 아직 안 지났다면 end날짜 이후 종료 상태로 변경하는 스케쥴 등록 + const recordModel = await Event.findOne({ + where: { id }, }); - // 수정한 글의 시작~종료일 사이 연-월 리스트 추출 - const ranges = calculateMonthsBetween(start, end); - - // 진행 중인 행사글, 수정한 글의 시작~종료일 사이에 있는 모든 행사글(=관련글) 추출 - const [onGoings, relations] = allEvents.reduce( - (acc, event) => { - if (!event.expired) acc[0].push(event); - if ( - ranges.some((range) => { - const [year, month] = range.split("-"); - const startOfMonth = new Date(+year, +month - 1, 1); - const endOfMonth = new Date(+year, +month, 0, 23, 59, 59); - return ( - (event.start >= startOfMonth && event.start <= endOfMonth) || - (event.end >= startOfMonth && event.end <= endOfMonth) || - (event.start <= startOfMonth && event.end >= endOfMonth) - ); - }) - ) - acc[1].push(event); - return acc; - }, - [[], []] as IEvent[][] - ); - // 전체 목록 캐싱 - promises.push(redisClient.set(redisKeyAll, JSON.stringify(onGoings))); - console.log({ relations: relations.map((v) => v.id) }); - // 관련글들을 연:월로 grouping - promises.push(...cacheYearMonthData(relations)); - await Promise.all(promises); + setEventToExpiredSchedule(recordModel); } - return originalResponse; - }; + // #2. redis 캐싱 + const redisKeyEach = `event:${id}`; + const redisKeyAll = 'allEvents'; + promises.push(redisClient.set(redisKeyEach, JSON.stringify(hasRecord))); + const allEvents = await Event.findAll({ + order: [['start', 'ASC']], + }); + // 수정한 글의 시작~종료일 사이 연-월 리스트 추출 + const ranges = calculateMonthsBetween(start, end); + + // 진행 중인 행사글, 수정한 글의 시작~종료일 사이에 있는 모든 행사글(=관련글) 추출 + const [onGoings, relations] = allEvents.reduce( + (acc, event) => { + if (!event.expired) acc[0].push(event); + if ( + ranges.some((range) => { + const [year, month] = range.split('-'); + const startOfMonth = new Date(+year, +month - 1, 1); + const endOfMonth = new Date(+year, +month, 0, 23, 59, 59); + return ( + (event.start >= startOfMonth && event.start <= endOfMonth) || + (event.end >= startOfMonth && event.end <= endOfMonth) || + (event.start <= startOfMonth && event.end >= endOfMonth) + ); + }) + ) + acc[1].push(event); + return acc; + }, + [[], []] as IEvent[][] + ); + // 전체 목록 캐싱 + promises.push(redisClient.set(redisKeyAll, JSON.stringify(onGoings))); + console.log({ relations: relations.map((v) => v.id) }); + // 달력용 데이터 캐싱(관련글들을 연:월로 grouping) + promises.push(...cacheYearMonthData(relations)); + await Promise.all(promises); // 병렬 처리 + + // #3. 행사 등록 알림 전송 + const notification = { + title, + body: content, + }; + const type = 'events'; + // 수정일땐 보내지 않음 + if (action === 'new') sendFCM(notification, `events_post`, { + type, + id: `${id}` + }); + + // #4. 행사 시작 알림 전송 스케쥴 등록 + // 시작날이 지나지 않은 경우에만 등록 + if (hasRecord.start > currentDate) { + setEventsPushForEntireSchedule(hasRecord); + setEventsPushForBookmarkSchedule(hasRecord); + } + } + + return originalResponse; +}; const deleteAfter = () => async (originalResponse, request, context) => { - const isPost = request?.method === "post"; - const isAction = context?.action.name === "delete"; + const isPost = request?.method === 'post'; + const isAction = context?.action.name === 'delete'; const { record } = originalResponse; - console.log({isPost, action: context?.action.name, record}); + console.log({ isPost, action: context?.action.name, record }); // checking if object doesn't have any errors or is a edit action if (isPost && isAction && record) { const { id, start, end } = record; - const redisKeyAll = "allEvents"; + const redisKeyAll = 'allEvents'; const promises = []; + const currentDate = new Date(); promises.push(redisClient.del(`event:${id}`)); - if (end > new Date()) { - delEventSchedule(record); + // 스케쥴 제거 + if (end > currentDate) { + // expired 스케쥴 제거 + delEventToExpiredSchedule(record); } + // fcm 스케쥴 제거 + // 시작일이 지나지 않은 경우에만 제거 + if (record.start > currentDate) { + delEventsPushForEntireSchedule(record); + delEventsPushForBookmarkSchedule(record); + } + // 캐싱 const allEvents = await Event.findAll({ - order: [["start", "ASC"]], + order: [['start', 'ASC']], }); // 수정한 글의 시작~종료일 사이 연-월 리스트 추출 const ranges = calculateMonthsBetween(start, end); @@ -176,7 +206,7 @@ const deleteAfter = () => async (originalResponse, request, context) => { if (!event.expired) acc[0].push(event); if ( ranges.some((range) => { - const [year, month] = range.split("-"); + const [year, month] = range.split('-'); const startOfMonth = new Date(+year, +month - 1, 1); const endOfMonth = new Date(+year, +month, 0, 23, 59, 59); return ( @@ -202,27 +232,37 @@ const deleteAfter = () => async (originalResponse, request, context) => { }; const bulkDelete = () => async (originalResponse, request, context) => { - const isPost = request?.method === "post"; - const isAction = context?.action.name === "bulkDelete"; + const isPost = request?.method === 'post'; + const isAction = context?.action.name === 'bulkDelete'; const { records } = originalResponse; // checking if object doesn't have any errors or is a edit action if (isPost && isAction && records) { + const currentDate = new Date(); const promises = records.map(async ({ params: record }) => { - // redis 캐싱 제거 - if (record.end > new Date()) { - // 스케쥴에 등록된 행사들 제거 - delEventSchedule(record); + // 스케쥴 제거 + if (record.end > currentDate) { + // expired 스케쥴 제거 + delEventToExpiredSchedule(record); } + // fcm 스케쥴 제거 + // 시작일이 지나지 않은 경우에만 제거 + if (record.start > currentDate) { + delEventsPushForEntireSchedule(record); + delEventsPushForBookmarkSchedule(record); + } + // redis 캐싱 제거 return redisClient.del(`event:${record.id}`); }); - const redisKeyAll = "allEvents"; + const redisKeyAll = 'allEvents'; // 전체 목록 캐싱 const allEvents = await Event.findAll({ - order: [["start", "ASC"]], + order: [['start', 'ASC']], }); - promises.push(redisClient.set(redisKeyAll, JSON.stringify(allEvents.filter(e => !e.expired)))); + promises.push( + redisClient.set(redisKeyAll, JSON.stringify(allEvents.filter((e) => !e.expired))) + ); promises.push(...cacheYearMonthData(allEvents)); await Promise.all(promises); } diff --git a/adminPage/handlers/notice.ts b/adminPage/handlers/notice.ts index 529d026..1be0c35 100644 --- a/adminPage/handlers/notice.ts +++ b/adminPage/handlers/notice.ts @@ -1,10 +1,11 @@ import { ActionHandler, Filter, SortSetter, flat, populator } from "adminjs"; import { Op } from "sequelize"; +import { sendFCM } from "../../common_method/index.js"; +import { delAlertToNoticeSchedule, setAlertToNoticeSchedule } from "../../common_method/post_schedule.js"; import Notice from "../../models/notice.js"; import { INotice } from "../../models/types.js"; import { cachingAllNotices } from "../../redis/caching.js"; import { redisClient } from "../../redis/connect.js"; -import { delNoticeSchedule, setNoticeSchedule } from "../../redis/schedule.js"; import { NoticeActionQueryParameters } from "./index.js"; const list: ActionHandler = async (request, response, context) => { @@ -89,51 +90,61 @@ const list: ActionHandler = async (request, response, context) => { * @returns action 실행 후 호출할 hook 함수 */ const after = (action: "edit" | "new") => async (originalResponse, request, context) => { - const isPost = request.method === "post"; - const isAction = context.action.name === action; - const { - currentAdmin: { role }, - } = context; - const hasRecord = originalResponse?.record?.params; - const hasError = Object.keys(originalResponse.record.errors).length; - // checking if object doesn't have any errors or is a edit action - if (isPost && isAction && hasRecord && !hasError) { - // 학과는 로그인한 관리자의 것으로 적용 - if (role != "관리자") { - hasRecord.major_advisor = role; - } - const { priority, id } = hasRecord; - const isGeneral = priority == "일반"; - const redisKeyEach = `notice:${id}`; - const redisKeyAll = `alerts:${isGeneral ? "general" : "urgent"}`; + const isPost = request.method === "post"; + const isAction = context.action.name === action; + const { + currentAdmin: { role }, + } = context; + const hasRecord: INotice = originalResponse?.record?.params; + const hasError = Object.keys(originalResponse.record.errors).length; + // checking if object doesn't have any errors or is a edit action + if (isPost && isAction && hasRecord && !hasError) { + // 학과는 로그인한 관리자의 것으로 적용 + if (role != "관리자") { + hasRecord.major_advisor = role; + } + const { priority, id, title, content } = hasRecord; + const isGeneral = priority == "일반"; + const redisKeyEach = `notice:${id}`; + const redisKeyAll = `alerts:${isGeneral ? "general" : "urgent"}`; - // 이 글 캐싱 - await redisClient.set(redisKeyEach, JSON.stringify(hasRecord)); - // 긴급일 경우 스케쥴러 등록 - if (!isGeneral) { - const recordModel = (await Notice.findOne({ - where: { id }, - })) as INotice; - setNoticeSchedule(recordModel); - } - // 전체 목록 캐싱 - const noticesFromDB = await Notice.findAll({ - where: { - expired: false, // 활성화된 공지만 가져오기 - priority: { - [Op.eq]: priority, - }, - }, - order: [ - ['date', 'ASC'] - ] - }).catch(e => console.log(e)); - await redisClient.set(redisKeyAll, JSON.stringify(noticesFromDB)); + // #1 redis 캐싱 + await redisClient.set(redisKeyEach, JSON.stringify(hasRecord)); + // 긴급일 경우 스케쥴러 등록 + if (!isGeneral) { + const recordModel = (await Notice.findOne({ + where: { id }, + })) as INotice; + setAlertToNoticeSchedule(recordModel); } + // 전체 목록 캐싱 + const noticesFromDB = await Notice.findAll({ + where: { + expired: false, // 활성화된 공지만 가져오기 + priority: { + [Op.eq]: priority, + }, + }, + order: [ + ['date', 'ASC'] + ] + }).catch(e => console.log(e)); + await redisClient.set(redisKeyAll, JSON.stringify(noticesFromDB)); + + // #2. 행사 등록 알림 전송 + const notification = { + title, + body: content + }; + sendFCM(notification, `${isGeneral ? 'notice' : 'alerts'}_push`, { + type: `${isGeneral ? 'notices' : 'alerts'}`, + id: id.toString() + }); + } + + return originalResponse; +}; - return originalResponse; - }; - // delete -> 삭제 후 redis 업데이트(개별 공지 제거, 전체 공지 업데이트) const deleteAfter = () => async (originalResponse, request, context) => { const isPost = request.method === "post"; @@ -148,10 +159,13 @@ const deleteAfter = () => async (originalResponse, request, context) => { // 해당 글의 캐싱데이터 제거 await redisClient.del(redisKeyEach); - // 긴급일 경우 스케쥴에서 제거 + + // 스케쥴 제거 if (!isGeneral) { - delNoticeSchedule(hasRecord); + // 긴급->일반 스케쥴 제거 + delAlertToNoticeSchedule(hasRecord); } + // 전체 목록 캐싱 await cachingAllNotices(); } @@ -162,10 +176,10 @@ const deleteAfter = () => async (originalResponse, request, context) => { const bulkDelete = () => async (originalResponse, request, context) => { const isPost = request?.method === "post"; const isAction = context?.action.name === "bulkDelete"; - const {records} = originalResponse; + const { records } = originalResponse; // checking if object doesn't have any errors or is a edit action if (isPost && isAction && records) { - records.forEach(async ({params: record}) => { + records.forEach(async ({ params: record }) => { const { priority, id } = record; const isGeneral = priority == "일반"; const redisKeyEach = `notice:${id}`; @@ -173,7 +187,7 @@ const bulkDelete = () => async (originalResponse, request, context) => { await redisClient.del(redisKeyEach); // 긴급일 경우 스케쥴에서 제거 if (!isGeneral) { - delNoticeSchedule(record); + delAlertToNoticeSchedule(record); } }) // 전체 목록 캐싱 diff --git a/adminPage/index.ts b/adminPage/index.ts index 0cae8ed..53aee1e 100644 --- a/adminPage/index.ts +++ b/adminPage/index.ts @@ -3,13 +3,13 @@ import { compare } from "bcrypt"; import fs from "fs"; import * as url from "url"; import { Admin, Alarm, Bookmark, BookmarkAsset, Event, EventsLike, Notice, NoticesLike, Read, ReadAsset, User } from "../models/index.js"; +import Linktree from "../models/linktree.js"; import { Components, componentLoader, isRunOnDist } from "./components/index.js"; import { ADMIN } from './resources/admin.js'; import { COMMON, TEST } from "./resources/common.js"; import { EVENT } from "./resources/event.js"; -import { NOTICE } from "./resources/notice.js"; -import Linktree from "../models/linktree.js"; import { LINKTREE } from "./resources/linktree.js"; +import { NOTICE } from "./resources/notice.js"; const authenticate = async (payload, context) => { const {email, role} = payload; diff --git a/controllers/alarm/fcmUtils.ts b/common_method/fcmUtils.ts similarity index 77% rename from controllers/alarm/fcmUtils.ts rename to common_method/fcmUtils.ts index 5b1cdb2..0e40f7d 100644 --- a/controllers/alarm/fcmUtils.ts +++ b/common_method/fcmUtils.ts @@ -4,7 +4,7 @@ import { readFile } from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; -const jsonFilePath = path.join(fileURLToPath(new URL(".", import.meta.url)), "../../../firebaseKey.json"); +const jsonFilePath = path.join(fileURLToPath(new URL(".", import.meta.url)), "../../firebaseKey.json"); const json = readFile(jsonFilePath, "utf-8"); json.then((v) => { admin.initializeApp({ @@ -15,43 +15,40 @@ json.then((v) => { export const subscribeTopic = (token: string | string[], topic: string) => { getMessaging().subscribeToTopic(token, topic) .then((response) => { + if (!response.errors.length) console.log('Successfully subscribed to topic:', topic); response.errors.forEach((err) => { console.log({ err }) }); }) .catch((error) => { console.log('Error subscribing to topic:', error); - }) - .finally(() => { - console.log('Successfully subscribed to topic:', topic); }); } export const unsubscribeTopic = (token: string | string[], topic: string) => { getMessaging().unsubscribeFromTopic(token, topic) .then((response) => { + if (!response.errors.length) console.log('Successfully unsubscribed from topic:', topic); response.errors.forEach((err) => { - console.log({ err }) + console.log({ err, info: err.error.toJSON(), token }) }); }) .catch((error) => { console.log('Error unsubscribing from topic:', error); - }) - .finally(() => { - console.log('Successfully unsubscribed from topic:', topic); }); } -export const sendFCM = (notification: { title: string, body: string }, topic: string, link: string) => { +export const sendFCM = (notification: { title: string, body: string }, topic: string, data: { type: 'events' | 'notices' | 'alerts', id: string }) => { const message: Message = { notification, data: { + ...data, topic }, topic, webpush: { fcmOptions: { - link + link: `${data.type}=${data.id}` } } }; diff --git a/common_method/fcm_schedule.ts b/common_method/fcm_schedule.ts new file mode 100644 index 0000000..c5243a2 --- /dev/null +++ b/common_method/fcm_schedule.ts @@ -0,0 +1,131 @@ +import { Job, scheduleJob } from 'node-schedule'; +import { IEvent } from '../models/types.js'; +import { sendFCM } from './index.js'; + +const fcmJobList: Job[] = []; +export const setEventsPushForEntireSchedule = (row: IEvent) => { + const { id, start, title, content: body } = row; + const currentDate = new Date(); + let timePadding = 24; + // 시간 차이 계산 + const timeDifference = (start.getTime() - currentDate.getTime()) / (1000 * 60 * 60); // 시간 단위 + + // 24시간 이내의 차이인 경우 timePadding 계산 + if (timeDifference <= 24 && timeDifference > 0) { + timePadding = Math.floor(timeDifference); + } + if (timePadding == 0) return; + for (let i = 1; i <= timePadding; i++) { + const topic = `event_${i}`; + const key = topic + `:${id}`; + const job = scheduleJob(key, getPrevHour(start, i), function () { + // job 실행 시 호출할 callback + console.log(`run fcm schedule: ${key}-${start.toLocaleString()}`); + sendFCM({ title, body }, topic, { + type: 'events', + id: id.toString(), + }); + fcmJobList.splice(existJobIndex, 1); + }); + + const existJobIndex = fcmJobList.findIndex((j) => j?.name == key); + // 이미 등록된 key면 교체 + if (existJobIndex > -1) { + fcmJobList[existJobIndex].cancel(); + fcmJobList[existJobIndex] = job; + } else { + // 등록 안됐으면 list에 추가 + fcmJobList.push(job); + } + } + console.log('set Events Push For Entire Schedule:', `event:${id}-${start.toLocaleString()}`); + console.log( + 'fcm job list:', + fcmJobList.map((j) => j?.name) + ); +}; + +export const setEventsPushForBookmarkSchedule = (row: IEvent) => { + const { id, start, title, content: body } = row; + const currentDate = new Date(); + let timePadding = 24; + // 시간 차이 계산 + const timeDifference = (start.getTime() - currentDate.getTime()) / (1000 * 60 * 60); // 시간 단위 + + // 24시간 이내의 차이인 경우 timePadding 계산 + if (timeDifference <= 24 && timeDifference > 0) { + timePadding = Math.floor(timeDifference); + } + if (timePadding == 0) return; + for (let i = 1; i <= timePadding; i++) { + const topic = `event:${id}_${i}`; + const key = topic; + const job = scheduleJob(key, getPrevHour(start, i), function () { + // job 실행 시 호출할 callback + console.log(`run fcm schedule: ${key}-${start.toLocaleString()}`); + sendFCM({ title, body }, topic, { + type: 'events', + id: id.toString(), + }); + fcmJobList.splice(existJobIndex, 1); + }); + + const existJobIndex = fcmJobList.findIndex((j) => j?.name == key); + // 이미 등록된 key면 수정(교체) + if (existJobIndex > -1) { + fcmJobList[existJobIndex].cancel(); + fcmJobList[existJobIndex] = job; + } else { + // 등록 안됐으면 list에 추가 + fcmJobList.push(job); + } + } + console.log('set Events Push For Bookmark Schedule:', `event:${id}-${start.toLocaleString()}`); + console.log( + 'fcm job list:', + fcmJobList.map((j) => j?.name) + ); +}; + +export const delEventsPushForEntireSchedule = (row: IEvent) => { + const { id, start } = row; + for (let i = 1; i <= 24; i++) { + const key = `event_${i}:${id}`; + const existJobIndex = fcmJobList.findIndex((j) => j.name === `${key}`); + if (existJobIndex > -1) { + fcmJobList[existJobIndex].cancel(); + fcmJobList.splice(existJobIndex, 1); + console.log('del schedule:', `${key}-${start.toLocaleString()}`); + console.log( + 'remained job list:', + fcmJobList.map((j) => j?.name) + ); + } else { + console.warn(`jobList hasn't schedule-${key}`); + } + } +}; + +export const delEventsPushForBookmarkSchedule = (row: IEvent) => { + const { id, start } = row; + for (let i = 1; i <= 24; i++) { + const key = `event:${id}_${i}`; + const existJobIndex = fcmJobList.findIndex((j) => j.name === `${key}`); + if (existJobIndex > -1) { + fcmJobList[existJobIndex].cancel(); + fcmJobList.splice(existJobIndex, 1); + console.log('del schedule:', `${key}-${start.toLocaleString()}`); + console.log( + 'remained job list:', + fcmJobList.map((j) => j?.name) + ); + } else { + console.warn(`jobList hasn't schedule-${key}`); + } + } +}; +const getPrevHour = (date: Date, hoursBefore: number) => { + const newDate = new Date(date.getTime()); + newDate.setHours(newDate.getHours() - hoursBefore); + return newDate; +}; diff --git a/common_method/index.ts b/common_method/index.ts new file mode 100644 index 0000000..41bd692 --- /dev/null +++ b/common_method/index.ts @@ -0,0 +1,47 @@ +import { sendFCM, subscribeTopic, unsubscribeTopic } from './fcmUtils.js'; +import { + delEventsPushForBookmarkSchedule, + delEventsPushForEntireSchedule, + setEventsPushForBookmarkSchedule, + setEventsPushForEntireSchedule, +} from './fcm_schedule.js'; +import { + delAlertToNoticeSchedule, + delEventToExpiredSchedule, + setAlertToNoticeSchedule, + setEventToExpiredSchedule, +} from './post_schedule.js'; +import { + getEventBookmarkInfo, + getEventLikeInfo, + getMajorInfo, + getNoticeLikeInfo, + getNoticeReadInfo, +} from './user_information.js'; +import { redisGetAndParse } from './utils.js'; +import { IGenericUserRequest, findObjectByPk, findUser, validateRequestBody } from './validator.js'; + +export { + IGenericUserRequest, + delAlertToNoticeSchedule, + delEventToExpiredSchedule, + delEventsPushForBookmarkSchedule, + delEventsPushForEntireSchedule, + findObjectByPk, + findUser, + getEventBookmarkInfo, + getEventLikeInfo, + getMajorInfo, + getNoticeLikeInfo, + getNoticeReadInfo, + redisGetAndParse, + sendFCM, + setAlertToNoticeSchedule, + setEventToExpiredSchedule, + setEventsPushForBookmarkSchedule, + setEventsPushForEntireSchedule, + subscribeTopic, + unsubscribeTopic, + validateRequestBody +}; + diff --git a/redis/schedule.ts b/common_method/post_schedule.ts similarity index 55% rename from redis/schedule.ts rename to common_method/post_schedule.ts index aca61e9..05c2f3b 100644 --- a/redis/schedule.ts +++ b/common_method/post_schedule.ts @@ -1,55 +1,55 @@ import { Job, scheduleJob } from "node-schedule"; -import { IEvent, INotice } from "../models/types.js"; -import { cachingAllNotices } from "./caching.js"; -import { redisClient } from "./connect.js"; import Event from "../models/events.js"; +import { IEvent, INotice } from "../models/types.js"; +import { cachingAllNotices } from "../redis/caching.js"; +import { redisClient } from "../redis/connect.js"; -const jobArray: Job[] = []; -export const setNoticeSchedule = (row: INotice) => { +const postJobList: Job[] = []; +export const setAlertToNoticeSchedule = (row: INotice) => { const { id, date } = row; const key = `notice:${id}`; const job = scheduleJob(key, getNextDay(date), async function () { - console.log("run schedule:", key, date.toLocaleString(), "to 일반"); + console.log("run post schedule:", key, date.toLocaleString(), "to 일반"); const updatedRow = await row.update({ ...row, priority: "일반" }); await redisClient.set(key, JSON.stringify(updatedRow)); // 전체 긴급 공지 목록 캐싱 await cachingAllNotices(); - jobArray.splice(existJobIndex, 1); + postJobList.splice(existJobIndex, 1); }); - const existJobIndex = jobArray.findIndex((j) => j?.name == key); + const existJobIndex = postJobList.findIndex((j) => j?.name == key); if (existJobIndex > -1) { - jobArray[existJobIndex].cancel(); - jobArray[existJobIndex] = job; + postJobList[existJobIndex].cancel(); + postJobList[existJobIndex] = job; } else { - jobArray.push(job); + postJobList.push(job); } console.log("set schedule:", `${key}-${date.toLocaleString()}`); console.log( "job list:", - jobArray.map((j) => j?.name) + postJobList.map((j) => j?.name) ); }; -export const delNoticeSchedule = (row: INotice) => { +export const delAlertToNoticeSchedule = (row: INotice) => { const { id, date } = row; const key = `notice:${id}`; - const existJobIndex = jobArray.findIndex((j) => j.name == `${key}`); + const existJobIndex = postJobList.findIndex((j) => j.name == `${key}`); if (existJobIndex > -1) { - jobArray[existJobIndex].cancel(); - jobArray.splice(existJobIndex, 1); + postJobList[existJobIndex].cancel(); + postJobList.splice(existJobIndex, 1); console.log("del schedule:", `${key}-${date.toLocaleString()}`); console.log( - "job list:", - jobArray.map((j) => j?.name) + "remained job list:", + postJobList.map((j) => j?.name) ); } else { console.warn(`jobArray hasn't schedule-${key}`); } }; -export const setEventSchedule = (row: IEvent) => { +export const setEventToExpiredSchedule = (row: IEvent) => { const { id, end } = row; const key = `event:${id}`; const job = scheduleJob(key, end, async function () { - console.log("run schedule:", key, end.toLocaleString(), "to 행사 종료"); + console.log("run post schedule:", key, end.toLocaleString(), "to 행사 종료"); const updatedRow = await row.update({ ...row, expired: true }); await redisClient.set(key, JSON.stringify(updatedRow)); // 전체 행사 목록 캐싱 @@ -60,32 +60,32 @@ export const setEventSchedule = (row: IEvent) => { order: [["start", "ASC"]], }); await redisClient.set("allEvents", JSON.stringify(allEventsFromDb)); - jobArray.splice(existJobIndex, 1); + postJobList.splice(existJobIndex, 1); }); - const existJobIndex = jobArray.findIndex((j) => j?.name == key); + const existJobIndex = postJobList.findIndex((j) => j?.name == key); if (existJobIndex > -1) { - jobArray[existJobIndex].cancel(); - jobArray[existJobIndex] = job; + postJobList[existJobIndex].cancel(); + postJobList[existJobIndex] = job; } else { - jobArray.push(job); + postJobList.push(job); } console.log("set schedule:", `${key}-${end.toLocaleString()}`); console.log( "job list:", - jobArray.map((j) => j?.name) + postJobList.map((j) => j?.name) ); }; -export const delEventSchedule = (row: IEvent) => { +export const delEventToExpiredSchedule = (row: IEvent) => { const { id, end } = row; const key = `event:${id}`; - const existJobIndex = jobArray.findIndex((j) => j.name == `${key}`); + const existJobIndex = postJobList.findIndex((j) => j.name == `${key}`); if (existJobIndex > -1) { - jobArray[existJobIndex].cancel(); - jobArray.splice(existJobIndex, 1); + postJobList[existJobIndex].cancel(); + postJobList.splice(existJobIndex, 1); console.log("del schedule:", `${key}-${end.toLocaleString()}`); console.log( - "job list:", - jobArray.map((j) => j?.name) + "remained job list:", + postJobList.map((j) => j?.name) ); } else { console.warn(`jobArray hasn't schedule-${key}`); diff --git a/controllers/common_method/user_information.ts b/common_method/user_information.ts similarity index 97% rename from controllers/common_method/user_information.ts rename to common_method/user_information.ts index d80d3b0..e6d6331 100644 --- a/controllers/common_method/user_information.ts +++ b/common_method/user_information.ts @@ -1,5 +1,5 @@ -import { Alarm, Bookmark, BookmarkAsset, EventsLike, NoticesLike, Read, ReadAsset, User } from "../../models/index.js"; -import { redisClient } from "../../redis/connect.js"; +import { Alarm, Bookmark, BookmarkAsset, EventsLike, NoticesLike, Read, ReadAsset, User } from "../models/index.js"; +import { redisClient } from "../redis/connect.js"; const EXPIRE = 3600; // 유효시간 1시간 diff --git a/controllers/common_method/utils.ts b/common_method/utils.ts similarity index 86% rename from controllers/common_method/utils.ts rename to common_method/utils.ts index 4d83f53..3fd1c2f 100644 --- a/controllers/common_method/utils.ts +++ b/common_method/utils.ts @@ -1,4 +1,4 @@ -import { redisClient } from "../../redis/connect.js"; +import { redisClient } from "../redis/connect.js"; /*** redis get 결과물 parse해서 리턴 ** get결과물이 없다면 throw error diff --git a/controllers/common_method/validator.ts b/common_method/validator.ts similarity index 95% rename from controllers/common_method/validator.ts rename to common_method/validator.ts index cc268bf..54c33d9 100644 --- a/controllers/common_method/validator.ts +++ b/common_method/validator.ts @@ -1,4 +1,4 @@ -import { Event, Notice, User } from "../../models/index.js"; +import { Event, Notice, User } from "../models/index.js"; const modelMap = new Map([ ['user_id', User], diff --git a/controllers/alarm/alarm.ts b/controllers/alarm/alarm.ts index a9f8608..5a4a35e 100644 --- a/controllers/alarm/alarm.ts +++ b/controllers/alarm/alarm.ts @@ -1,8 +1,8 @@ import * as express from "express"; +import { subscribeTopic, unsubscribeTopic } from "../../common_method/index.js"; +import { getEventBookmarkInfo } from "../../common_method/user_information.js"; import { FCMToken, sequelize } from "../../models/index.js"; import { IAlarm } from "../../models/types.js"; -import { getEventBookmarkInfo } from "../common_method/user_information.js"; -import { subscribeTopic, unsubscribeTopic } from "./index.js"; const alarmDataKeyList = [ "alarm_push", @@ -102,4 +102,28 @@ function findDifferences(before: string[], after: string[]) { const removed = before.filter(item => !after.includes(item)); const added = after.filter(item => !before.includes(item)); return { added, removed }; +} + +export const retireToken = async (req: express.Request, res: express.Response) => { + const transaction = await sequelize.transaction(); + try { + const { token } = req.body; + const { id } = req.cookies; + const existingToken = await FCMToken.findOne({ where: { fk_user_id: id, token }, transaction }); + + if (!existingToken) { + return res.status(404).json({ error: 'Token not found' }); + } + existingToken.topics.forEach((topic) => { + unsubscribeTopic(token, topic); + }) + await existingToken.destroy({ transaction }); + + await transaction.commit(); + + return res.status(200).json({ message: '토큰 폐기가 완료되었습니다.' }); + } catch (error) { + await transaction.rollback(); + return res.status(500).json({ error }); + } } \ No newline at end of file diff --git a/controllers/alarm/index.ts b/controllers/alarm/index.ts index f0294c8..3c23007 100644 --- a/controllers/alarm/index.ts +++ b/controllers/alarm/index.ts @@ -1,5 +1,4 @@ -import { setAlarm } from "./alarm.js"; -import { sendFCM, subscribeTopic, unsubscribeTopic } from './fcmUtils.js'; +import { retireToken, setAlarm } from "./alarm.js"; -export { sendFCM, setAlarm, subscribeTopic, unsubscribeTopic }; +export { retireToken, setAlarm }; diff --git a/controllers/common_method/index.ts b/controllers/common_method/index.ts deleted file mode 100644 index a371b4d..0000000 --- a/controllers/common_method/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getEventBookmarkInfo, getEventLikeInfo, getMajorInfo, getNoticeLikeInfo, getNoticeReadInfo } from "./user_information.js"; -import { redisGetAndParse } from './utils.js'; -import { IGenericUserRequest, findObjectByPk, findUser, validateRequestBody } from "./validator.js"; - -export { - IGenericUserRequest, findObjectByPk, findUser, getEventBookmarkInfo, - getEventLikeInfo, getMajorInfo, getNoticeLikeInfo, - getNoticeReadInfo, redisGetAndParse, validateRequestBody, -}; diff --git a/controllers/event/event_bookmark.ts b/controllers/event/event_bookmark.ts index b9665a2..0be0267 100644 --- a/controllers/event/event_bookmark.ts +++ b/controllers/event/event_bookmark.ts @@ -1,8 +1,7 @@ import * as express from "express"; +import { findObjectByPk, subscribeTopic, unsubscribeTopic, validateRequestBody } from "../../common_method/index.js"; import { Bookmark, BookmarkAsset, FCMToken, sequelize } from "../../models/index.js"; import { redisClient } from "../../redis/connect.js"; -import { subscribeTopic, unsubscribeTopic } from "../alarm/index.js"; -import { findObjectByPk, validateRequestBody } from "../common_method/index.js"; import { IEventUserRequest } from "./request/request.js"; const bodyList = [ diff --git a/controllers/event/event_like.ts b/controllers/event/event_like.ts index f5b0ba4..f139e96 100644 --- a/controllers/event/event_like.ts +++ b/controllers/event/event_like.ts @@ -1,7 +1,7 @@ import * as express from "express"; +import { findObjectByPk, validateRequestBody } from "../../common_method/validator.js"; import { Event, EventsLike, sequelize } from "../../models/index.js"; import { redisClient } from "../../redis/connect.js"; -import { findObjectByPk, validateRequestBody } from "../common_method/validator.js"; import { IEventUserRequest } from "./request/request.js"; const bodyList = [ diff --git a/controllers/event/event_search.ts b/controllers/event/event_search.ts index 2d55ce1..39680df 100644 --- a/controllers/event/event_search.ts +++ b/controllers/event/event_search.ts @@ -1,9 +1,8 @@ import * as express from "express"; -import { Event, sequelize } from "../../models/index.js"; +import { redisGetAndParse } from "../../common_method/utils.js"; +import { Event } from "../../models/index.js"; import { IEvent } from "../../models/types.js"; import { redisClient } from "../../redis/connect.js"; -import { redisGetAndParse } from "../common_method/utils.js"; -import { Op } from "sequelize"; const EXPIRE = 3600; // 유효시간 1시간 diff --git a/controllers/jwt/jwt.ts b/controllers/jwt/jwt.ts index aeeb8f5..9df25ee 100644 --- a/controllers/jwt/jwt.ts +++ b/controllers/jwt/jwt.ts @@ -35,7 +35,7 @@ export const verifyToken = (req: Express.Request, res, next) => { next(); } catch (error) { if (error.name === 'TokenExpiredError') { - return res.status(419).json({ message: '토큰이 만료되었습니다.' }); + return res.status(419).json({ message: '토큰이 만료되었습니다.', error }); } // TODO: error.message 수정하기 return res.status(401).json({ message: '유효하지 않은 토큰입니다:' + error.message }); diff --git a/controllers/linktree/linktree.ts b/controllers/linktree/linktree.ts index 71e941e..7e78fbd 100644 --- a/controllers/linktree/linktree.ts +++ b/controllers/linktree/linktree.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { redisGetAndParse } from "../common_method/utils.js"; +import { redisGetAndParse } from "../../common_method/utils.js"; // GET /events export const getLinktrees = async ( diff --git a/controllers/notice/notice_like.ts b/controllers/notice/notice_like.ts index 1d5f4c3..b68a579 100644 --- a/controllers/notice/notice_like.ts +++ b/controllers/notice/notice_like.ts @@ -1,7 +1,7 @@ import * as express from "express"; +import { findObjectByPk, validateRequestBody } from "../../common_method/validator.js"; import { Notice, NoticesLike, sequelize } from "../../models/index.js"; import { redisClient } from "../../redis/connect.js"; -import { findObjectByPk, validateRequestBody } from "../common_method/validator.js"; import { INoticeUserRequest } from "./request/request.js"; const bodyList = [ diff --git a/controllers/notice/notice_read.ts b/controllers/notice/notice_read.ts index 4cba867..38b63b1 100644 --- a/controllers/notice/notice_read.ts +++ b/controllers/notice/notice_read.ts @@ -1,8 +1,8 @@ import * as express from "express"; +import { getNoticeReadInfo } from "../../common_method/user_information.js"; +import { findObjectByPk, validateRequestBody } from "../../common_method/validator.js"; import { Read, ReadAsset, sequelize } from "../../models/index.js"; import { redisClient } from "../../redis/connect.js"; -import { getNoticeReadInfo } from "../common_method/user_information.js"; -import { findObjectByPk, validateRequestBody } from "../common_method/validator.js"; import { INoticeUserRequest } from "./request/request.js"; const bodyList = [ diff --git a/controllers/notice/notice_search.ts b/controllers/notice/notice_search.ts index 5d13048..0632a91 100644 --- a/controllers/notice/notice_search.ts +++ b/controllers/notice/notice_search.ts @@ -1,8 +1,8 @@ import * as express from "express"; +import { redisGetAndParse } from "../../common_method/utils.js"; import { Notice } from "../../models/index.js"; import { INotice } from "../../models/types.js"; import { redisClient } from "../../redis/connect.js"; -import { redisGetAndParse } from "../common_method/utils.js"; const EXPIRE = 3600; // 유효시간 1시간 const ALERT_EXPIRE = 60; // 유효시간 1분 diff --git a/controllers/user/major.ts b/controllers/user/major.ts index da57a59..f2c543f 100644 --- a/controllers/user/major.ts +++ b/controllers/user/major.ts @@ -1,7 +1,7 @@ import * as express from "express"; -import { findObjectByPk, validateRequestBody } from "../common_method/validator.js"; +import { getMajorInfo } from "../../common_method/user_information.js"; +import { findObjectByPk, validateRequestBody } from "../../common_method/validator.js"; import { User } from "../../models/index.js"; -import { getMajorInfo } from "../common_method/user_information.js"; import { redisClient } from "../../redis/connect.js"; const bodyList = [ diff --git a/controllers/user/user.ts b/controllers/user/user.ts index 0a366d3..5af4460 100644 --- a/controllers/user/user.ts +++ b/controllers/user/user.ts @@ -1,7 +1,7 @@ import express from 'express'; +import { getEventBookmarkInfo, getEventLikeInfo, getNoticeLikeInfo, getNoticeReadInfo } from '../../common_method/index.js'; import User from '../../models/user.js'; import { redisClient } from '../../redis/connect.js'; -import { getEventBookmarkInfo, getEventLikeInfo, getNoticeLikeInfo, getNoticeReadInfo } from '../common_method/index.js'; import { generate } from "../jwt/index.js"; type LoginSuccess = { diff --git a/redis/caching.ts b/redis/caching.ts index 7a93b90..2f2800d 100644 --- a/redis/caching.ts +++ b/redis/caching.ts @@ -1,9 +1,10 @@ +import { setEventsPushForBookmarkSchedule, setEventsPushForEntireSchedule } from "../common_method/index.js"; +import { getNextDay, setAlertToNoticeSchedule, setEventToExpiredSchedule } from "../common_method/post_schedule.js"; import Event from "../models/events.js"; import Linktree from "../models/linktree.js"; import Notice from "../models/notice.js"; import { IEvent, INotice } from "../models/types.js"; import { redisClient } from "./connect.js"; -import { getNextDay, setEventSchedule, setNoticeSchedule } from "./schedule.js"; export const initAllLinktrees = async () => { const redisKey = "linktrees"; @@ -56,12 +57,16 @@ export const initAllOngoingEvents = async () => { // 종료 전이면 if (event.end > currentDate) { // 종료 날에 종료되도록 스케쥴링 - setEventSchedule(event); + setEventToExpiredSchedule(event); } else { - // 종료 됐으면 - // 종료로 변경 + // 종료 됐으면 종료로 변경 event.update({ ...event, expired: true }); } + // 이벤트 시작 알림 전송 스케쥴 등록 + if (event.start > currentDate) { + setEventsPushForEntireSchedule(event); + setEventsPushForBookmarkSchedule(event); + } await redisClient.set(eventRedisKey, JSON.stringify(event)); } @@ -89,7 +94,7 @@ export const initAllOngoingNotices = async () => { // 일반 공지로 이동해야할 긴급 공지가 있는지 체크 const noticeNextDay = getNextDay(new Date(notice.date)); if (noticeNextDay > currentDate.getTime()) { - setNoticeSchedule(notice); + setAlertToNoticeSchedule(notice); } else { notice.update({ ...notice, priority: "일반" }); } @@ -127,6 +132,7 @@ export const cachingAllNotices = async ( await redisClient.set(`alerts:urgent`, JSON.stringify(urgent)); await redisClient.set(`alerts:general`, JSON.stringify(general)); }; + /** * @returns [urgent, general] */ @@ -185,6 +191,11 @@ export function calculateMonthsBetween(start: Date, end: Date): string[] { return result; } +/** + * 달력 데이터 생성한 뒤 redis에 캐싱하는 함수 + * @param {IEvent[]} events 모든 행사글 + * @returns {Promise[]} Promise.all을 위한 redis 캐싱 함수 배열 + */ export const cacheYearMonthData = (events: IEvent[]) => { const eventsHashMapByDate = events.reduce((yearObject, event) => { const { start, end } = event; @@ -200,7 +211,7 @@ export const cacheYearMonthData = (events: IEvent[]) => { return yearObject; }, {}); - const promises: Promise[] = []; + const promises: ReturnType[] = []; for (const [year, monthObject] of Object.entries(eventsHashMapByDate)) { const months = Object.entries(monthObject); for (const [month, events] of months) { diff --git a/routes/alarm.ts b/routes/alarm.ts index 9690410..9411654 100644 --- a/routes/alarm.ts +++ b/routes/alarm.ts @@ -1,9 +1,10 @@ import { Router } from "express"; -import { setAlarm } from "../controllers/alarm/index.js"; +import { retireToken, setAlarm } from "../controllers/alarm/index.js"; import { verifyToken } from "../controllers/jwt/index.js"; const alarmRouter = Router(); alarmRouter.put('/:id/alarms', verifyToken, setAlarm); +alarmRouter.post('/fcm-token/retire', verifyToken, retireToken); export default alarmRouter; \ No newline at end of file