Skip to content
5 changes: 5 additions & 0 deletions llm-wiki/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@
| 2026-08-16 | 개발 환경 검증 세션 내용을 `raw/2026-08-16-supabase-dev-environment-verification-session-log.md`에 저장 | 로컬 env 전환 및 Playwright 검증 과정, auth.users → public.user 트리거 누락 발견과 수정, db push 스크립트 버그 수정, 웹훅 구성 방식을 Dashboard 방식으로 재전환한 경위 보존 | 운영 마이그레이션 이력 repair와 두 수정 마이그레이션의 운영 반영 미실행, dev Dashboard 웹훅 미생성 |
| 2026-08-16 | `wiki/supabase-schema-change-migration-strategy.md`에 "구조 변경과 환경별 설정을 구분하는 기준" 절 추가 | 테이블/함수/트리거/RLS/cron 같은 구조 변경은 마이그레이션 필수, OAuth Provider·Database Webhook 인증값·Vault 시크릿 같은 환경별 자격증명은 Dashboard가 맞다는 원칙을 실제 사례(트리거 누락, 웹훅 중복 위험) 근거로 정리 | 이 구분을 CI/리뷰 체크리스트에 반영할지 미논의 |
| 2026-08-18 | 랜딩페이지와 검색 관련 코드 조사 후 `output/2026-08-18-faq-landing-page-implementation-plan.md` 작성 | FAQ 섹션, 공용 콘텐츠 원본, 구조화 데이터와 플레이wright 검증 범위 설계 | 최종 문항과 답변, 콘텐츠 목업, 펼침 방식 확인 |
| 2026-08-18 | 설계서 0단계(notification, fcmToken) 구현 후 코드 리뷰 반영 | 401 강제 리다이렉트 제거, 미사용 응답 헬퍼 정리 | `getFCMDeviceToken`의 `catch` 누락은 후속 이슈 6번으로 미룸 |
| 2026-08-18 | 설계서 1단계(nbread, nbreadRecord 10함수) 구현 및 로컬 검증 | Route Handler 6개 추가, curl과 브라우저로 동작 확인 | `getUserNbreads`에 `currentMonth` 쿼리 파라미터를 추가해 설계서 매핑표와 달라짐 |
| 2026-08-18 | 설계서 2단계(participant, invite 10함수) 구현 및 로컬 검증 | Route Handler 6개 추가, 초대 수락까지 브라우저로 확인 | 비로그인 초대 조회는 `nbread_invite` RLS가 authenticated만 허용해 이관 전과 동일하게 막힘 |
| 2026-08-18 | 설계서 3단계(friend, users/search 6함수) 구현 및 로컬 검증 | Route Handler 3개 추가, 친구 검색과 수락/거절을 브라우저로 확인 | `getInviteFriendList`은 호출부가 없어 노출하지 않음. 제거는 후속 이슈 5번 |
| 2026-08-18 | 설계서 4단계(post, chatMessage 6함수) 구현 및 로컬 검증 | Route Handler 3개 추가로 이관 대상 39함수 완료, 게시판과 채팅을 브라우저로 확인 | 게시글 수정·삭제는 `/api/posts/[postId]`로 두어 설계서 경로와 달라짐 |
64 changes: 64 additions & 0 deletions src/app/api/_lib/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { NextResponse } from 'next/server'
import type { SupabaseClient, User } from '@supabase/supabase-js'
import { fail } from './response'
import {
createAnonRouteClient,
createRouteClient,
} from './supabaseRouteClient'

export type AuthResult =
| { ok: true; user: User; client: SupabaseClient }
| { ok: false; response: NextResponse }

const parseBearerToken = (request: Request) => {
const authorization = request.headers.get('authorization')

return authorization?.startsWith('Bearer ')
? authorization.slice('Bearer '.length)
: null
}

/**
* Authorization 헤더의 Bearer 토큰을 검증하고
* 그 토큰을 바인딩한 Supabase 클라이언트를 함께 돌려준다.
*/
export const requireAuth = async (request: Request): Promise<AuthResult> => {
const accessToken = parseBearerToken(request)

if (!accessToken) {
return { ok: false, response: fail(401, '인증 토큰이 없습니다.') }
}

let client: SupabaseClient

try {
client = createRouteClient(accessToken)
} catch {
return {
ok: false,
response: fail(500, 'Supabase 서버 환경 변수가 설정되지 않았습니다.'),
}
}

const {
data: { user },
error,
} = await createAnonRouteClient().auth.getUser(accessToken)

if (error || !user) {
return { ok: false, response: fail(401, '유효하지 않은 인증 토큰입니다.') }
}

return { ok: true, user, client }
}

/**
* 인증이 선택적인 엔드포인트용이다.
* 토큰이 있으면 그 토큰을 바인딩한 클라이언트를, 없으면 anon 클라이언트를 돌려준다.
* 비로그인 상태에서도 열리는 초대 링크 조회에만 쓴다.
*/
export const optionalAuth = (request: Request): SupabaseClient => {
const accessToken = parseBearerToken(request)

return accessToken ? createRouteClient(accessToken) : createAnonRouteClient()
}
13 changes: 13 additions & 0 deletions src/app/api/_lib/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server'

/**
* Route Handler 공통 응답 헬퍼.
* 성공 응답은 항상 { data } 로 감싸고, 실패 응답은 { message, code? } 형태를 쓴다.
*/
export const ok = <T>(data: T, status = 200) =>
NextResponse.json({ data }, { status })

export const noContent = () => new NextResponse(null, { status: 204 })

export const fail = (status: number, message: string, code?: string) =>
NextResponse.json(code ? { message, code } : { message }, { status })
47 changes: 47 additions & 0 deletions src/app/api/_lib/supabaseRouteClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey =
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_KEY

const authOptions = {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
} as const

const requireEnv = () => {
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase 서버 환경 변수가 설정되지 않았습니다.')
}

return { supabaseUrl, supabaseAnonKey }
}

/**
* 사용자 JWT를 바인딩한 Supabase 클라이언트를 만든다.
* anon key를 그대로 쓰므로 RLS는 브라우저에서 직접 호출할 때와 동일하게 적용된다.
*
* 반드시 요청마다 새로 만들어야 한다.
* 모듈 최상위에서 만들어 재사용하면 서버리스 인스턴스가 재사용될 때
* 다른 사용자의 토큰으로 쿼리가 나갈 수 있다.
*/
export const createRouteClient = (accessToken: string) => {
const { supabaseUrl, supabaseAnonKey } = requireEnv()

return createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: `Bearer ${accessToken}` } },
auth: authOptions,
})
}

/**
* 토큰을 바인딩하지 않은 anon 클라이언트를 만든다.
* 인증이 선택적인 엔드포인트와 토큰 검증에만 쓴다.
*/
export const createAnonRouteClient = () => {
const { supabaseUrl, supabaseAnonKey } = requireEnv()

return createClient(supabaseUrl, supabaseAnonKey, { auth: authOptions })
}
31 changes: 31 additions & 0 deletions src/app/api/fcm-tokens/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { requireAuth } from '@/app/api/_lib/requireAuth'
import { fail, noContent } from '@/app/api/_lib/response'
import { upsertFcmToken } from '@/lib/server/fcmToken/upsertFcmToken'
import { captureAppError } from '@/lib/sentry/sentry'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function PUT(request: Request) {
const auth = await requireAuth(request)
if (!auth.ok) return auth.response

const body = (await request.json().catch(() => null)) as {
fcmToken?: unknown
} | null

if (!body || typeof body.fcmToken !== 'string' || !body.fcmToken) {
return fail(400, 'FCM 토큰이 올바르지 않습니다.')
}

try {
await upsertFcmToken(auth.client, auth.user.id, body.fcmToken)
return noContent()
} catch (error) {
captureAppError(error, {
action: 'fcm_token.upsert',
tags: { userId: auth.user.id },
})
return fail(500, 'FCM 토큰 저장에 실패했습니다.')
}
}
83 changes: 83 additions & 0 deletions src/app/api/friends/requests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { requireAuth } from '@/app/api/_lib/requireAuth'
import { fail, noContent, ok } from '@/app/api/_lib/response'
import { sendFriendRequest } from '@/lib/server/friend/sendFriendRequest'
import {
updateAcceptFriend,
updateRejectedFriend,
} from '@/lib/server/friend/updateFriend'
import { captureAppError } from '@/lib/sentry/sentry'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function POST(request: Request) {
const auth = await requireAuth(request)
if (!auth.ok) return auth.response

const body = (await request.json().catch(() => null)) as {
receiverId?: unknown
status?: unknown
} | null

if (
!body ||
typeof body.receiverId !== 'string' ||
!body.receiverId ||
typeof body.status !== 'string'
) {
return fail(400, '친구 요청 값이 올바르지 않습니다.')
}

try {
// 보내는 사람은 항상 토큰의 사용자다.
const result = await sendFriendRequest(
auth.client,
auth.user.id,
body.receiverId,
body.status,
)
return ok(result)
} catch (error) {
captureAppError(error, {
action: 'friend.send_request',
tags: { userId: auth.user.id, receiverId: body.receiverId },
})
return fail(500, '친구 요청을 보내지 못했습니다.')
}
}

export async function PATCH(request: Request) {
const auth = await requireAuth(request)
if (!auth.ok) return auth.response

const body = (await request.json().catch(() => null)) as {
senderId?: unknown
status?: unknown
} | null

// 요청을 받은 쪽이 수락하거나 거절하므로 receiverId는 토큰의 사용자다.
if (
!body ||
typeof body.senderId !== 'string' ||
!body.senderId ||
(body.status !== 'accepted' && body.status !== 'rejected')
) {
return fail(400, '친구 응답 값이 올바르지 않습니다.')
}

try {
if (body.status === 'accepted') {
await updateAcceptFriend(auth.client, auth.user.id, body.senderId)
} else {
await updateRejectedFriend(auth.client, auth.user.id, body.senderId)
}

return noContent()
} catch (error) {
captureAppError(error, {
action: `friend.${body.status}`,
tags: { userId: auth.user.id, senderId: body.senderId },
})
return fail(500, '친구 요청에 응답하지 못했습니다.')
}
}
26 changes: 26 additions & 0 deletions src/app/api/friends/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { requireAuth } from '@/app/api/_lib/requireAuth'
import { fail, ok } from '@/app/api/_lib/response'
import { getFriendList } from '@/lib/server/friend/getSearchFriend'
import { captureAppError } from '@/lib/sentry/sentry'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
const auth = await requireAuth(request)
if (!auth.ok) return auth.response

// nbreadId가 있으면 초대 상태(inviteState)를 함께 붙여 준다.
const nbreadId = new URL(request.url).searchParams.get('nbreadId')

try {
const friends = await getFriendList(auth.client, auth.user.id, nbreadId)
return ok(friends)
} catch (error) {
captureAppError(error, {
action: 'friend.list',
tags: { userId: auth.user.id },
})
return fail(500, '친구 목록을 불러오지 못했습니다.')
}
}
36 changes: 36 additions & 0 deletions src/app/api/invites/[token]/response/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { requireAuth } from '@/app/api/_lib/requireAuth'
import { fail, ok } from '@/app/api/_lib/response'
import { respondToInvite } from '@/lib/server/invite/respondToInvite'
import { captureAppError } from '@/lib/sentry/sentry'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

type RouteContext = { params: Promise<{ token: string }> }

export async function POST(request: Request, context: RouteContext) {
const auth = await requireAuth(request)
if (!auth.ok) return auth.response

const { token } = await context.params
const body = (await request.json().catch(() => null)) as {
response?: unknown
} | null

if (
!body ||
(body.response !== 'accepted' && body.response !== 'rejected')
) {
return fail(400, '초대 응답 값이 올바르지 않습니다.')
}

try {
const result = await respondToInvite(auth.client, token, body.response)
return ok(result)
} catch (error) {
captureAppError(error, {
action: `invite.${body.response}`,
})
return fail(500, '초대에 응답하지 못했습니다.')
}
}
26 changes: 26 additions & 0 deletions src/app/api/invites/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { optionalAuth } from '@/app/api/_lib/requireAuth'
import { fail, ok } from '@/app/api/_lib/response'
import { getInviteByToken } from '@/lib/server/invite/getInviteByToken'
import { captureAppError } from '@/lib/sentry/sentry'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

type RouteContext = { params: Promise<{ token: string }> }

/**
* 초대 링크는 로그인 전에도 열리므로 이 엔드포인트만 인증이 선택이다.
*/
export async function GET(request: Request, context: RouteContext) {
const { token } = await context.params

try {
const invite = await getInviteByToken(optionalAuth(request), token)
return ok(invite)
} catch (error) {
captureAppError(error, {
action: 'invite.get_by_token',
})
return fail(500, '초대 정보를 불러오지 못했습니다.')
}
}
23 changes: 23 additions & 0 deletions src/app/api/invites/pending/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { requireAuth } from '@/app/api/_lib/requireAuth'
import { fail, ok } from '@/app/api/_lib/response'
import { getPendingInvites } from '@/lib/server/invite/getPendingInvites'
import { captureAppError } from '@/lib/sentry/sentry'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
const auth = await requireAuth(request)
if (!auth.ok) return auth.response

try {
const invites = await getPendingInvites(auth.client, auth.user.id)
return ok(invites)
} catch (error) {
captureAppError(error, {
action: 'invite.get_pending',
tags: { userId: auth.user.id },
})
return fail(500, '받은 초대를 불러오지 못했습니다.')
}
}
Loading