diff --git a/backend/src/guards/getNotification.ts b/backend/src/guards/getNotification.ts new file mode 100644 index 0000000..21c4acb --- /dev/null +++ b/backend/src/guards/getNotification.ts @@ -0,0 +1,30 @@ +import Elysia, { t } from "elysia"; +import { isValidObjectId } from "mongoose"; + +import NotificationModel, { INotification } from "@back/models/notification"; +import exit, { errorElysia } from "@back/utils/error"; + +const getNotification = new Elysia() + .use(NotificationModel) + .guard({ + params: t.Object({ + notification_id: t.String({ description: "알림 ID" }), + }), + response: { + ...errorElysia(["NOTIFICATION_NOT_FOUND", "INVALID_ID_TYPE"]), + }, + }) + .resolve(async ({ params, notificationModel, error }): Promise<{ notification: INotification }> => { + const { notification_id } = params; + + if (!isValidObjectId(notification_id)) return exit(error, "INVALID_ID_TYPE"); + + const found = await notificationModel.db.findById(notification_id); + + if (!found) return exit(error, "NOTIFICATION_NOT_FOUND"); + + return { notification: found.toObject() }; + }) + .as("plugin"); + +export default getNotification; \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index b36a17b..34e0005 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -58,6 +58,10 @@ if (Bun.env.NODE_ENV === "development") { { name: "Event", description: "이벤트에 관련된 API입니다.", + }, + { + name: "Notification", + description: "알림에 관련된 API입니다.", } ] } diff --git a/backend/src/models/notification.ts b/backend/src/models/notification.ts new file mode 100644 index 0000000..70d150d --- /dev/null +++ b/backend/src/models/notification.ts @@ -0,0 +1,109 @@ +import dayjs from "dayjs"; +import { Elysia } from "elysia"; +import mongoose, { ObjectId } from "mongoose"; + +import { IDocument } from "@common/types/db"; + +export type NotificationType = "event_created"; + +interface DNotification { + userId: ObjectId; + + senderType: "user" | "activity"; + senderId: ObjectId; + senderName?: string; + senderImage?: string; + + type: NotificationType; + message: string; + data?: Record; + read: boolean; + createdAt: string; +} + +export type INotification = IDocument; + +const notificationSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + senderType: { + type: String, + enum: ["user", "activity"], + required: true, + }, + senderId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + }, + senderName: String, + senderImage: String, + + type: { + type: String, + enum: ["event_created"], + required: true, + }, + message: { + type: String, + required: true, + }, + data: { + type: Object, + default: {}, + }, + read: { + type: Boolean, + default: false, + }, + createdAt: { + type: String, + default: dayjs().format("YYYY-MM-DD HH:mm:ss"), + }, +}); + +notificationSchema.index({ userId: 1, createdAt: -1 }); + +const NotificationDB = mongoose.model("Notification", notificationSchema); + +const createNotification = async ({ + userId, + senderType, + senderId, + senderName, + senderImage, + type, + message, + data = {}, +}: { + userId: ObjectId; + senderType: "user" | "activity"; + senderId: ObjectId; + senderName?: string; + senderImage?: string; + type: NotificationType; + message: string; + data?: Record; +}) => { + return NotificationDB.create({ + userId, + senderType, + senderId, + senderName, + senderImage, + type, + message, + data, + read: false, + createdAt: dayjs().format("YYYY-MM-DD HH:mm:ss"), + }); +}; + +const NotificationModel = new Elysia().decorate("notificationModel", { + db: NotificationDB, + create: createNotification, +}); + +export default NotificationModel; \ No newline at end of file diff --git a/backend/src/routers/event/create.ts b/backend/src/routers/event/create.ts index 3e4aa82..744d3c1 100644 --- a/backend/src/routers/event/create.ts +++ b/backend/src/routers/event/create.ts @@ -2,7 +2,11 @@ import dayjs from "dayjs"; import Elysia, { t } from "elysia"; import timetableAuthorityService from "@back/guards/timetableAuthorityService"; +import ActivityModel from "@back/models/activity"; import EventModel, { eventElysiaSchema } from "@back/models/event"; +import JoinedActivityModel from "@back/models/joined_activity"; +import NotificationModel from "@back/models/notification"; +import TimetableModel from "@back/models/timetable"; import exit, { errorElysia } from "@back/utils/error"; const FORMAT = "YYYY-MM-DD HH:mm:ss"; @@ -23,9 +27,13 @@ const normalizeRepeat = (repeat: any) => { const create = new Elysia() .use(timetableAuthorityService) .use(EventModel) + .use(TimetableModel) + .use(ActivityModel) + .use(JoinedActivityModel) + .use(NotificationModel) .post( "create", - async ({ body, eventModel, error }) => { + async ({ body, eventModel, timetableModel, notificationModel, activityModel, joinedActivityModel, user, error }) => { try { const repeat = normalizeRepeat(body.repeat); @@ -38,6 +46,31 @@ const create = new Elysia() repeat, }); + const timetable = await timetableModel.db.findById(body.timetable_id); + + if (timetable && timetable.owner_type === "activity") { + const activity = await activityModel.db.findById(timetable.owner); + if (activity) { + const members = await joinedActivityModel.db.find({ activity_id: activity._id }); + + for (const member of members) { + await notificationModel.create({ + userId: member.user_id, + senderType: "activity", + senderId: activity._id.toString(), + senderName: activity.name, + senderImage: activity.logo_url ?? "", + type: "event_created", + message: `${event.title}`, + data: { + eventId: event._id.toString(), + timetableId: timetable._id.toString(), + }, + }); + } + } + } + return { success: true, message: "이벤트 생성 성공", @@ -64,10 +97,10 @@ const create = new Elysia() }, detail: { tags: ["Event"], - summary: "이벤트 생성", + summary: "이벤트 생성 + 알림", description: "일반 또는 반복 이벤트를 생성합니다. `repeat` 옵션으로 고급 반복 설정도 가능합니다.", }, } ); -export default create; \ No newline at end of file +export default create; diff --git a/backend/src/routers/index.ts b/backend/src/routers/index.ts index eabac6e..b89061c 100644 --- a/backend/src/routers/index.ts +++ b/backend/src/routers/index.ts @@ -3,6 +3,7 @@ import Elysia from "elysia"; import ActivityRouter from "./activity"; import AuthRouter from "./auth"; import EventRouter from "./event"; +import NotificationRouter from "./notification"; import TimetableRouter from "./timetable"; const IndexRouter = new Elysia({ @@ -12,6 +13,7 @@ const IndexRouter = new Elysia({ .use(AuthRouter) .use(ActivityRouter) .use(TimetableRouter) - .use(EventRouter); + .use(EventRouter) + .use(NotificationRouter); export default IndexRouter; diff --git a/backend/src/routers/notification/[id]/index.ts b/backend/src/routers/notification/[id]/index.ts new file mode 100644 index 0000000..6675f69 --- /dev/null +++ b/backend/src/routers/notification/[id]/index.ts @@ -0,0 +1,11 @@ +import Elysia from "elysia"; + +import read from "./read"; + +const EventIdRouter = new Elysia({ + name: "Notification Router", + prefix: ":notification_id", +}) + .use(read); + +export default EventIdRouter; \ No newline at end of file diff --git a/backend/src/routers/notification/[id]/read.ts b/backend/src/routers/notification/[id]/read.ts new file mode 100644 index 0000000..1b15803 --- /dev/null +++ b/backend/src/routers/notification/[id]/read.ts @@ -0,0 +1,36 @@ +import Elysia, { t } from "elysia"; + +import getNotification from "@back/guards/getNotification"; +import getUser from "@back/guards/getUser"; +import NotificationModel from "@back/models/notification"; + +const read = new Elysia() + .use(getUser) + .use(getNotification) + .use(NotificationModel) + .patch( + "read", + async ({ notification, notificationModel }) => { + await notificationModel.db.updateOne( + { _id: notification._id }, + { $set: { read: true } } + ); + + return { success: true, message: "알림을 읽음 처리했습니다." }; + }, + { + response: { + 200: t.Object({ + success: t.Boolean(), + message: t.String(), + }), + }, + detail: { + tags: ["Notification"], + summary: "단일 알림 읽음 처리", + description: "특정 알림을 읽음 처리합니다.", + }, + } + ); + +export default read; \ No newline at end of file diff --git a/backend/src/routers/notification/index.ts b/backend/src/routers/notification/index.ts new file mode 100644 index 0000000..ac16e94 --- /dev/null +++ b/backend/src/routers/notification/index.ts @@ -0,0 +1,15 @@ +import Elysia from "elysia"; + +import NotificationIdRouter from "./[id]"; +import list from "./list"; +import read from "./read"; + +const NotificationRouter = new Elysia({ + name: "Notification Router", + prefix: "notification", +}) + .use(NotificationIdRouter) + .use(read) + .use(list); + +export default NotificationRouter; \ No newline at end of file diff --git a/backend/src/routers/notification/list.ts b/backend/src/routers/notification/list.ts new file mode 100644 index 0000000..0192500 --- /dev/null +++ b/backend/src/routers/notification/list.ts @@ -0,0 +1,69 @@ +import Elysia, { t } from "elysia"; + +import getUser from "@back/guards/getUser"; +import NotificationModel from "@back/models/notification"; + +const list = new Elysia() + .use(getUser) + .use(NotificationModel) + .get( + "list", + async ({ query, user, notificationModel }) => { + const { read } = query; + + const filter: Record = { userId: user._id }; + if (read !== undefined) { + filter.read = read === "true"; + } + + const notifications = await notificationModel.db + .find(filter) + .sort({ createdAt: -1 }) + .lean(); + + return { + success: true, + data: notifications.map((n) => ({ + _id: n._id.toString(), + senderType: n.senderType, + senderId: n.senderId.toString(), + senderName: n.senderName, + senderImage: n.senderImage, + type: n.type, + message: n.message, + data: n.data, + read: n.read, + createdAt: n.createdAt, + })), + }; + }, + { + query: t.Object({ + read: t.Optional(t.String({ description: "읽음 여부 필터: true / false" })), + }), + response: t.Object({ + success: t.Boolean(), + data: t.Array( + t.Object({ + _id: t.String(), + senderType: t.Enum({ user: "user", activity: "activity" }), + senderId: t.String(), + senderName: t.Optional(t.String()), + senderImage: t.Optional(t.String()), + type: t.String(), + message: t.String(), + data: t.Optional(t.Record(t.String(), t.Any())), + read: t.Boolean(), + createdAt: t.String(), + }) + ), + }), + detail: { + tags: ["Notification"], + summary: "알림 목록 조회", + description: "사용자의 알림 목록을 최신순으로 반환합니다. `read=true/false`로 필터링할 수 있습니다.", + }, + } + ); + +export default list; diff --git a/backend/src/routers/notification/read.ts b/backend/src/routers/notification/read.ts new file mode 100644 index 0000000..62f6f0e --- /dev/null +++ b/backend/src/routers/notification/read.ts @@ -0,0 +1,34 @@ +import Elysia, { t } from "elysia"; + +import getUser from "@back/guards/getUser"; +import NotificationModel from "@back/models/notification"; + +const read = new Elysia() + .use(getUser) + .use(NotificationModel) + .patch( + "/read-all", + async ({ user, notificationModel }) => { + await notificationModel.db.updateMany( + { userId: user._id, read: false }, + { $set: { read: true } } + ); + + return { success: true, message: "모든 알림을 읽음 처리했습니다." }; + }, + { + response: { + 200: t.Object({ + success: t.Boolean(), + message: t.String(), + }), + }, + detail: { + tags: ["Notification"], + summary: "전체 알림 읽음 처리", + description: "읽지 않은 모든 알림을 읽음 처리합니다.", + }, + } + ); + +export default read; \ No newline at end of file diff --git a/backend/src/utils/error.ts b/backend/src/utils/error.ts index 36bae19..a29aa20 100644 --- a/backend/src/utils/error.ts +++ b/backend/src/utils/error.ts @@ -37,6 +37,8 @@ export const ERROR_MESSAGE = { NO_EVENT_ID: [400, "이벤트 ID가 없습니다."], NO_EVENT: [404, "존재하지 않는 이벤트입니다."], + + NOTIFICATION_NOT_FOUND: [404, "알림을 찾을 수 없습니다."], } as const; export type ERROR_MESSAGE_TYPE = typeof ERROR_MESSAGE;