Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
62 changes: 62 additions & 0 deletions scripts/backfill-user-terms-agreements.ts
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: {} },
},
Comment on lines +16 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) 백필 필터가 '레코드 0개'만 대상으로 하는지 확인"
rg -n -C3 'termsAgreements:\s*\{\s*none:\s*\{\s*\}\s*\}' scripts/backfill-user-terms-agreements.ts

echo
echo "2) UserTermsAgreement 모델의 유니크 제약 확인"
rg -n -A30 -B2 'model UserTermsAgreement' prisma/schema.prisma
rg -n '@@unique|`@unique`' prisma/schema.prisma

Repository: I5-Project/TALKY-OWL

Length of output: 3124


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Lines 16-50+ of backfill script to see full context ==="
cat -n scripts/backfill-user-terms-agreements.ts | head -60

echo
echo "=== Check for skipDuplicates and createMany usage ==="
rg -n -A5 -B5 'skipDuplicates|createMany|createManyAndReturn' scripts/backfill-user-terms-agreements.ts

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 복합 유니크 등이 필요).

해결책:

  1. 필터를 termsAgreements: { some: {} } 또는 별도 로직으로 변경하여 부분 손상 유저도 포함
  2. UserTermsAgreement 스키마에 적절한 유니크 제약 추가 (예: @@unique([userId, termsType]))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/backfill-user-terms-agreements.ts` around lines 16 - 20, In the
usersWithoutAgreements query filter, change the condition from `termsAgreements:
{ none: {} }` to `termsAgreements: { some: {} }` to include users with existing
(even partially corrupted) agreement records in the backfill scope.
Additionally, add a unique constraint to the UserTermsAgreement schema model
definition such as `@@unique([userId, termsType])` to establish the unique
constraint that the `skipDuplicates: true` parameter in the createMany operation
depends on to prevent duplicate insertions.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

백필 스크립트가 중앙 약관 버전 상수를 우회하고 있습니다.

src/domains/auth/constants.ts에서 버전을 중앙관리하도록 바뀌었는데, 여기서는 INITIAL_TERMS_VERSION/TermsType를 하드코딩하고 있어 로그인 경로와 백필 경로의 버전 계약이 쉽게 분기됩니다.

제안 변경안
-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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/backfill-user-terms-agreements.ts` around lines 13 - 45, The backfill
script is hardcoding the INITIAL_TERMS_VERSION constant and TermsType values in
the main() function instead of importing them from the centralized
src/domains/auth/constants.ts file. Remove the hardcoded INITIAL_TERMS_VERSION
constant declaration at the top of the file and add imports for both
INITIAL_TERMS_VERSION and TermsType from src/domains/auth/constants.ts to ensure
consistency between the backfill script and the authentication login path
version management.

])

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())
50 changes: 0 additions & 50 deletions src/app/api/user/me/route.ts

This file was deleted.

155 changes: 155 additions & 0 deletions src/app/api/users/me/route.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

세션 조회도 try/catch 안에서 처리하세요.

getServerSession(authOptions)가 예외를 던지면 현재 catch를 거치지 않아 ApiResponse 형식 대신 기본 500 응답이 나갈 수 있습니다. GET/PATCH 모두 세션 조회와 userId 검증을 핸들러 try 블록 안으로 옮겨 주세요.

As per coding guidelines, API responses must follow common response structure format; Try-catch error handling must be implemented in API Route Handlers and Server Services.

Also applies to: 66-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/users/me/route.ts` around lines 15 - 17, Move the session
retrieval call to getServerSession(authOptions) and the subsequent
getSessionUserId(session) validation inside the try block of both the GET and
PATCH functions in the route handler. Currently these calls are outside the
try/catch block, which means any exceptions thrown by getServerSession will not
be caught and will result in a default 500 response instead of the properly
formatted ApiResponse structure. Ensure that both functions handle session
initialization and userId extraction within their respective try blocks to
comply with the API response format guidelines.

Source: 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 = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

user type 파일 하나 만들까요? 저도 같은 거 써서 그냥 같이 쓸까봐요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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자로 입력해주세요.' })

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것도 수정해야겠네요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

if (fieldErrors.length > 0) {
return NextResponse.json<ApiResponse>(
{ success: false, error: { code: 'VALIDATION_ERROR', message: '입력값을 확인해주세요.', fieldErrors } },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 지시멘트가 너무 애매해서.. 한글 ㅇㅇ자 문장부호 안된다 이런식으로 작성해야될 것 같아요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 },
)
}
Comment thread
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 },
)
}
}
6 changes: 6 additions & 0 deletions src/domains/auth/constants.ts
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 },
}
37 changes: 34 additions & 3 deletions src/domains/user/hooks.ts
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 })
},
})
}
Loading