Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
810 changes: 381 additions & 429 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/app/api/google/auth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { buildAuthUrl } from "@/lib/google/auth";

export const dynamic = "force-dynamic";

/**
* GET /api/google/auth
* Google OAuth 동의 화면으로 리다이렉트한다.
*/
export async function GET() {
const authUrl = buildAuthUrl();
return NextResponse.redirect(authUrl);
}
41 changes: 41 additions & 0 deletions src/app/api/google/calendars/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { getValidTokens } from "@/lib/google/auth";
import { listCalendars } from "@/lib/google/calendars";

export const dynamic = "force-dynamic";

/**
* GET /api/google/calendars
* 연결된 Google 계정의 캘린더 목록을 반환한다.
*/
export async function GET() {
const tokens = await getValidTokens();
if (!tokens) {
return NextResponse.json(
{
error:
"Google 계정이 연결되지 않았습니다. /api/google/auth로 인증해주세요.",
},
{ status: 401 },
);
}

try {
const calendars = await listCalendars(tokens.accessToken);
return NextResponse.json({ calendars });
} catch (err) {
console.error("[google.calendars] 오류:", err);

if (err instanceof Error && err.message.includes("만료")) {
return NextResponse.json(
{ error: "Google 인증이 만료되었습니다. 다시 연결해주세요." },
{ status: 401 },
);
}

return NextResponse.json(
{ error: "캘린더 목록 조회 중 오류가 발생했습니다." },
{ status: 502 },
);
}
}
49 changes: 49 additions & 0 deletions src/app/api/google/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import {
exchangeCodeForTokens,
getUserEmail,
saveTokensToCookie,
} from "@/lib/google/auth";

export const dynamic = "force-dynamic";

/**
* GET /api/google/callback
* Google OAuth 콜백. authorization code를 토큰으로 교환한 뒤 쿠키에 저장한다.
*/
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get("code");
const error = req.nextUrl.searchParams.get("error");

if (error) {
return NextResponse.json(
{ error: `Google OAuth 거부: ${error}` },
{ status: 400 },
);
}

if (!code) {
return NextResponse.json(
{ error: "authorization code가 없습니다." },
{ status: 400 },
);
}

try {
const tokens = await exchangeCodeForTokens(code);
const email = await getUserEmail(tokens.accessToken);

await saveTokensToCookie(tokens);

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
return NextResponse.redirect(
`${baseUrl}/api/google/calendars?connected=true&email=${encodeURIComponent(email)}`,
);
} catch (err) {
console.error("[google.callback] 오류:", err);
return NextResponse.json(
{ error: "Google 계정 연결에 실패했습니다." },
{ status: 500 },
);
}
}
83 changes: 83 additions & 0 deletions src/app/api/google/events/create/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getValidTokens } from "@/lib/google/auth";
import { createEvent } from "@/lib/google/events";

export const dynamic = "force-dynamic";

const CreateEventSchema = z.object({
calendarId: z.string().min(1, "calendarId는 필수입니다."),
summary: z
.string()
.min(1, "제목은 1자 이상이어야 합니다.")
.max(255, "제목은 255자 이하여야 합니다."),
startDateTime: z.string().datetime({
offset: true,
message: "startDateTime은 ISO 8601 형식이어야 합니다.",
}),
endDateTime: z.string().datetime({
offset: true,
message: "endDateTime은 ISO 8601 형식이어야 합니다.",
}),
location: z.string().max(500).optional(),
description: z.string().max(8000).optional(),
timeZone: z.string().optional(),
});

/**
* POST /api/google/events/create
* Google Calendar에 새 일정을 생성한다.
*/
export async function POST(req: NextRequest) {
const tokens = await getValidTokens();
if (!tokens) {
return NextResponse.json(
{ error: "Google 계정이 연결되지 않았습니다." },
{ status: 401 },
);
}

const body = await req.json().catch(() => null);
const parsed = CreateEventSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 },
);
}

const { calendarId, summary, startDateTime, endDateTime, ...rest } =
parsed.data;

if (new Date(startDateTime) >= new Date(endDateTime)) {
return NextResponse.json(
{ error: "endDateTime은 startDateTime보다 나중이어야 합니다." },
{ status: 400 },
);
}

try {
const event = await createEvent(tokens.accessToken, calendarId, {
summary,
startDateTime,
endDateTime,
...rest,
});

return NextResponse.json({ event }, { status: 201 });
} catch (err) {
console.error("[google.events.create] 오류:", err);

if (err instanceof Error && err.message.includes("만료")) {
return NextResponse.json(
{ error: "Google 인증이 만료되었습니다." },
{ status: 401 },
);
}

return NextResponse.json(
{ error: "일정 생성 중 오류가 발생했습니다." },
{ status: 502 },
);
}
}
71 changes: 71 additions & 0 deletions src/app/api/google/events/query/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { getValidTokens } from "@/lib/google/auth";
import { queryEvents } from "@/lib/google/events";

export const dynamic = "force-dynamic";

/**
* GET /api/google/events/query?calendarId=...&startDate=...&endDate=...
* 특정 캘린더의 기간별 일정을 조회한다.
*/
export async function GET(req: NextRequest) {
const tokens = await getValidTokens();
if (!tokens) {
return NextResponse.json(
{ error: "Google 계정이 연결되지 않았습니다." },
{ status: 401 },
);
}

const calendarId = req.nextUrl.searchParams.get("calendarId");
const startDate = req.nextUrl.searchParams.get("startDate");
const endDate = req.nextUrl.searchParams.get("endDate");

if (!calendarId || !startDate || !endDate) {
return NextResponse.json(
{ error: "calendarId, startDate, endDate 파라미터가 필요합니다." },
{ status: 400 },
);
}

const start = new Date(startDate);
const end = new Date(endDate);

if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return NextResponse.json(
{ error: "날짜 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요." },
{ status: 400 },
);
}

if (start >= end) {
return NextResponse.json(
{ error: "endDate는 startDate보다 나중이어야 합니다." },
{ status: 400 },
);
}

try {
const events = await queryEvents(
tokens.accessToken,
calendarId,
start,
end,
);
return NextResponse.json({ events });
} catch (err) {
console.error("[google.events.query] 오류:", err);

if (err instanceof Error && err.message.includes("만료")) {
return NextResponse.json(
{ error: "Google 인증이 만료되었습니다." },
{ status: 401 },
);
}

return NextResponse.json(
{ error: "일정 조회 중 오류가 발생했습니다." },
{ status: 502 },
);
}
}
12 changes: 5 additions & 7 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";

const geistSans = localFont({
src: "./fonts/GeistVF.woff",
const geistSans = Inter({
subsets: ["latin"],
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
const geistMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
weight: "100 900",
});

export const metadata: Metadata = {
Expand Down
Loading
Loading