Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e378c4b
feat: 캘린더 OAuth API 유틸 추가
seongwon030 Mar 22, 2026
f4e5c7c
feat: 캘린더 동기화 유틸 분리
seongwon030 Mar 22, 2026
46361ce
test: 캘린더 동기화 유틸 테스트 추가
seongwon030 Mar 22, 2026
ea82390
style: 캘린더 연동 탭 스타일 정리
seongwon030 Mar 22, 2026
02135e4
feat: 캘린더 연동 탭 UI 구성 개선
seongwon030 Mar 22, 2026
f2fffe6
refactor: 구글 캘린더 로직 훅 분리
seongwon030 Mar 22, 2026
ad35d74
refactor: 노션 데이터 로직 훅 분리
seongwon030 Mar 22, 2026
ea35403
refactor: 노션 캘린더 UI 상태 훅 분리
seongwon030 Mar 22, 2026
7dec85a
refactor: 노션 OAuth 로직 훅 분리
seongwon030 Mar 22, 2026
989aab3
refactor: 캘린더 연동 조합 훅 구성
seongwon030 Mar 22, 2026
3c96345
feat: 캘린더 라우트 추가
seongwon030 Mar 22, 2026
bd10eb5
fix: 캘린더 날짜 파싱과 Notion OAuth 콜백 URL 정리
seongwon030 Mar 24, 2026
aced3e6
fix: lint error
seongwon030 Mar 25, 2026
a6113ed
feat: 동아리 일정 get api추가
seongwon030 Mar 26, 2026
7fd173c
feat: 일정보기 탭 클릭 이벤트 추가
seongwon030 Mar 26, 2026
81a2d5d
feat: 캘린더 querykey추가
seongwon030 Mar 26, 2026
b2701c8
feat: 캘린더 useQuery추가
seongwon030 Mar 26, 2026
cf15433
feat: 캘린더 이벤트 타입 추가
seongwon030 Mar 26, 2026
3841cd6
feat: 상세페이지 일정보기 탭 추가
seongwon030 Mar 26, 2026
359d5b0
feat: 동아리 상세 페이지 일정 캘린더 컴포넌트 추가
seongwon030 Mar 27, 2026
b2aca7b
fix: lint error
seongwon030 Mar 27, 2026
b887d97
refactor: Google/Notion 타입을 별도 파일로 분리
seongwon030 Mar 28, 2026
195ecd9
fix: clearError를 useCallback으로 감싸서 참조가 유지
seongwon030 Mar 28, 2026
71ee3ce
fix: OAuth 완료 후 sessionStorage state 정리
seongwon030 Mar 28, 2026
0f2377e
fix: parseDateKey datetime 타임존 처리 개선
seongwon030 Mar 28, 2026
ab0a3f0
fix: Google OAuth state 검증 후 sessionStorage 정리
seongwon030 Mar 28, 2026
12982cd
feat: 캘린더 데이터 없으면 일정 탭 숨김
seongwon030 Mar 28, 2026
6f04500
feat: 캘린더 데이터 lazy loading 및 캐싱 최적화
seongwon030 Mar 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 267 additions & 0 deletions frontend/src/apis/calendarOAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import API_BASE_URL from '@/constants/api';
import { secureFetch } from './auth/secureFetch';
import { handleResponse } from './utils/apiHelpers';

export interface GoogleCalendarItem {
id: string;
summary: string;
primary?: boolean;
}

export interface GoogleEventItem {
id: string;
summary?: string;
htmlLink?: string;
start?: {
dateTime?: string;
date?: string;
};
end?: {
dateTime?: string;
date?: string;
};
}

export interface NotionSearchItem {
id: string;
object: string;
url?: string;
last_edited_time?: string;
properties?: Record<string, unknown>;
}

export interface NotionDatabaseOption {
id: string;
title: string;
}

export const fetchGoogleCalendarList = async (accessToken: string) => {
const response = await fetch(
'https://www.googleapis.com/calendar/v3/users/me/calendarList',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);

if (!response.ok) {
throw new Error('Google 캘린더 목록 조회에 실패했습니다.');
}

const data = await response.json();
return (data.items ?? []) as GoogleCalendarItem[];
};

export const fetchGooglePrimaryEvents = async (accessToken: string) => {
const query = new URLSearchParams({
maxResults: '10',
singleEvents: 'true',
orderBy: 'startTime',
timeMin: new Date().toISOString(),
});

const response = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/primary/events?${query.toString()}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);

if (!response.ok) {
throw new Error('Google 캘린더 이벤트 조회에 실패했습니다.');
}

const data = await response.json();
return (data.items ?? []) as GoogleEventItem[];
};

interface NotionTokenRequest {
code: string;
}

interface NotionTokenResponse {
accessToken?: string;
workspaceName?: string;
workspaceId?: string;
}

interface NotionAuthorizeResponse {
authorizeUrl: string;
}

interface NotionPagesPayload {
items?: NotionSearchItem[];
results?: NotionSearchItem[];
total_results?: number;
totalResults?: number;
database_id?: string;
databaseId?: string;
}

interface NotionDatabasePayload {
id: string;
object?: string;
title?: Array<{ plain_text?: string }>;
}

export interface NotionPagesResponse {
items: NotionSearchItem[];
totalResults: number;
databaseId?: string;
}

export const fetchNotionAuthorizeUrl = async (state?: string) => {
const params = new URLSearchParams();
if (state) {
params.set('state', state);
}

const url = `${API_BASE_URL}/api/integration/notion/oauth/authorize${params.toString() ? `?${params.toString()}` : ''}`;
const response = await secureFetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
},
});

const data = await handleResponse<NotionAuthorizeResponse>(
response,
'Notion 인가 URL 생성에 실패했습니다.',
);

if (!data?.authorizeUrl) {
throw new Error('Notion 인가 URL이 비어있습니다.');
}
return data.authorizeUrl;
};

export const exchangeNotionCode = async ({ code }: NotionTokenRequest) => {
const response = await secureFetch(
`${API_BASE_URL}/api/integration/notion/oauth/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
}),
},
);

const data = await handleResponse<NotionTokenResponse>(
response,
'Notion 토큰 교환에 실패했습니다.',
);
return data;
};

export const fetchNotionPages = async () => {
const response = await secureFetch(
`${API_BASE_URL}/api/integration/notion/pages`,
{
method: 'GET',
headers: {
Accept: 'application/json',
},
},
);

const data = await handleResponse<NotionPagesPayload | NotionSearchItem[]>(
response,
'Notion 데이터 조회에 실패했습니다.',
);
if (Array.isArray(data)) {
return {
items: data,
totalResults: data.length,
} satisfies NotionPagesResponse;
}

const items = (data?.items ?? data?.results ?? []) as NotionSearchItem[];
const totalResults =
data?.total_results ?? data?.totalResults ?? items.length;
const databaseId = data?.database_id ?? data?.databaseId;
return {
items,
totalResults,
databaseId,
} satisfies NotionPagesResponse;
};

export const fetchNotionDatabasePages = async ({
databaseId,
dateProperty,
}: {
databaseId: string;
dateProperty?: string;
}) => {
const params = new URLSearchParams();
if (dateProperty) {
params.set('dateProperty', dateProperty);
}

const query = params.toString();
const response = await secureFetch(
`${API_BASE_URL}/api/integration/notion/databases/${databaseId}/pages${query ? `?${query}` : ''}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
},
},
);

const data = await handleResponse<NotionPagesPayload | NotionSearchItem[]>(
response,
'Notion 데이터베이스 페이지 조회에 실패했습니다.',
);

if (Array.isArray(data)) {
return {
items: data,
totalResults: data.length,
databaseId,
} satisfies NotionPagesResponse;
}

const items = (data?.items ?? data?.results ?? []) as NotionSearchItem[];
const totalResults =
data?.total_results ?? data?.totalResults ?? items.length;
const resolvedDatabaseId =
data?.database_id ?? data?.databaseId ?? databaseId;
return {
items,
totalResults,
databaseId: resolvedDatabaseId,
} satisfies NotionPagesResponse;
};

export const fetchNotionDatabases = async () => {
const response = await secureFetch(
`${API_BASE_URL}/api/integration/notion/databases`,
{
method: 'GET',
headers: {
Accept: 'application/json',
},
},
);

const data = await handleResponse<
{ results?: NotionDatabasePayload[] } | NotionDatabasePayload[]
>(response, 'Notion 데이터베이스 목록 조회에 실패했습니다.');

const databases = Array.isArray(data) ? data : (data?.results ?? []);
return databases.map((database) => ({
id: database.id,
title:
database.title
?.map((segment) => segment.plain_text ?? '')
.join('')
.trim() || '(이름 없는 데이터베이스)',
})) as NotionDatabaseOption[];
};
2 changes: 2 additions & 0 deletions frontend/src/pages/AdminPage/AdminRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ApplicantDetailPage from '@/pages/AdminPage/tabs/ApplicantsTab/ApplicantD
import ApplicantsListTab from '@/pages/AdminPage/tabs/ApplicantsTab/ApplicantsListTab/ApplicantsListTab';
import ApplicationEditTab from '@/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab';
import ApplicationListTab from '@/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab';
import CalendarSyncTab from '@/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab';
import ClubInfoEditTab from '@/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab';
import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab';
import RecruitEditTab from '@/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab';
Expand All @@ -18,6 +19,7 @@ export default function AdminRoutes() {
<Route index element={<Navigate to='club-info' replace />} />
<Route path='club-info' element={<ClubInfoEditTab />} />
<Route path='recruit-edit' element={<RecruitEditTab />} />
<Route path='calendar-sync' element={<CalendarSyncTab />} />
<Route path='photo-edit' element={<PhotoEditTab />} />
<Route path='account-edit' element={<AccountEditTab />} />
<Route path='application-list' element={<ApplicationListTab />} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const tabs: TabCategory[] = [
{ label: '기본 정보 수정', path: '/admin/club-info' },
{ label: '소개 정보 수정', path: '/admin/club-intro' },
{ label: '활동 사진 수정', path: '/admin/photo-edit' },
{ label: '동아리 일정 관리', path: '/admin/calendar-sync' },
],
},
{
Expand Down
Loading
Loading