Skip to content

Commit 525907f

Browse files
authored
feat: Add Calander and Event (#2)
* - Timetable 생성 api - Timetable 수정 api - Timetable 삭제 api - 현재 사용자가 볼 수 있는 Timetable GET api - Event 생성 api - ics파일 import api - Event 수정 api - Event 삭제 api - 현재 사용자가 볼 수 있는 Event 목록 GET api - 반복 Event 전개 후 표시 - 날짜 필터링 넣기 * feat: ics export * feat: activity timetable add visibility * fix: reorganize API endpoints * fix: clean up * fix: event repeat fix * fix: apply eslint, change to dayjs, move create event
1 parent d500eff commit 525907f

28 files changed

+1450
-3
lines changed

backend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"@elysiajs/jwt": "^1.2.0",
1212
"@elysiajs/swagger": "^1.2.2",
1313
"elysia": "^1.2.0",
14-
"mongoose": "^8.13.1"
14+
"ics": "^3.8.1",
15+
"mongoose": "^8.13.1",
16+
"node-ical": "^0.20.1"
1517
},
1618
"devDependencies": {
1719
"bun-types": "latest"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Elysia } from "elysia";
2+
3+
import ActivityModel from "@back/models/activity";
4+
import JoinedActivityModel, { permissionList } from "@back/models/joined_activity";
5+
import TimetableModel from "@back/models/timetable";
6+
import exit, { errorElysia } from "@back/utils/error";
7+
8+
import getEvent from "./getEvent";
9+
import getUser from "./getUser";
10+
11+
const eventAuthorityService = () =>
12+
new Elysia()
13+
.use(getUser)
14+
.use(getEvent)
15+
.use(ActivityModel)
16+
.use(JoinedActivityModel)
17+
.use(TimetableModel)
18+
.guard({
19+
response: {
20+
...errorElysia(["UNAUTHORIZED"])
21+
}
22+
})
23+
.resolve(async ({
24+
event,
25+
user,
26+
activityModel,
27+
joinedActivityModel,
28+
timetableModel,
29+
error
30+
}) => {
31+
const userId = user._id.toString();
32+
33+
const timetable = await timetableModel.db.findById(event.timetable_id);
34+
if (!timetable) return exit(error, "UNAUTHORIZED");
35+
36+
const ownerType = timetable.owner_type;
37+
const ownerId = timetable.owner?.toString();
38+
39+
if ((ownerType === "user" || ownerType === "global") && ownerId === userId) {
40+
return;
41+
}
42+
43+
if (ownerType === "activity") {
44+
const activity = await activityModel.db.findById(ownerId);
45+
if (!activity) return exit(error, "UNAUTHORIZED");
46+
47+
const permissionIndex = permissionList.indexOf("vice_president");
48+
const allowedPermissions = permissionList.slice(0, permissionIndex + 1);
49+
50+
const joined = await joinedActivityModel.db.findOne({
51+
activity_id: ownerId,
52+
user_id: userId,
53+
permission: { $in: allowedPermissions },
54+
});
55+
56+
if (!joined) return exit(error, "UNAUTHORIZED");
57+
return;
58+
}
59+
60+
return exit(error, "UNAUTHORIZED");
61+
})
62+
.as("plugin");
63+
64+
export default eventAuthorityService;

backend/src/guards/getEvent.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Elysia, { t } from "elysia";
2+
3+
import EventModel, { IEvent } from "@back/models/event";
4+
import exit, { errorElysia } from "@back/utils/error";
5+
6+
const getEvent = new Elysia()
7+
.use(EventModel)
8+
.guard({
9+
params: t.Object({
10+
event_id: t.String({
11+
description: "이벤트 ID",
12+
}),
13+
}),
14+
response: {
15+
...errorElysia(["NO_EVENT_ID", "NO_EVENT", "INVALID_ID_TYPE"]),
16+
},
17+
})
18+
.resolve(async ({
19+
params,
20+
error,
21+
eventModel,
22+
}): Promise<{ event: IEvent }> => {
23+
try {
24+
const { event_id } = params;
25+
26+
if (!event_id) return exit(error, "NO_EVENT_ID");
27+
const found = await eventModel.db.findById(event_id);
28+
29+
if (!found) return exit(error, "NO_EVENT");
30+
31+
return { event: found.toObject() };
32+
} catch {
33+
return exit(error, "INVALID_ID_TYPE");
34+
}
35+
})
36+
.as("plugin");
37+
38+
export default getEvent;

backend/src/guards/getTimetable.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Elysia, { t } from "elysia";
2+
3+
import TimetableModel, { ITimetable } from "@back/models/timetable";
4+
import exit, { errorElysia } from "@back/utils/error";
5+
6+
const getTimetable = new Elysia()
7+
.use(TimetableModel)
8+
.guard({
9+
params: t.Object({
10+
timetable_id: t.String({
11+
description: "캘린더 ID",
12+
}),
13+
}),
14+
response: {
15+
...errorElysia(["NO_TIMETABLE_ID", "NO_TIMETABLE", "INVALID_ID_TYPE"]),
16+
},
17+
})
18+
.resolve(async ({
19+
params,
20+
error,
21+
timetableModel,
22+
}): Promise<{ timetable: ITimetable }> => {
23+
try {
24+
const { timetable_id } = params;
25+
26+
if (!timetable_id) return exit(error, "NO_TIMETABLE_ID");
27+
const found = await timetableModel.db.findById(timetable_id);
28+
29+
if (!found) return exit(error, "NO_TIMETABLE");
30+
31+
return { timetable: found.toObject() };
32+
} catch {
33+
return exit(error, "INVALID_ID_TYPE");
34+
}
35+
})
36+
.as("plugin");
37+
38+
export default getTimetable;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Elysia } from "elysia";
2+
3+
import ActivityModel from "@back/models/activity";
4+
import JoinedActivityModel, { permissionList } from "@back/models/joined_activity";
5+
import TimetableModel from "@back/models/timetable";
6+
import exit, { errorElysia } from "@back/utils/error";
7+
8+
import getUser from "./getUser";
9+
10+
const timetableAuthorityService = () =>
11+
new Elysia()
12+
.use(getUser)
13+
.use(ActivityModel)
14+
.use(JoinedActivityModel)
15+
.use(TimetableModel)
16+
.guard({
17+
response: {
18+
...errorElysia(["UNAUTHORIZED"]),
19+
},
20+
})
21+
.resolve(async ({ user, error, body, query, params, timetableModel, activityModel, joinedActivityModel }) => {
22+
type RequestInput = { timetable_id?: string };
23+
24+
const timetableId =
25+
(body as RequestInput)?.timetable_id ??
26+
(query as RequestInput)?.timetable_id ??
27+
(params as RequestInput)?.timetable_id;
28+
29+
30+
if (!timetableId) return exit(error, "UNAUTHORIZED");
31+
32+
const timetable = await timetableModel.db.findById(timetableId);
33+
if (!timetable) return exit(error, "UNAUTHORIZED");
34+
35+
const ownerType = timetable.owner_type;
36+
const ownerId = timetable.owner?.toString();
37+
const userId = user._id.toString();
38+
39+
if ((ownerType === "user" || ownerType === "global") && ownerId === userId) {
40+
return;
41+
}
42+
43+
if (ownerType === "activity") {
44+
const activity = await activityModel.db.findById(timetable.owner);
45+
if (!activity) return exit(error, "UNAUTHORIZED");
46+
47+
const permissionIndex = permissionList.indexOf("vice_president");
48+
const allowedPermissions = permissionList.slice(0, permissionIndex + 1);
49+
50+
const joined = await joinedActivityModel.db.findOne({
51+
activity_id: timetable.owner,
52+
user_id: user._id,
53+
permission: { $in: allowedPermissions },
54+
});
55+
56+
if (!joined) return exit(error, "UNAUTHORIZED");
57+
return;
58+
}
59+
60+
return exit(error, "UNAUTHORIZED");
61+
})
62+
.as("plugin");
63+
64+
export default timetableAuthorityService;

backend/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ if (Bun.env.NODE_ENV === "development") {
5050
{
5151
name: "Activity",
5252
description: "활동(동아리)에 관련된 API입니다.",
53+
},
54+
{
55+
name: "Timetable",
56+
description: "캘린더(시간표)에 관련된 API입니다.",
57+
},
58+
{
59+
name: "Event",
60+
description: "이벤트에 관련된 API입니다.",
5361
}
5462
]
5563
}

backend/src/models/event.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import dayjs from "dayjs";
2+
import Elysia, { t } from "elysia";
3+
import mongoose, { ObjectId } from "mongoose";
4+
5+
import { IDocument } from "@common/types/db";
6+
7+
export const weekdayList = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const;
8+
9+
interface DEvent {
10+
timetable_id: ObjectId;
11+
title?: string;
12+
startTime: string;
13+
endTime: string;
14+
isAllDay?: boolean;
15+
16+
repeat?: {
17+
frequency: "daily" | "weekly" | "monthly" | null;
18+
interval?: number;
19+
byWeekDay?: string[];
20+
bySetPosition?: number;
21+
byMonthDay?: number;
22+
until?: string;
23+
};
24+
}
25+
export type IEvent = IDocument<DEvent>;
26+
27+
export const eventElysiaSchema = t.Object({
28+
title: t.String({ example: "회의" }),
29+
startTime: t.String({ example: "2025-04-05 10:00:00 " }),
30+
endTime: t.String({ example: "2025-04-05 11:00:00" }),
31+
isAllDay: t.Optional(t.Boolean({ example: false })),
32+
repeat: t.Optional(
33+
t.Object({
34+
frequency: t.Enum(
35+
{ daily: "daily", weekly: "weekly", monthly: "monthly", none: "none" },
36+
{ example: "weekly", description: "\"none\"이면 반복 없음" }
37+
),
38+
interval: t.Optional(t.Number({ minimum: 1, default: 1, example: 1, description: "daily 사용시 유효, ex. 이틀에 한번 = 2" })),
39+
byWeekDay: t.Optional(t.Array(t.String({ examples: weekdayList, description: "반복할 요일들" }), { example: ["MO", "WE"] })),
40+
bySetPosition: t.Optional(t.Number({ example: 2, description: "monthly 사용시 유효, ex. 매월 2번째 화요일 = 2" })),
41+
byMonthDay: t.Optional(t.Number({ example: 1, description: "monthly 사용시 유효, byWeekDay bySetPosition 속성은 무시됨 , ex. 매월 1일 = 1" })),
42+
until: t.Optional(t.String({ example: "2025-06-30 23:59:59" }))
43+
})
44+
)
45+
});
46+
47+
const eventSchema = new mongoose.Schema<IEvent>({
48+
timetable_id: {
49+
type: mongoose.Schema.Types.ObjectId,
50+
ref: "Timetable",
51+
required: true,
52+
},
53+
title: String,
54+
startTime: { type: String, default: dayjs().format("YYYY-MM-DD HH:mm:ss"), required: true },
55+
endTime: { type: String, default: dayjs().format("YYYY-MM-DD HH:mm:ss"), required: true },
56+
isAllDay: Boolean,
57+
58+
repeat: {
59+
frequency: { type: String, enum: ["daily", "weekly", "monthly"], default: null },
60+
interval: { type: Number, default: 1 },
61+
byWeekDay: [String],
62+
bySetPosition: Number,
63+
byMonthDay: Number,
64+
until: { type: String, default: dayjs().format("YYYY-MM-DD HH:mm:ss") }
65+
},
66+
});
67+
68+
eventSchema.index({ timetable_id: 1, startTime: 1, endTime: 1 });
69+
70+
const EventDB = mongoose.model<IEvent>("Event", eventSchema);
71+
72+
const EventModel = new Elysia().decorate("eventModel", {
73+
db: EventDB,
74+
});
75+
76+
export default EventModel;

0 commit comments

Comments
 (0)