Repository navigation
feat(auth): 카카오 로그인 시 약관 동의 레코드 저장 구현 #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
7755a9e
3e42ccc
a7bb550
3906a8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /** | ||
| * 기존 유저 약관 동의 데이터 backfill 스크립트 | ||
| * | ||
| * 실행: npx tsx scripts/backfill-user-terms-agreements.ts | ||
| * | ||
| * users.terms_agreed_at이 존재하지만 user_terms_agreements 레코드가 없는 유저 대상으로 | ||
| * service, privacy 약관 동의 레코드를 일괄 생성합니다. | ||
| */ | ||
| import { PrismaClient, TermsType } from '@prisma/client' | ||
|
|
||
| const prisma = new PrismaClient() | ||
|
|
||
| const INITIAL_TERMS_VERSION = '2026-06-18' | ||
|
|
||
| async function main() { | ||
| const usersWithoutAgreements = await prisma.user.findMany({ | ||
| where: { | ||
| termsAgreedAt: { not: null }, | ||
| termsAgreements: { none: {} }, | ||
| }, | ||
| select: { id: true, termsAgreedAt: true }, | ||
| }) | ||
|
|
||
| console.log(`backfill 대상 유저 수: ${usersWithoutAgreements.length}`) | ||
|
|
||
| if (usersWithoutAgreements.length === 0) { | ||
| console.log('backfill 대상 없음. 종료.') | ||
| return | ||
| } | ||
|
|
||
| const records = usersWithoutAgreements.flatMap((user) => [ | ||
| { | ||
| userId: user.id, | ||
| termsType: TermsType.SERVICE, | ||
| termsVersion: INITIAL_TERMS_VERSION, | ||
| isRequired: true, | ||
| agreedAt: user.termsAgreedAt!, | ||
| }, | ||
| { | ||
| userId: user.id, | ||
| termsType: TermsType.PRIVACY, | ||
| termsVersion: INITIAL_TERMS_VERSION, | ||
| isRequired: true, | ||
| agreedAt: user.termsAgreedAt!, | ||
| }, | ||
|
Comment on lines
+13
to
+45
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 백필 스크립트가 중앙 약관 버전 상수를 우회하고 있습니다.
제안 변경안-import { PrismaClient, TermsType } from '`@prisma/client`'
+import { PrismaClient } from '`@prisma/client`'
+import { CURRENT_TERMS_VERSIONS } from '../src/domains/auth/constants'
const prisma = new PrismaClient()
-const INITIAL_TERMS_VERSION = '2026-06-18'
-
async function main() {
@@
- const records = usersWithoutAgreements.flatMap((user) => [
- {
- userId: user.id,
- termsType: TermsType.SERVICE,
- termsVersion: INITIAL_TERMS_VERSION,
- isRequired: true,
- agreedAt: user.termsAgreedAt!,
- },
- {
- userId: user.id,
- termsType: TermsType.PRIVACY,
- termsVersion: INITIAL_TERMS_VERSION,
- isRequired: true,
- agreedAt: user.termsAgreedAt!,
- },
- ])
+ const records = usersWithoutAgreements.flatMap((user) =>
+ Object.values(CURRENT_TERMS_VERSIONS).map((terms) => ({
+ userId: user.id,
+ termsType: terms.type,
+ termsVersion: terms.version,
+ isRequired: terms.isRequired,
+ agreedAt: user.termsAgreedAt!,
+ })),
+ )🤖 Prompt for AI Agents |
||
| ]) | ||
|
|
||
| const result = await prisma.userTermsAgreement.createMany({ | ||
| data: records, | ||
| skipDuplicates: true, | ||
| }) | ||
|
|
||
| console.log(`생성된 레코드 수: ${result.count}`) | ||
| console.log('backfill 완료.') | ||
| } | ||
|
|
||
| main() | ||
| .catch((e) => { | ||
| console.error('backfill 실패:', e) | ||
| process.exit(1) | ||
| }) | ||
| .finally(() => prisma.$disconnect()) | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { NextRequest, NextResponse } from 'next/server' | ||
| import { getServerSession } from 'next-auth' | ||
| import { authOptions } from '@/lib/auth' | ||
| import { prisma } from '@/lib/db' | ||
| import { getSessionUserId } from '@/lib/auth/session' | ||
| import type { ApiResponse } from '@/types/common' | ||
|
|
||
| interface UserMeDto { | ||
| id: string | ||
| name: string | null | ||
| nickname: string | null | ||
| mbti: string | null | ||
| } | ||
|
|
||
| export async function GET() { | ||
| const session = await getServerSession(authOptions) | ||
| const userId = getSessionUserId(session) | ||
|
Comment on lines
+14
to
+16
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 세션 조회도
As per coding guidelines, Also applies to: 66-68 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| if (!userId) { | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, | ||
| { status: 401 }, | ||
| ) | ||
| } | ||
|
|
||
| try { | ||
| const user = await prisma.user.findUnique({ | ||
| where: { id: userId }, | ||
| select: { id: true, name: true, nickname: true, mbti: true }, | ||
| }) | ||
|
|
||
| if (!user) { | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'USER_NOT_FOUND', message: '사용자를 찾을 수 없습니다.' } }, | ||
| { status: 404 }, | ||
| ) | ||
| } | ||
|
|
||
| return NextResponse.json<ApiResponse<UserMeDto>>({ | ||
| success: true, | ||
| data: { id: user.id, name: user.name, nickname: user.nickname, mbti: user.mbti }, | ||
| }) | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error) | ||
| console.error('[user/me] GET error', { message }) | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, | ||
| { status: 500 }, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| const VALID_MBTI = [ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. user type 파일 하나 만들까요? 저도 같은 거 써서 그냥 같이 쓸까봐요
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 그러는게 좋을 듯 싶슴다 |
||
| 'INTJ', 'INTP', 'ENTJ', 'ENTP', | ||
| 'INFJ', 'INFP', 'ENFJ', 'ENFP', | ||
| 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', | ||
| 'ISTP', 'ISFP', 'ESTP', 'ESFP', | ||
| ] | ||
|
|
||
| interface PatchBody { | ||
| name?: string | ||
| nickname?: string | ||
| mbti?: string | null | ||
| } | ||
|
|
||
| export async function PATCH(request: NextRequest) { | ||
| const session = await getServerSession(authOptions) | ||
| const userId = getSessionUserId(session) | ||
|
|
||
| if (!userId) { | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, | ||
| { status: 401 }, | ||
| ) | ||
| } | ||
|
|
||
| try { | ||
| const body = (await request.json()) as PatchBody | ||
| const data: Record<string, string | null> = {} | ||
| const fieldErrors: { field: string; code: string; message: string }[] = [] | ||
|
|
||
| if (body.name !== undefined) { | ||
| const trimmed = body.name.trim() | ||
| if (!trimmed || trimmed.length < 1 || trimmed.length > 50) { | ||
| fieldErrors.push({ field: 'name', code: 'INVALID_NAME', message: '이름은 1~50자로 입력해주세요.' }) | ||
| } else { | ||
| data.name = trimmed | ||
| } | ||
| } | ||
|
|
||
| if (body.nickname !== undefined) { | ||
| const trimmed = body.nickname.trim() | ||
| if (!trimmed || trimmed.length < 2 || trimmed.length > 20) { | ||
| fieldErrors.push({ field: 'nickname', code: 'INVALID_NICKNAME', message: '닉네임은 2~20자로 입력해주세요.' }) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이것도 수정해야겠네요
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 확인했슴다 |
||
| } else { | ||
| data.nickname = trimmed | ||
| } | ||
| } | ||
|
|
||
| if (body.mbti !== undefined) { | ||
| if (body.mbti === null || body.mbti === '') { | ||
| data.mbti = null | ||
| } else if (!VALID_MBTI.includes(body.mbti.toUpperCase())) { | ||
| fieldErrors.push({ field: 'mbti', code: 'INVALID_MBTI', message: '올바른 MBTI를 선택해주세요.' }) | ||
| } else { | ||
| data.mbti = body.mbti.toUpperCase() | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| if (fieldErrors.length > 0) { | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'VALIDATION_ERROR', message: '입력값을 확인해주세요.', fieldErrors } }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 음 지시멘트가 너무 애매해서.. 한글 ㅇㅇ자 문장부호 안된다 이런식으로 작성해야될 것 같아요
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 확인했슴다 |
||
| { status: 400 }, | ||
| ) | ||
| } | ||
|
|
||
| if (Object.keys(data).length === 0) { | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'NO_CHANGES', message: '변경할 항목이 없습니다.' } }, | ||
| { status: 400 }, | ||
| ) | ||
| } | ||
|
|
||
| const updated = await prisma.user.update({ | ||
| where: { id: userId }, | ||
| select: { id: true, name: true, nickname: true, mbti: true }, | ||
| data, | ||
| }) | ||
|
|
||
| return NextResponse.json<ApiResponse<UserMeDto>>({ | ||
| success: true, | ||
| data: { | ||
| id: updated.id, | ||
| name: updated.name, | ||
| nickname: updated.nickname, | ||
| mbti: updated.mbti, | ||
| }, | ||
| }) | ||
| } catch (error) { | ||
| const err = error as { code?: string; meta?: { target?: string[] } } | ||
| if (err.code === 'P2002' && err.meta?.target?.includes('nickname')) { | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'DUPLICATE_NICKNAME', message: '이미 사용 중인 닉네임입니다.' } }, | ||
| { status: 409 }, | ||
| ) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| const message = error instanceof Error ? error.message : String(error) | ||
| console.error('[user/me] PATCH error', { message }) | ||
| return NextResponse.json<ApiResponse>( | ||
| { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, | ||
| { status: 500 }, | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { TermsType } from '@prisma/client' | ||
|
|
||
| export const CURRENT_TERMS_VERSIONS: Record<string, { type: TermsType; version: string; isRequired: boolean }> = { | ||
| SERVICE: { type: TermsType.SERVICE, version: '2026-06-18', isRequired: true }, | ||
| PRIVACY: { type: TermsType.PRIVACY, version: '2026-06-18', isRequired: true }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,26 +1,57 @@ | ||
| 'use client' | ||
|
|
||
| import { useQuery } from '@tanstack/react-query' | ||
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' | ||
| import type { ApiResponse } from '@/types/common' | ||
|
|
||
| interface UserMeData { | ||
| id: string | ||
| name: string | null | ||
| nickname: string | null | ||
| mbti: string | null | ||
| } | ||
|
|
||
| export interface UpdateProfileParams { | ||
| name?: string | ||
| nickname?: string | ||
| mbti?: string | null | ||
| } | ||
|
|
||
| const USER_ME_KEY = ['user', 'me'] as const | ||
|
|
||
| async function fetchUserMe(): Promise<UserMeData | null> { | ||
| const res = await fetch('/api/user/me') | ||
| const res = await fetch('/api/users/me') | ||
| if (res.status === 401) return null | ||
| if (!res.ok) throw new Error('사용자 정보를 불러오지 못했습니다.') | ||
| const json: ApiResponse<UserMeData> = await res.json() | ||
| return json.data ?? null | ||
| } | ||
|
|
||
| async function updateUserProfile(params: UpdateProfileParams): Promise<UserMeData> { | ||
| const res = await fetch('/api/users/me', { | ||
| method: 'PATCH', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify(params), | ||
| }) | ||
| const json: ApiResponse<UserMeData> = await res.json() | ||
| if (!json.success || !json.data) throw new Error(json.error?.message ?? '수정에 실패했습니다.') | ||
| return json.data | ||
| } | ||
|
|
||
| export function useUserMe() { | ||
| return useQuery({ | ||
| queryKey: ['user', 'me'], | ||
| queryKey: USER_ME_KEY, | ||
| queryFn: fetchUserMe, | ||
| staleTime: 1000 * 60 * 5, | ||
| }) | ||
| } | ||
|
|
||
| export function useUpdateProfile() { | ||
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (params: UpdateProfileParams) => updateUserProfile(params), | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: USER_ME_KEY }) | ||
| }, | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: I5-Project/TALKY-OWL
Length of output: 3124
🏁 Script executed:
Repository: I5-Project/TALKY-OWL
Length of output: 2345
쿼리 필터가 부분 손상 유저를 영구 제외하고,
skipDuplicates가 실제로는 작동하지 않습니다.termsAgreements: { none: {} }(line 19)는 약관 레코드가 0개인 유저만 선택합니다. 이미 1개 이상의 부분 손상 상태인 유저는 영구적으로 백필 대상에서 제외되어 데이터 불일치가 발생합니다.또한
createMany(..., skipDuplicates: true)(line 50)는UserTermsAgreement모델에 유니크 제약이 없으므로 실제로 중복을 방지하지 못합니다.skipDuplicates는 유니크 제약에 의존하는데, 현재 스키마에는 그런 제약이 없습니다(userId + termsType 복합 유니크 등이 필요).해결책:
termsAgreements: { some: {} }또는 별도 로직으로 변경하여 부분 손상 유저도 포함UserTermsAgreement스키마에 적절한 유니크 제약 추가 (예:@@unique([userId, termsType]))🤖 Prompt for AI Agents