From 6100fa66d618a886cb18138d3ef692e80f92a830 Mon Sep 17 00:00:00 2001 From: Edward Kim <65283190+bepyan@users.noreply.github.com> Date: Sat, 20 Jul 2024 20:22:14 +0900 Subject: [PATCH 1/3] =?UTF-8?q?inv-18=20SSO=20=EC=97=B0=EB=8F=99=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: lucia * feat: auth session * ✦ google * ✦ kakao * ✦ naver * style: cleanup code * feat: db 연동 * style: lint migrations * ✦ db 반영 * fix: set cookie * feat: user email * feat: sign-out * feat: add email in auth * feat: sign-out action * docs: SSO env template * fix: naver 응답 * feat: footer 여백 추가 * chore: remove unused --- .env.template | 24 ++- bun.lockb | Bin 223892 -> 242016 bytes package.json | 8 +- .../(auth)/sign-in/google/callback/route.ts | 65 ++++++ src/app/(auth)/sign-in/google/route.ts | 33 +++ .../(auth)/sign-in/kakao/callback/route.ts | 66 ++++++ src/app/(auth)/sign-in/kakao/route.ts | 11 + .../(auth)/sign-in/naver/callback/route.ts | 67 ++++++ src/app/(auth)/sign-in/naver/route.ts | 10 + src/app/(auth)/sign-out/actions.ts | 28 +++ src/app/(auth)/sign-out/route.ts | 28 +++ .../(playground)/playground/inner-tools.tsx | 2 +- .../(playground)/playground/sign-in/page.tsx | 51 +++++ src/lib/auth/lucia.ts | 60 ++++++ src/lib/auth/naver-provider.ts | 65 ++++++ src/lib/auth/utils.ts | 51 +++++ src/lib/db/migrations/0001_far_archangel.sql | 24 +++ src/lib/db/migrations/0002_zippy_stature.sql | 2 + src/lib/db/migrations/meta/0001_snapshot.json | 192 +++++++++++++++++ src/lib/db/migrations/meta/0002_snapshot.json | 204 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 14 ++ src/lib/db/schema/auth.ts | 13 ++ src/lib/db/schema/users.query.ts | 34 +++ src/lib/db/schema/users.ts | 19 ++ src/lib/env.ts | 10 + 25 files changed, 1077 insertions(+), 4 deletions(-) create mode 100644 src/app/(auth)/sign-in/google/callback/route.ts create mode 100644 src/app/(auth)/sign-in/google/route.ts create mode 100644 src/app/(auth)/sign-in/kakao/callback/route.ts create mode 100644 src/app/(auth)/sign-in/kakao/route.ts create mode 100644 src/app/(auth)/sign-in/naver/callback/route.ts create mode 100644 src/app/(auth)/sign-in/naver/route.ts create mode 100644 src/app/(auth)/sign-out/actions.ts create mode 100644 src/app/(auth)/sign-out/route.ts create mode 100644 src/app/(playground)/playground/sign-in/page.tsx create mode 100644 src/lib/auth/lucia.ts create mode 100644 src/lib/auth/naver-provider.ts create mode 100644 src/lib/auth/utils.ts create mode 100644 src/lib/db/migrations/0001_far_archangel.sql create mode 100644 src/lib/db/migrations/0002_zippy_stature.sql create mode 100644 src/lib/db/migrations/meta/0001_snapshot.json create mode 100644 src/lib/db/migrations/meta/0002_snapshot.json create mode 100644 src/lib/db/schema/auth.ts create mode 100644 src/lib/db/schema/users.query.ts create mode 100644 src/lib/db/schema/users.ts diff --git a/.env.template b/.env.template index 4cc714a..480d766 100644 --- a/.env.template +++ b/.env.template @@ -1 +1,23 @@ -DATABASE_URL= \ No newline at end of file +# database +## @see https://console.neon.tech/app/projects/wild-union-33027706 +DATABASE_URL= + +# OAUTH +## GOOGLE +## @see https://console.cloud.google.com/apis/credentials?project=invi-428615 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= + +## KAKAO +### @see https://developers.kakao.com/console/app/1102339/config/appKey +KAKAO_CLIENT_ID= +### @see https://developers.kakao.com/console/app/1102339/product/login/security +KAKAO_CLIENT_SECRET= +KAKAO_REDIRECT_URI= + +## NAVER +### @see https://developers.naver.com/apps/#/myapps/f4vceVr1QiIcw1FITmMo/overview +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= +NAVER_REDIRECT_URI= diff --git a/bun.lockb b/bun.lockb index 9dce61e429473f1cb242338bb79600fad314c787..8b4d4c8f8768be70443c5a6536a102d30dbc6dee 100755 GIT binary patch delta 54294 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 WNSxks%LHxX*NgVN(Xh(nVgCaNx9D^L delta 43636 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 diff --git a/package.json b/package.json index f1c9b01..f552aec 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,17 @@ "ui": "bunx shadcn-ui@latest", "ui:add": "bunx shadcn-ui@latest add", "ui:lint": "bunx prettier src/components/ui/* --write", - "db:lint": "bunx prettier src/lib/db/migrations/**/*.json --write", + "db:lint": "bunx prettier src/lib/db/migrations/**/*.{json,ts} --write", "db:generate": "drizzle-kit generate && bun db:lint", "db:migrate": "drizzle-kit migrate && bun db:lint", "db:push": "drizzle-kit push", - "db:pull": "drizzle-kit introspect", + "db:pull": "drizzle-kit introspect && bun db:lint", "db:drop": "drizzle-kit drop", "db:studio": "drizzle-kit studio", "db:check": "drizzle-kit check" }, "dependencies": { + "@lucia-auth/adapter-drizzle": "^1.0.7", "@neondatabase/serverless": "^0.9.4", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", @@ -38,10 +39,13 @@ "@tanstack/react-form": "^0.26.1", "@tanstack/react-query": "^5.50.1", "@tanstack/zod-form-adapter": "^0.25.3", + "arctic": "^2.0.0-next.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "drizzle-orm": "^0.31.3", "es-hangul": "^1.4.2", + "ky": "^1.4.0", + "lucia": "^3.2.0", "next": "14.2.4", "next-themes": "^0.3.0", "react": "^18.3.1", diff --git a/src/app/(auth)/sign-in/google/callback/route.ts b/src/app/(auth)/sign-in/google/callback/route.ts new file mode 100644 index 0000000..4e1c28d --- /dev/null +++ b/src/app/(auth)/sign-in/google/callback/route.ts @@ -0,0 +1,65 @@ +import { OAuth2RequestError } from "arctic"; +import ky from "ky"; +import { cookies } from "next/headers"; +import { google } from "~/lib/auth/lucia"; +import { createSession } from "~/lib/auth/utils"; +import { findOrCreateUser } from "~/lib/db/schema/users.query"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const storedState = cookies().get(`google_oauth_state`)?.value; + const codeVerifier = cookies().get(`google_oauth_code_verifier`)?.value; + + if (!code || !storedState || !codeVerifier || state !== storedState) { + return new Response(null, { + status: 400, + }); + } + + try { + // @see https://arctic.js.org/providers/google + const tokens = await google.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + + // @see https://developers.google.com/identity/openid-connect/openid-connect#an-id-tokens-payload + const user = await ky("https://openidconnect.googleapis.com/v1/userinfo", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }).json<{ + sub: string; + name: string; + given_name: string; + picture: string; + email: string; + }>(); + console.log(":user", user); + + const dbUser = await findOrCreateUser({ + name: user.name, + email: user.email, + provider: "google", + }); + + await createSession(dbUser.id); + + return new Response(null, { + status: 302, + headers: { + Location: "/playground/sign-in", + }, + }); + } catch (e) { + console.log(e); + if (e instanceof OAuth2RequestError) { + return new Response(e.message, { + status: 400, + }); + } + return new Response(null, { + status: 500, + }); + } +} diff --git a/src/app/(auth)/sign-in/google/route.ts b/src/app/(auth)/sign-in/google/route.ts new file mode 100644 index 0000000..1cc5aba --- /dev/null +++ b/src/app/(auth)/sign-in/google/route.ts @@ -0,0 +1,33 @@ +import { generateCodeVerifier, generateState } from "arctic"; +import { cookies } from "next/headers"; +import { google } from "~/lib/auth/lucia"; +import { env } from "~/lib/env"; + +export async function GET(): Promise { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + + // @see https://developers.google.com/identity/openid-connect/openid-connect?hl=ko#an-id-tokens-payload + const url = google.createAuthorizationURL(state, codeVerifier, [ + "openid", + "profile", + "email", + ]); + + // @see https://arcticjs.dev/guides/oauth2-pkce + cookies().set("google_oauth_state", state, { + path: "/", + secure: env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + cookies().set("google_oauth_code_verifier", codeVerifier, { + path: "/", + secure: env.NODE_ENV === "production", + httpOnly: true, + maxAge: 60 * 10, + }); + + return Response.redirect(url); +} diff --git a/src/app/(auth)/sign-in/kakao/callback/route.ts b/src/app/(auth)/sign-in/kakao/callback/route.ts new file mode 100644 index 0000000..bfcc7cd --- /dev/null +++ b/src/app/(auth)/sign-in/kakao/callback/route.ts @@ -0,0 +1,66 @@ +import { OAuth2RequestError } from "arctic"; +import ky from "ky"; +import { kakao } from "~/lib/auth/lucia"; +import { createSession } from "~/lib/auth/utils"; +import { findOrCreateUser } from "~/lib/db/schema/users.query"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + + if (!code) { + return new Response(null, { + status: 400, + }); + } + + try { + const tokens = await kakao.validateAuthorizationCode(code); + const accessToken = tokens.accessToken(); + + // @see https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + const user = await ky("https://kapi.kakao.com/v2/user/me", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }).json<{ + id: string; + name: string; + // TODO: 비즈앱 전환 + email: string; + phone_number: string; + kakao_account: { + profile: { + nickname: string; + profile_image_url: string; + }; + }; + }>(); + console.log(":user", user); + + const dbUser = await findOrCreateUser({ + name: user.name, + email: user.email, + provider: "kakao", + }); + + await createSession(dbUser.id); + + return new Response(null, { + status: 302, + headers: { + Location: "/playground/sign-in", + }, + }); + } catch (e) { + console.log(e); + if (e instanceof OAuth2RequestError) { + return new Response(e.message, { + status: 400, + }); + } + return new Response(null, { + status: 500, + }); + } +} diff --git a/src/app/(auth)/sign-in/kakao/route.ts b/src/app/(auth)/sign-in/kakao/route.ts new file mode 100644 index 0000000..780e40f --- /dev/null +++ b/src/app/(auth)/sign-in/kakao/route.ts @@ -0,0 +1,11 @@ +import { generateState } from "arctic"; +import { kakao } from "~/lib/auth/lucia"; + +export async function GET(): Promise { + const state = generateState(); + + // @see https://developers.kakao.com/docs/latest/ko/kakaologin/prerequisite#consent-item + const url = kakao.createAuthorizationURL(state, ["account_email"]); + + return Response.redirect(url); +} diff --git a/src/app/(auth)/sign-in/naver/callback/route.ts b/src/app/(auth)/sign-in/naver/callback/route.ts new file mode 100644 index 0000000..54af64c --- /dev/null +++ b/src/app/(auth)/sign-in/naver/callback/route.ts @@ -0,0 +1,67 @@ +import { OAuth2RequestError } from "arctic"; +import ky from "ky"; +import { naver } from "~/lib/auth/lucia"; +import { createSession } from "~/lib/auth/utils"; +import { findOrCreateUser } from "~/lib/db/schema/users.query"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorCode = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (!code || !state) { + return new Response(errorDescription, { + status: errorCode ? parseInt(errorCode) : 400, + }); + } + + try { + const tokens = await naver.validateAuthorizationCode(code, state); + const accessToken = tokens.accessToken(); + + // @see https://developers.naver.com/docs/login/devguide/devguide.md#3-4-5-접근-토큰을-이용하여-프로필-api-호출하기 + const res = await ky("https://openapi.naver.com/v1/nid/me", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }).json<{ + resultcode: string; + message: string; + response: { + id: string; + nickname: string; + profile_image: string; + email: string; + name: string; + }; + }>(); + console.log(":res", res); + + const dbUser = await findOrCreateUser({ + name: res.response.name, + email: res.response.email, + provider: "naver", + }); + + await createSession(dbUser.id); + + return new Response(null, { + status: 302, + headers: { + Location: "/playground/sign-in", + }, + }); + } catch (e) { + console.log(e); + if (e instanceof OAuth2RequestError) { + return new Response(e.message, { + status: 400, + }); + } + return new Response(null, { + status: 500, + }); + } +} diff --git a/src/app/(auth)/sign-in/naver/route.ts b/src/app/(auth)/sign-in/naver/route.ts new file mode 100644 index 0000000..0bde92e --- /dev/null +++ b/src/app/(auth)/sign-in/naver/route.ts @@ -0,0 +1,10 @@ +import { generateState } from "arctic"; +import { naver } from "~/lib/auth/lucia"; + +export async function GET(): Promise { + const state = generateState(); + + const url = naver.createAuthorizationURL(state); + + return Response.redirect(url); +} diff --git a/src/app/(auth)/sign-out/actions.ts b/src/app/(auth)/sign-out/actions.ts new file mode 100644 index 0000000..501f0e3 --- /dev/null +++ b/src/app/(auth)/sign-out/actions.ts @@ -0,0 +1,28 @@ +"use server"; + +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { + getAuth, + invalidateAuth, + invalidateSessionCookie, +} from "~/lib/auth/utils"; + +export async function signOutAction(redirectUrl = "/playground/sign-in") { + const { session } = await getAuth(); + if (!session) { + invalidateSessionCookie(); + return { error: "Unauthorized" }; + } + + await invalidateAuth(session.id); + + const headersList = headers(); + const referer = headersList.get("referer") || "/"; + const currentUrl = new URL(referer).pathname; + if (currentUrl === redirectUrl) { + return { refresh: true }; + } else { + redirect(redirectUrl); + } +} diff --git a/src/app/(auth)/sign-out/route.ts b/src/app/(auth)/sign-out/route.ts new file mode 100644 index 0000000..6da728f --- /dev/null +++ b/src/app/(auth)/sign-out/route.ts @@ -0,0 +1,28 @@ +import { signOutAction } from "~/app/(auth)/sign-out/actions"; + +export async function GET(): Promise { + const result = await signOutAction(); + + if (result.error) { + return new Response(null, { + status: 401, + }); + } + + if (result.refresh) { + return new Response(null, { + status: 200, + headers: { + "Content-Type": "text/html", + Refresh: "0", + }, + }); + } + + return new Response(null, { + status: 302, + headers: { + Location: "/playground/sign-in", + }, + }); +} diff --git a/src/app/(playground)/playground/inner-tools.tsx b/src/app/(playground)/playground/inner-tools.tsx index bc3b943..1abb577 100644 --- a/src/app/(playground)/playground/inner-tools.tsx +++ b/src/app/(playground)/playground/inner-tools.tsx @@ -6,7 +6,7 @@ export function AMain(props: React.HTMLAttributes) {
diff --git a/src/app/(playground)/playground/sign-in/page.tsx b/src/app/(playground)/playground/sign-in/page.tsx new file mode 100644 index 0000000..6016cf9 --- /dev/null +++ b/src/app/(playground)/playground/sign-in/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link"; +import { ALink, AMain } from "~/app/(playground)/playground/inner-tools"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { getAuth } from "~/lib/auth/utils"; + +export default async function Page() { + const auth = await getAuth(); + + return ( + + playground + {!!auth.user ? ( + + + + 환영합니다, {auth.user.name}님 + + + +
{JSON.stringify(auth, null, 2)}
+
+ +
+
+
+ ) : ( + + + 로그인 + + +
+ + + +
+
+
+ )} +
+ ); +} diff --git a/src/lib/auth/lucia.ts b/src/lib/auth/lucia.ts new file mode 100644 index 0000000..cac02de --- /dev/null +++ b/src/lib/auth/lucia.ts @@ -0,0 +1,60 @@ +import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle"; +import { Google, Kakao } from "arctic"; +import { Lucia } from "lucia"; +import { Naver } from "~/lib/auth/naver-provider"; +import { db } from "~/lib/db"; +import { sessions } from "~/lib/db/schema/auth"; +import { users } from "~/lib/db/schema/users"; +import { env } from "~/lib/env"; + +const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + // this sets cookies with super long expiration + // since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages + expires: false, + attributes: { + secure: env.NODE_ENV === "production", + sameSite: env.NODE_ENV === "production" ? "strict" : undefined, + domain: env.NODE_ENV === "production" ? "invi.my" : undefined, + }, + }, + getUserAttributes: (attributes) => { + return { + name: attributes.name, + email: attributes.email, + }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + name: string; + email: string; +} + +// OAuth +export const google = new Google( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.GOOGLE_REDIRECT_URI, +); + +export const kakao = new Kakao( + env.KAKAO_CLIENT_ID, + env.KAKAO_CLIENT_SECRET, + env.KAKAO_REDIRECT_URI, +); + +export const naver = new Naver( + env.NAVER_CLIENT_ID, + env.NAVER_CLIENT_SECRET, + env.NAVER_REDIRECT_URI, +); diff --git a/src/lib/auth/naver-provider.ts b/src/lib/auth/naver-provider.ts new file mode 100644 index 0000000..f549f56 --- /dev/null +++ b/src/lib/auth/naver-provider.ts @@ -0,0 +1,65 @@ +import type { OAuth2Tokens } from "arctic"; +import { createOAuth2Request, sendTokenRequest } from "arctic/dist/request"; + +const authorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize"; +const tokenEndpoint = "https://nid.naver.com/oauth2.0/token"; + +export class Naver { + private clientId: string; + private clientSecret: string; + private redirectURI: string; + + constructor(clientId: string, clientSecret: string, redirectURI: string) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectURI = redirectURI; + } + + public createAuthorizationURL(state: string): URL { + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("redirect_uri", this.redirectURI); + url.searchParams.set("state", state); + return url; + } + + public async validateAuthorizationCode( + code: string, + state: string, + ): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + body.set("code", code); + body.set("state", state); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; + } + + public async refreshAccessToken(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + body.set("refresh_token", refreshToken); + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; + } + + public async revokeToken(accessToken: string): Promise { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("client_id", this.clientId); + body.set("client_secret", this.clientSecret); + body.set("access_token", accessToken); + body.set("service_provider", "NAVER"); + + const request = createOAuth2Request(tokenEndpoint, body); + const tokens = await sendTokenRequest(request); + return tokens; + } +} diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts new file mode 100644 index 0000000..3cafdec --- /dev/null +++ b/src/lib/auth/utils.ts @@ -0,0 +1,51 @@ +import { cookies } from "next/headers"; +import { lucia } from "~/lib/auth/lucia"; + +export type Auth = ReturnType; + +export const getAuth = async (): Promise => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) return { user: null, session: null }; + + const result = await lucia.validateSession(sessionId); + try { + if (result.session?.fresh) { + setSessionCookie(result.session.id); + } + if (!result.session) { + invalidateSessionCookie(); + } + } catch { + // Next.js throws error when attempting to set cookies when rendering page + } + return result; +}; + +export const invalidateAuth = async (sessionId: string) => { + await lucia.invalidateSession(sessionId); + invalidateSessionCookie(); +}; + +export const createSession = async (userId: string) => { + const session = await lucia.createSession(userId, {}); + setSessionCookie(session.id); + return session; +}; + +export const setSessionCookie = (sessionId: string) => { + const sessionCookie = lucia.createSessionCookie(sessionId); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); +}; + +export const invalidateSessionCookie = () => { + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); +}; diff --git a/src/lib/db/migrations/0001_far_archangel.sql b/src/lib/db/migrations/0001_far_archangel.sql new file mode 100644 index 0000000..0f5975c --- /dev/null +++ b/src/lib/db/migrations/0001_far_archangel.sql @@ -0,0 +1,24 @@ +DO $$ BEGIN + CREATE TYPE "public"."provider" AS ENUM('google', 'kakao', 'naver'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "session" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "provider" "provider", + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/src/lib/db/migrations/0002_zippy_stature.sql b/src/lib/db/migrations/0002_zippy_stature.sql new file mode 100644 index 0000000..f0aa5f8 --- /dev/null +++ b/src/lib/db/migrations/0002_zippy_stature.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "email" text NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email"); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0001_snapshot.json b/src/lib/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..7417583 --- /dev/null +++ b/src/lib/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,192 @@ +{ + "id": "80611927-d177-4e25-951d-fd2762beff43", + "prevId": "84c21b9f-b531-4bfb-9ba0-c68b838dce50", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.test_job": { + "name": "test_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_id": { + "name": "test_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "test_job_test_id_test_id_fk": { + "name": "test_job_test_id_test_id_fk", + "tableFrom": "test_job", + "tableTo": "test", + "columnsFrom": ["test_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.test": { + "name": "test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "test_email_unique": { + "name": "test_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.provider": { + "name": "provider", + "schema": "public", + "values": ["google", "kakao", "naver"] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/db/migrations/meta/0002_snapshot.json b/src/lib/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..a995ab9 --- /dev/null +++ b/src/lib/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,204 @@ +{ + "id": "c9d261b2-a826-4c92-b261-a6f5ca8a7264", + "prevId": "80611927-d177-4e25-951d-fd2762beff43", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.test_job": { + "name": "test_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_id": { + "name": "test_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "test_job_test_id_test_id_fk": { + "name": "test_job_test_id_test_id_fk", + "tableFrom": "test_job", + "tableTo": "test", + "columnsFrom": ["test_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.test": { + "name": "test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "test_email_unique": { + "name": "test_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + } + }, + "enums": { + "public.provider": { + "name": "provider", + "schema": "public", + "values": ["google", "kakao", "naver"] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index abe47a3..ef26888 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1720450854164, "tag": "0000_tranquil_sasquatch", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1721033805896, + "tag": "0001_far_archangel", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1721096502112, + "tag": "0002_zippy_stature", + "breakpoints": true } ] } diff --git a/src/lib/db/schema/auth.ts b/src/lib/db/schema/auth.ts new file mode 100644 index 0000000..8e181e6 --- /dev/null +++ b/src/lib/db/schema/auth.ts @@ -0,0 +1,13 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { users } from "~/lib/db/schema/users"; + +export const sessions = pgTable("session", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date", + }).notNull(), +}); diff --git a/src/lib/db/schema/users.query.ts b/src/lib/db/schema/users.query.ts new file mode 100644 index 0000000..9c4f8ed --- /dev/null +++ b/src/lib/db/schema/users.query.ts @@ -0,0 +1,34 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { generateId } from "lucia"; +import { db } from "~/lib/db"; +import { users, type UserInsert } from "~/lib/db/schema/users"; + +export async function createUser(data: Omit) { + const res = await db + .insert(users) + .values({ ...data, id: generateId(15) }) + .returning(); + return res[0]; +} + +export async function getUser(id: string) { + const userList = await db.select().from(users).where(eq(users.id, id)); + return userList.at(0); +} + +export async function getUserByEmail(email: string) { + const userList = await db.select().from(users).where(eq(users.email, email)); + return userList.at(0); +} + +export async function findOrCreateUser(user: Omit) { + let dbUser = await getUserByEmail(user.email); + + if (!dbUser) { + dbUser = await createUser(user); + } + + return dbUser; +} diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts new file mode 100644 index 0000000..ab4b036 --- /dev/null +++ b/src/lib/db/schema/users.ts @@ -0,0 +1,19 @@ +import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +export const providerEnum = pgEnum("provider", ["google", "kakao", "naver"]); + +export const users = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + provider: providerEnum("provider"), + createdAt: timestamp("created_at", { + withTimezone: true, + mode: "date", + }) + .notNull() + .defaultNow(), +}); + +export type User = typeof users.$inferSelect; +export type UserInsert = typeof users.$inferInsert; diff --git a/src/lib/env.ts b/src/lib/env.ts index 3c6f177..d73395e 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -7,6 +7,16 @@ export const env = createEnv({ .enum(["development", "test", "production"]) .default("development"), DATABASE_URL: z.string().min(1), + /* ---- OAuth ---- */ + GOOGLE_CLIENT_ID: z.string(), + GOOGLE_CLIENT_SECRET: z.string(), + GOOGLE_REDIRECT_URI: z.string(), + KAKAO_CLIENT_ID: z.string(), + KAKAO_CLIENT_SECRET: z.string(), + KAKAO_REDIRECT_URI: z.string(), + NAVER_CLIENT_ID: z.string(), + NAVER_CLIENT_SECRET: z.string(), + NAVER_REDIRECT_URI: z.string(), }, client: {}, experimental__runtimeEnv: {}, From 744b28e359807d7a7a2a46dca98feb899b3ead2d Mon Sep 17 00:00:00 2001 From: Gibeom Lim Date: Sun, 21 Jul 2024 14:06:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?INV-51=20UT=20DB=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=A0=95=EC=9D=98=20(invitation=5Fresponse)=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add invitation_response schema * fix: filename * feat: drizzle gen files --------- Co-authored-by: gibeom --- src/lib/db/migrations/meta/0003_snapshot.json | 260 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 9 +- src/lib/db/schema/invitation_response.ts | 12 + 3 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/lib/db/migrations/meta/0003_snapshot.json create mode 100644 src/lib/db/schema/invitation_response.ts diff --git a/src/lib/db/migrations/meta/0003_snapshot.json b/src/lib/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..16dc2ef --- /dev/null +++ b/src/lib/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,260 @@ +{ + "id": "f11905dc-c092-4bac-84c7-aa15b24a3791", + "prevId": "c9d261b2-a826-4c92-b261-a6f5ca8a7264", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.invitation_response": { + "name": "invitation_response", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "participant_name": { + "name": "participant_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attendance": { + "name": "attendance", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.test_job": { + "name": "test_job", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_id": { + "name": "test_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "test_job_test_id_test_id_fk": { + "name": "test_job_test_id_test_id_fk", + "tableFrom": "test_job", + "tableTo": "test", + "columnsFrom": [ + "test_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.test": { + "name": "test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "test_email_unique": { + "name": "test_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": { + "public.provider": { + "name": "provider", + "schema": "public", + "values": [ + "google", + "kakao", + "naver" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index ef26888..c36abe4 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1721096502112, "tag": "0002_zippy_stature", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1721536648423, + "tag": "0003_happy_sharon_ventura", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/lib/db/schema/invitation_response.ts b/src/lib/db/schema/invitation_response.ts new file mode 100644 index 0000000..b1ff852 --- /dev/null +++ b/src/lib/db/schema/invitation_response.ts @@ -0,0 +1,12 @@ +import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const invitationResponses = pgTable("invitation_response", { + id: uuid("id").primaryKey().notNull(), + participant_name: text("participant_name").notNull(), + attendance: boolean("attendance").notNull(), + reason: text("reason"), + created_at: timestamp("created_at").notNull(), +}); + +export type InvitationResponse = typeof invitationResponses.$inferSelect; +export type InvitationResponseInsert = typeof invitationResponses.$inferInsert; From ed9ee8078ab0f959a9e6669070ba5e5ba41c1628 Mon Sep 17 00:00:00 2001 From: gibeom Date: Sun, 21 Jul 2024 14:19:06 +0900 Subject: [PATCH 3/3] hotfix: migration files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마이그레이션 gen 파일들이 정상적으로 병합되지 않아 hotfix합니다. --- .../migrations/0003_peaceful_white_tiger.sql | 7 ++++ src/lib/db/migrations/meta/0003_snapshot.json | 34 +++++-------------- src/lib/db/migrations/meta/_journal.json | 6 ++-- 3 files changed, 19 insertions(+), 28 deletions(-) create mode 100644 src/lib/db/migrations/0003_peaceful_white_tiger.sql diff --git a/src/lib/db/migrations/0003_peaceful_white_tiger.sql b/src/lib/db/migrations/0003_peaceful_white_tiger.sql new file mode 100644 index 0000000..28fd152 --- /dev/null +++ b/src/lib/db/migrations/0003_peaceful_white_tiger.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "invitation_response" ( + "id" uuid PRIMARY KEY NOT NULL, + "participant_name" text NOT NULL, + "attendance" boolean NOT NULL, + "reason" text, + "created_at" timestamp NOT NULL +); diff --git a/src/lib/db/migrations/meta/0003_snapshot.json b/src/lib/db/migrations/meta/0003_snapshot.json index 16dc2ef..0b0d40c 100644 --- a/src/lib/db/migrations/meta/0003_snapshot.json +++ b/src/lib/db/migrations/meta/0003_snapshot.json @@ -1,5 +1,5 @@ { - "id": "f11905dc-c092-4bac-84c7-aa15b24a3791", + "id": "1e7681b2-7222-4b1a-a6a5-eb338ef65ce8", "prevId": "c9d261b2-a826-4c92-b261-a6f5ca8a7264", "version": "7", "dialect": "postgresql", @@ -33,12 +33,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -134,12 +130,8 @@ "name": "test_job_test_id_test_id_fk", "tableFrom": "test_job", "tableTo": "test", - "columnsFrom": [ - "test_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["test_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -183,9 +175,7 @@ "test_email_unique": { "name": "test_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } } }, @@ -233,9 +223,7 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } } } @@ -244,11 +232,7 @@ "public.provider": { "name": "provider", "schema": "public", - "values": [ - "google", - "kakao", - "naver" - ] + "values": ["google", "kakao", "naver"] } }, "schemas": {}, @@ -257,4 +241,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index c36abe4..78868e2 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -26,9 +26,9 @@ { "idx": 3, "version": "7", - "when": 1721536648423, - "tag": "0003_happy_sharon_ventura", + "when": 1721539066271, + "tag": "0003_peaceful_white_tiger", "breakpoints": true } ] -} \ No newline at end of file +}