From a4ab2c8d036357dca989c89e7819e5b4bc722719 Mon Sep 17 00:00:00 2001 From: junghogil Date: Mon, 17 Aug 2026 22:30:08 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC/FCM=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20Supabase=20=ED=98=B8=EC=B6=9C=EC=9D=84=20app/api=20?= =?UTF-8?q?Route=20Handler=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #170 0단계. 공통 기반을 세우고 notification 6함수와 fcmToken 1함수를 파일럿으로 이관한다. 공통 기반 - src/lib/apiClient.ts: Route Handler 호출 클라이언트. Authorization 헤더 부착, 401 시 세션 갱신 후 1회 재시도, 실패 시 로그아웃 - src/app/api/_lib/supabaseRouteClient.ts: 사용자 JWT를 바인딩한 클라이언트. 다른 사용자 토큰 유출을 막기 위해 요청마다 새로 만든다 - src/app/api/_lib/requireAuth.ts: Bearer 파싱과 토큰 검증 - src/app/api/_lib/response.ts: 응답 헬퍼 서버 쿼리 - src/lib/server/** 함수는 Supabase 클라이언트를 첫 인자로 주입받는다. 이후 SSR 전환 시 서버 컴포넌트가 같은 함수를 그대로 호출할 수 있게 한다 클라이언트 lib - 함수 이름, 인자, 반환 타입을 그대로 두고 내부 구현만 apiClient 호출로 바꿨다. 호출부 8곳은 수정하지 않았다 설계서와 다른 부분 - updateNotificationState가 원시 행 대신 NotificationSettings를 반환한다. API 명세의 camelCase 규약을 따랐고 반환값을 쓰는 호출부는 없다 - 401 재시도에 getSession 대신 refreshSession을 쓴다. getSession은 캐시된 같은 토큰을 돌려줄 수 있어 재시도가 무의미해진다 - auth.ts의 logout은 router를 인자로 받아 재사용할 수 없어 apiClient 안에 로컬 signOut과 리다이렉트로 최소 구현했다 RLS는 그대로 유지한다. 로컬 Supabase에서 인증, RLS 격리, 소유권 404, 설정 기본값 자동 생성, 상태 코드를 확인했다. Co-Authored-By: Claude Opus 5 --- src/app/api/_lib/requireAuth.ts | 53 +++++++ src/app/api/_lib/response.ts | 15 ++ src/app/api/_lib/supabaseRouteClient.ts | 47 ++++++ src/app/api/fcm-tokens/route.ts | 31 ++++ .../notifications/[notificationId]/route.ts | 79 ++++++++++ src/app/api/notifications/route.ts | 40 +++++ src/app/api/notifications/settings/route.ts | 78 ++++++++++ src/lib/apiClient.ts | 141 ++++++++++++++++++ src/lib/fcmToken/upsertFcmToken.ts | 20 +-- src/lib/notification/deleteNotifications.ts | 31 ++-- src/lib/notification/getNotificationState.ts | 75 +--------- src/lib/notification/getNotifications.ts | 34 +---- .../notification/markNotificationAsRead.ts | 23 ++- .../notification/updateNotificationState.ts | 54 ++----- src/lib/server/fcmToken/upsertFcmToken.ts | 20 +++ .../notification/deleteNotifications.ts | 40 +++++ .../notification/getNotificationState.ts | 83 +++++++++++ .../server/notification/getNotifications.ts | 38 +++++ .../notification/markNotificationAsRead.ts | 24 +++ .../notification/updateNotificationState.ts | 55 +++++++ 20 files changed, 792 insertions(+), 189 deletions(-) create mode 100644 src/app/api/_lib/requireAuth.ts create mode 100644 src/app/api/_lib/response.ts create mode 100644 src/app/api/_lib/supabaseRouteClient.ts create mode 100644 src/app/api/fcm-tokens/route.ts create mode 100644 src/app/api/notifications/[notificationId]/route.ts create mode 100644 src/app/api/notifications/route.ts create mode 100644 src/app/api/notifications/settings/route.ts create mode 100644 src/lib/apiClient.ts create mode 100644 src/lib/server/fcmToken/upsertFcmToken.ts create mode 100644 src/lib/server/notification/deleteNotifications.ts create mode 100644 src/lib/server/notification/getNotificationState.ts create mode 100644 src/lib/server/notification/getNotifications.ts create mode 100644 src/lib/server/notification/markNotificationAsRead.ts create mode 100644 src/lib/server/notification/updateNotificationState.ts diff --git a/src/app/api/_lib/requireAuth.ts b/src/app/api/_lib/requireAuth.ts new file mode 100644 index 0000000..d43f568 --- /dev/null +++ b/src/app/api/_lib/requireAuth.ts @@ -0,0 +1,53 @@ +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; accessToken: string } + | { 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 => { + 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, accessToken } +} diff --git a/src/app/api/_lib/response.ts b/src/app/api/_lib/response.ts new file mode 100644 index 0000000..1e141c1 --- /dev/null +++ b/src/app/api/_lib/response.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' + +/** + * Route Handler 공통 응답 헬퍼. + * 성공 응답은 항상 { data } 로 감싸고, 실패 응답은 { message, code? } 형태를 쓴다. + */ +export const ok = (data: T, status = 200) => + NextResponse.json({ data }, { status }) + +export const created = (data: T) => ok(data, 201) + +export const noContent = () => new NextResponse(null, { status: 204 }) + +export const fail = (status: number, message: string, code?: string) => + NextResponse.json(code ? { message, code } : { message }, { status }) diff --git a/src/app/api/_lib/supabaseRouteClient.ts b/src/app/api/_lib/supabaseRouteClient.ts new file mode 100644 index 0000000..f6f5f42 --- /dev/null +++ b/src/app/api/_lib/supabaseRouteClient.ts @@ -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 }) +} diff --git a/src/app/api/fcm-tokens/route.ts b/src/app/api/fcm-tokens/route.ts new file mode 100644 index 0000000..6cf55f9 --- /dev/null +++ b/src/app/api/fcm-tokens/route.ts @@ -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 토큰 저장에 실패했습니다.') + } +} diff --git a/src/app/api/notifications/[notificationId]/route.ts b/src/app/api/notifications/[notificationId]/route.ts new file mode 100644 index 0000000..0f41c88 --- /dev/null +++ b/src/app/api/notifications/[notificationId]/route.ts @@ -0,0 +1,79 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, noContent } from '@/app/api/_lib/response' +import { deleteNotification } from '@/lib/server/notification/deleteNotifications' +import { markNotificationAsRead } from '@/lib/server/notification/markNotificationAsRead' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ notificationId: string }> } + +const NOT_FOUND_MESSAGE = '알림을 찾을 수 없거나 권한이 없습니다.' + +const parseNotificationId = async (context: RouteContext) => { + const { notificationId } = await context.params + const parsed = Number(notificationId) + + return Number.isInteger(parsed) ? parsed : null +} + +export async function PATCH(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const notificationId = await parseNotificationId(context) + if (notificationId === null) { + return fail(400, '알림 식별자가 올바르지 않습니다.') + } + + try { + const updatedCount = await markNotificationAsRead( + auth.client, + notificationId, + auth.user.id, + ) + + if (updatedCount !== 1) { + return fail(404, NOT_FOUND_MESSAGE) + } + + return noContent() + } catch (error) { + captureAppError(error, { + action: 'notification.mark_as_read', + tags: { userId: auth.user.id }, + }) + return fail(500, '알림을 읽음 처리하지 못했습니다.') + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const notificationId = await parseNotificationId(context) + if (notificationId === null) { + return fail(400, '알림 식별자가 올바르지 않습니다.') + } + + try { + const deletedCount = await deleteNotification( + auth.client, + notificationId, + auth.user.id, + ) + + if (deletedCount !== 1) { + return fail(404, NOT_FOUND_MESSAGE) + } + + return noContent() + } catch (error) { + captureAppError(error, { + action: 'notification.delete', + tags: { userId: auth.user.id }, + }) + return fail(500, '알림을 삭제하지 못했습니다.') + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..3121167 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,40 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, noContent, ok } from '@/app/api/_lib/response' +import { getNotification } from '@/lib/server/notification/getNotifications' +import { deleteAllNotifications } from '@/lib/server/notification/deleteNotifications' +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 notifications = await getNotification(auth.client, auth.user.id) + return ok(notifications) + } catch (error) { + captureAppError(error, { + action: 'notification.list', + tags: { userId: auth.user.id }, + }) + return fail(500, '알림을 불러오지 못했습니다.') + } +} + +export async function DELETE(request: Request) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + try { + await deleteAllNotifications(auth.client, auth.user.id) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'notification.delete_all', + tags: { userId: auth.user.id }, + }) + return fail(500, '알림을 삭제하지 못했습니다.') + } +} diff --git a/src/app/api/notifications/settings/route.ts b/src/app/api/notifications/settings/route.ts new file mode 100644 index 0000000..a9437fa --- /dev/null +++ b/src/app/api/notifications/settings/route.ts @@ -0,0 +1,78 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getNotificationState } from '@/lib/server/notification/getNotificationState' +import { + updateNotificationState, + type NotificationSettingsUpdate, +} from '@/lib/server/notification/updateNotificationState' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const BOOLEAN_FIELDS = [ + 'allEnabled', + 'chatEnabled', + 'inviteEnabled', + 'friendEnabled', + 'paymentEnabled', +] as const + +const parseSettings = (body: unknown): NotificationSettingsUpdate | null => { + if (typeof body !== 'object' || body === null) return null + + const source = body as Record + const settings: NotificationSettingsUpdate = {} + + for (const field of BOOLEAN_FIELDS) { + const value = source[field] + if (value === undefined) continue + if (typeof value !== 'boolean') return null + settings[field] = value + } + + return settings +} + +export async function GET(request: Request) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + try { + const settings = await getNotificationState(auth.client, auth.user.id) + return ok(settings) + } catch (error) { + captureAppError(error, { + action: 'notification.get_settings', + tags: { userId: auth.user.id }, + }) + 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) + const settings = parseSettings(body) + + if (!settings) { + return fail(400, '알림 설정 값이 올바르지 않습니다.') + } + + try { + const updated = await updateNotificationState( + auth.client, + auth.user.id, + settings, + ) + return ok(updated) + } catch (error) { + captureAppError(error, { + action: 'notification.update_settings', + tags: { userId: auth.user.id }, + }) + return fail(500, '알림 설정을 변경하지 못했습니다.') + } +} diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts new file mode 100644 index 0000000..ad18a0c --- /dev/null +++ b/src/lib/apiClient.ts @@ -0,0 +1,141 @@ +'use client' // 브라우저에서 app/api를 호출하는 전용 클라이언트 + +import { supabase } from '@/lib/supabaseClient' +import useUserStore from '@/stores/useAuthStore' +import { clearLegacyAuthStorage } from '@/lib/authStorage' + +export class ApiError extends Error { + readonly status: number + readonly code?: string + + constructor(status: number, message: string, code?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + } +} + +export type QueryValue = string | number | boolean | undefined | null + +export interface ApiRequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' + body?: unknown + query?: Record + /** 인증이 선택적인 엔드포인트에서만 false로 둔다. */ + auth?: boolean +} + +const buildUrl = (path: string, query?: Record) => { + if (!query) return path + + const searchParams = new URLSearchParams() + + Object.entries(query).forEach(([key, value]) => { + if (value === undefined || value === null) return + searchParams.set(key, String(value)) + }) + + const queryString = searchParams.toString() + return queryString ? `${path}?${queryString}` : path +} + +const getAccessToken = async () => { + const { data } = await supabase.auth.getSession() + return data.session?.access_token ?? null +} + +/** + * 재시도까지 실패한 401은 세션이 살아날 수 없는 상태로 본다. + * 로컬 세션과 스토어를 비우고 로그인 화면으로 돌려보낸다. + */ +const forceLogout = async () => { + await supabase.auth.signOut({ scope: 'local' }).catch(() => {}) + useUserStore.getState().clearUser() + clearLegacyAuthStorage() + + if (typeof window !== 'undefined') { + window.location.replace('/login') + } +} + +const parseErrorMessage = async (response: Response) => { + const result = (await response.json().catch(() => null)) as { + message?: string + code?: string + } | null + + return { + message: result?.message ?? `요청에 실패했습니다. (${response.status})`, + code: result?.code, + } +} + +const send = async ( + url: string, + method: string, + body: unknown, + accessToken: string | null, +) => { + const headers: Record = {} + + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + } + + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + + return fetch(url, { + method, + headers, + cache: 'no-store', + ...(body !== undefined && { body: JSON.stringify(body) }), + }) +} + +/** + * app/api Route Handler를 호출한다. + * 성공 응답의 { data }를 벗겨서 돌려주고, 204는 undefined를 돌려준다. + * 실패하면 ApiError를 던진다. + */ +export const apiRequest = async ( + path: string, + options: ApiRequestOptions = {}, +): Promise => { + const { method = 'GET', body, query, auth = true } = options + const url = buildUrl(path, query) + + let accessToken = auth ? await getAccessToken() : null + let response = await send(url, method, body, accessToken) + + // 토큰 만료로 401을 받으면 세션을 갱신해 1회만 재시도한다. + if (response.status === 401 && auth) { + const { data } = await supabase.auth.refreshSession() + const refreshedToken = data.session?.access_token ?? null + + if (refreshedToken && refreshedToken !== accessToken) { + accessToken = refreshedToken + response = await send(url, method, body, accessToken) + } + + if (response.status === 401) { + await forceLogout() + const { message, code } = await parseErrorMessage(response) + throw new ApiError(401, message, code) + } + } + + if (!response.ok) { + const { message, code } = await parseErrorMessage(response) + throw new ApiError(response.status, message, code) + } + + if (response.status === 204) { + return undefined as T + } + + const result = (await response.json()) as { data: T } + return result.data +} diff --git a/src/lib/fcmToken/upsertFcmToken.ts b/src/lib/fcmToken/upsertFcmToken.ts index 290aaa0..9803a73 100644 --- a/src/lib/fcmToken/upsertFcmToken.ts +++ b/src/lib/fcmToken/upsertFcmToken.ts @@ -1,22 +1,14 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' export const upsertFcmToken = async (userId: string, fcmToken: string) => { try { - const { data, error } = await supabase.from('fcm_token').upsert( - { - user_id: userId, - fcm_token: fcmToken, - }, - { onConflict: 'fcm_token' }, - ) - - if (error) { - console.error('Error upserting fcm token:', error) - throw error - } + await apiRequest('/api/fcm-tokens', { + method: 'PUT', + body: { fcmToken }, + }) - return data + return null } catch (error) { captureAppError(error, { action: 'fcm_token.upsert', diff --git a/src/lib/notification/deleteNotifications.ts b/src/lib/notification/deleteNotifications.ts index c9069c5..7c50897 100644 --- a/src/lib/notification/deleteNotifications.ts +++ b/src/lib/notification/deleteNotifications.ts @@ -1,33 +1,28 @@ -import { supabase } from '@/lib/supabaseClient' +import { ApiError, apiRequest } from '@/lib/apiClient' export const deleteNotification = async ( notificationId: number, userId: string, ) => { - const { data, error } = await supabase - .from('notification') - .delete() - .eq('id', notificationId) - .eq('user_id', userId) - .select('id') + try { + await apiRequest(`/api/notifications/${notificationId}`, { + method: 'DELETE', + }) + } catch (error) { + // 대상이 없거나 소유자가 아닌 경우의 기존 오류를 그대로 유지한다. + if (error instanceof ApiError && error.status === 404) { + throw new Error('Notification not found or not owned by the current user') + } - if (error) { console.error('Error deleting notification:', error) throw error } - - if (data.length !== 1) { - throw new Error('Notification not found or not owned by the current user') - } } export const deleteAllNotifications = async (userId: string) => { - const { error } = await supabase - .from('notification') - .delete() - .eq('user_id', userId) - - if (error) { + try { + await apiRequest('/api/notifications', { method: 'DELETE' }) + } catch (error) { console.error('Error deleting notifications:', error) throw error } diff --git a/src/lib/notification/getNotificationState.ts b/src/lib/notification/getNotificationState.ts index fc47ac6..50edbe6 100644 --- a/src/lib/notification/getNotificationState.ts +++ b/src/lib/notification/getNotificationState.ts @@ -1,78 +1,11 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' +import type { NotificationSettings } from '@/lib/server/notification/getNotificationState' -export interface NotificationSettings { - userId: string - allEnabled: boolean - chatEnabled: boolean - inviteEnabled: boolean - friendEnabled: boolean - paymentEnabled: boolean -} - -interface NotificationSettingsRow { - user_id: string - all_enabled: boolean - chat_enabled: boolean - invite_enabled: boolean - friend_enabled: boolean - payment_enabled: boolean -} - -const mapNotificationSettings = ( - row: NotificationSettingsRow, -): NotificationSettings => ({ - userId: row.user_id, - allEnabled: row.all_enabled, - chatEnabled: row.chat_enabled, - inviteEnabled: row.invite_enabled, - friendEnabled: row.friend_enabled, - paymentEnabled: row.payment_enabled, -}) - -const createDefaultNotificationSettings = async (userId: string) => { - const { data, error } = await supabase - .from('user_notification_settings') - .insert({ - user_id: userId, - all_enabled: true, - chat_enabled: true, - invite_enabled: true, - friend_enabled: true, - payment_enabled: true, - }) - .select( - 'user_id, all_enabled, chat_enabled, invite_enabled, friend_enabled, payment_enabled', - ) - .single() - - if (error) { - console.error('Error creating notification settings:', error) - throw error - } - - return mapNotificationSettings(data as NotificationSettingsRow) -} +export type { NotificationSettings } export const getNotificationState = async (userId: string) => { try { - const { data, error } = await supabase - .from('user_notification_settings') - .select( - 'user_id, all_enabled, chat_enabled, invite_enabled, friend_enabled, payment_enabled', - ) - .eq('user_id', userId) - .maybeSingle() - - if (error) { - console.error('Error get notification state:', error) - throw error - } - - if (!data) { - return createDefaultNotificationSettings(userId) - } - - return mapNotificationSettings(data as NotificationSettingsRow) + return await apiRequest('/api/notifications/settings') } catch (error) { console.error('Error fetching notification state:', error) throw error diff --git a/src/lib/notification/getNotifications.ts b/src/lib/notification/getNotifications.ts index 5a63ea0..b30ebde 100644 --- a/src/lib/notification/getNotifications.ts +++ b/src/lib/notification/getNotifications.ts @@ -1,39 +1,11 @@ -import { supabase } from '@/lib/supabaseClient' -import { Notification, NotificationType } from '@/types/notification' -import { NotificationRow } from '@/types/supabase' -import { PostgrestError } from '@supabase/supabase-js' +import { apiRequest } from '@/lib/apiClient' +import { Notification } from '@/types/notification' import { sortNotifications } from './sortNotifications' -type GetNotificationType = { - data: NotificationRow[] | null - error: PostgrestError | null -} - export const getNotification = async (userId: string) => { try { - const { data, error }: GetNotificationType = await supabase - .from('notification') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }) - - if (error || !data) { - console.error('Error get notifications:', error) - throw error - } + const notifications = await apiRequest('/api/notifications') - const notifications: Notification[] = (data as NotificationRow[])?.map( - (notification) => ({ - id: notification.id, - user_id: notification.user_id!, - created_at: notification.created_at, - title: notification.title, - message: notification.message, - data: notification.data, - type: notification.type as NotificationType, - is_read: notification.is_read, - }), - ) return sortNotifications(notifications) } catch (error) { console.error('Error fetching notifications:', error) diff --git a/src/lib/notification/markNotificationAsRead.ts b/src/lib/notification/markNotificationAsRead.ts index a8cd034..f7c90e9 100644 --- a/src/lib/notification/markNotificationAsRead.ts +++ b/src/lib/notification/markNotificationAsRead.ts @@ -1,21 +1,20 @@ -import { supabase } from '@/lib/supabaseClient' +import { ApiError, apiRequest } from '@/lib/apiClient' export const markNotificationAsRead = async ( notificationId: number, userId: string, ) => { - const { data, error } = await supabase - .from('notification') - .update({ is_read: true }) - .eq('id', notificationId) - .eq('user_id', userId) - .select('id') + try { + await apiRequest(`/api/notifications/${notificationId}`, { + method: 'PATCH', + body: { isRead: true }, + }) + } catch (error) { + // 대상이 없거나 소유자가 아닌 경우의 기존 오류를 그대로 유지한다. + if (error instanceof ApiError && error.status === 404) { + throw new Error('Notification not found or not owned by the current user') + } - if (error) { throw error } - - if (data.length !== 1) { - throw new Error('Notification not found or not owned by the current user') - } } diff --git a/src/lib/notification/updateNotificationState.ts b/src/lib/notification/updateNotificationState.ts index e269bc2..1752c23 100644 --- a/src/lib/notification/updateNotificationState.ts +++ b/src/lib/notification/updateNotificationState.ts @@ -1,53 +1,21 @@ -import { supabase } from '@/lib/supabaseClient' -import type { NotificationSettings } from './getNotificationState' +import { apiRequest } from '@/lib/apiClient' +import type { NotificationSettings } from '@/lib/server/notification/getNotificationState' +import type { NotificationSettingsUpdate } from '@/lib/server/notification/updateNotificationState' -export type NotificationSettingsUpdate = Partial< - Omit -> - -const mapNotificationSettingsUpdate = ( - settings: NotificationSettingsUpdate, -) => ({ - ...(settings.allEnabled !== undefined && { - all_enabled: settings.allEnabled, - }), - ...(settings.chatEnabled !== undefined && { - chat_enabled: settings.chatEnabled, - }), - ...(settings.inviteEnabled !== undefined && { - invite_enabled: settings.inviteEnabled, - }), - ...(settings.friendEnabled !== undefined && { - friend_enabled: settings.friendEnabled, - }), - ...(settings.paymentEnabled !== undefined && { - payment_enabled: settings.paymentEnabled, - }), -}) +export type { NotificationSettingsUpdate } export async function updateNotificationState( userId: string, settings: NotificationSettingsUpdate, ) { try { - const { data, error } = await supabase - .from('user_notification_settings') - .upsert( - { - user_id: userId, - ...mapNotificationSettingsUpdate(settings), - }, - { onConflict: 'user_id' }, - ) - .select() - .single() - - if (error) { - console.error('Error updating notification state:', error) - throw error - } - - return data + return await apiRequest( + '/api/notifications/settings', + { + method: 'PATCH', + body: settings, + }, + ) } catch (error) { console.error('Error updating notification state:', error) throw error diff --git a/src/lib/server/fcmToken/upsertFcmToken.ts b/src/lib/server/fcmToken/upsertFcmToken.ts new file mode 100644 index 0000000..065d61a --- /dev/null +++ b/src/lib/server/fcmToken/upsertFcmToken.ts @@ -0,0 +1,20 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const upsertFcmToken = async ( + client: SupabaseClient, + userId: string, + fcmToken: string, +) => { + const { error } = await client.from('fcm_token').upsert( + { + user_id: userId, + fcm_token: fcmToken, + }, + { onConflict: 'fcm_token' }, + ) + + if (error) { + console.error('Error upserting fcm token:', error) + throw error + } +} diff --git a/src/lib/server/notification/deleteNotifications.ts b/src/lib/server/notification/deleteNotifications.ts new file mode 100644 index 0000000..3f55793 --- /dev/null +++ b/src/lib/server/notification/deleteNotifications.ts @@ -0,0 +1,40 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * 삭제된 행 수를 돌려준다. + * 1이 아닌 경우의 처리(404 매핑)는 Route Handler가 담당한다. + */ +export const deleteNotification = async ( + client: SupabaseClient, + notificationId: number, + userId: string, +): Promise => { + const { data, error } = await client + .from('notification') + .delete() + .eq('id', notificationId) + .eq('user_id', userId) + .select('id') + + if (error) { + console.error('Error deleting notification:', error) + throw error + } + + return data?.length ?? 0 +} + +export const deleteAllNotifications = async ( + client: SupabaseClient, + userId: string, +) => { + const { error } = await client + .from('notification') + .delete() + .eq('user_id', userId) + + if (error) { + console.error('Error deleting notifications:', error) + throw error + } +} diff --git a/src/lib/server/notification/getNotificationState.ts b/src/lib/server/notification/getNotificationState.ts new file mode 100644 index 0000000..015e8f4 --- /dev/null +++ b/src/lib/server/notification/getNotificationState.ts @@ -0,0 +1,83 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface NotificationSettings { + userId: string + allEnabled: boolean + chatEnabled: boolean + inviteEnabled: boolean + friendEnabled: boolean + paymentEnabled: boolean +} + +interface NotificationSettingsRow { + user_id: string + all_enabled: boolean + chat_enabled: boolean + invite_enabled: boolean + friend_enabled: boolean + payment_enabled: boolean +} + +export const NOTIFICATION_SETTINGS_COLUMNS = + 'user_id, all_enabled, chat_enabled, invite_enabled, friend_enabled, payment_enabled' + +export const mapNotificationSettings = ( + row: NotificationSettingsRow, +): NotificationSettings => ({ + userId: row.user_id, + allEnabled: row.all_enabled, + chatEnabled: row.chat_enabled, + inviteEnabled: row.invite_enabled, + friendEnabled: row.friend_enabled, + paymentEnabled: row.payment_enabled, +}) + +const createDefaultNotificationSettings = async ( + client: SupabaseClient, + userId: string, +) => { + const { data, error } = await client + .from('user_notification_settings') + .insert({ + user_id: userId, + all_enabled: true, + chat_enabled: true, + invite_enabled: true, + friend_enabled: true, + payment_enabled: true, + }) + .select(NOTIFICATION_SETTINGS_COLUMNS) + .single() + + if (error) { + console.error('Error creating notification settings:', error) + throw error + } + + return mapNotificationSettings(data as NotificationSettingsRow) +} + +/** + * 설정 행이 없으면 기본값을 생성해 돌려주는 기존 동작을 그대로 유지한다. + */ +export const getNotificationState = async ( + client: SupabaseClient, + userId: string, +): Promise => { + const { data, error } = await client + .from('user_notification_settings') + .select(NOTIFICATION_SETTINGS_COLUMNS) + .eq('user_id', userId) + .maybeSingle() + + if (error) { + console.error('Error get notification state:', error) + throw error + } + + if (!data) { + return createDefaultNotificationSettings(client, userId) + } + + return mapNotificationSettings(data as NotificationSettingsRow) +} diff --git a/src/lib/server/notification/getNotifications.ts b/src/lib/server/notification/getNotifications.ts new file mode 100644 index 0000000..815c886 --- /dev/null +++ b/src/lib/server/notification/getNotifications.ts @@ -0,0 +1,38 @@ +import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js' +import { Notification, NotificationType } from '@/types/notification' +import { NotificationRow } from '@/types/supabase' + +type GetNotificationType = { + data: NotificationRow[] | null + error: PostgrestError | null +} + +/** + * 정렬(sortNotifications)은 순수 함수이므로 클라이언트에 남긴다. + */ +export const getNotification = async ( + client: SupabaseClient, + userId: string, +): Promise => { + const { data, error }: GetNotificationType = await client + .from('notification') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + + if (error || !data) { + console.error('Error get notifications:', error) + throw error ?? new Error('Error get notifications') + } + + return (data as NotificationRow[]).map((notification) => ({ + id: notification.id, + user_id: notification.user_id!, + created_at: notification.created_at, + title: notification.title, + message: notification.message, + data: notification.data, + type: notification.type as NotificationType, + is_read: notification.is_read, + })) +} diff --git a/src/lib/server/notification/markNotificationAsRead.ts b/src/lib/server/notification/markNotificationAsRead.ts new file mode 100644 index 0000000..bbe19eb --- /dev/null +++ b/src/lib/server/notification/markNotificationAsRead.ts @@ -0,0 +1,24 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * 갱신된 행 수를 돌려준다. + * 1이 아닌 경우의 처리(404 매핑)는 Route Handler가 담당한다. + */ +export const markNotificationAsRead = async ( + client: SupabaseClient, + notificationId: number, + userId: string, +): Promise => { + const { data, error } = await client + .from('notification') + .update({ is_read: true }) + .eq('id', notificationId) + .eq('user_id', userId) + .select('id') + + if (error) { + throw error + } + + return data?.length ?? 0 +} diff --git a/src/lib/server/notification/updateNotificationState.ts b/src/lib/server/notification/updateNotificationState.ts new file mode 100644 index 0000000..6cf8b64 --- /dev/null +++ b/src/lib/server/notification/updateNotificationState.ts @@ -0,0 +1,55 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { + mapNotificationSettings, + NOTIFICATION_SETTINGS_COLUMNS, + type NotificationSettings, +} from './getNotificationState' + +export type NotificationSettingsUpdate = Partial< + Omit +> + +const mapNotificationSettingsUpdate = ( + settings: NotificationSettingsUpdate, +) => ({ + ...(settings.allEnabled !== undefined && { + all_enabled: settings.allEnabled, + }), + ...(settings.chatEnabled !== undefined && { + chat_enabled: settings.chatEnabled, + }), + ...(settings.inviteEnabled !== undefined && { + invite_enabled: settings.inviteEnabled, + }), + ...(settings.friendEnabled !== undefined && { + friend_enabled: settings.friendEnabled, + }), + ...(settings.paymentEnabled !== undefined && { + payment_enabled: settings.paymentEnabled, + }), +}) + +export const updateNotificationState = async ( + client: SupabaseClient, + userId: string, + settings: NotificationSettingsUpdate, +): Promise => { + const { data, error } = await client + .from('user_notification_settings') + .upsert( + { + user_id: userId, + ...mapNotificationSettingsUpdate(settings), + }, + { onConflict: 'user_id' }, + ) + .select(NOTIFICATION_SETTINGS_COLUMNS) + .single() + + if (error) { + console.error('Error updating notification state:', error) + throw error + } + + return mapNotificationSettings(data) +} From f51852689db74bc7049a5eea5887543fd9666011 Mon Sep 17 00:00:00 2001 From: junghogil Date: Tue, 18 Aug 2026 07:45:25 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20401=20=EC=B2=98=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=95=EC=A0=9C=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=97=AC=ED=8D=BC=EB=A5=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 리뷰 지적 사항 중 두 건을 반영한다. 401 강제 리다이렉트 제거 - useUserStore는 localStorage에 영속되고 protectRoute는 살아있는 세션이 아니라 hasPersistedUser()만 본다. 그래서 refresh token이 만료된 상태로 앱을 열면 화면은 정상 렌더되고, Header의 배경 요청이 401을 받아 window.location.replace('/login')을 실행했다. - 배경 요청 하나가 실패했다고 사용자를 페이지 밖으로 밀어내는 동작이고, clearUser() 이후 protectRoute는 '/'로 보내려 해 리다이렉트가 경쟁했다. - forceLogout을 clearDeadSession으로 바꾸고 화면 이동을 뺐다. 세션 정리는 apiClient가, 이동 판단은 기존대로 protectRoute가 담당한다. 미사용 코드 정리 - response.ts의 created(), AuthResult의 accessToken을 지웠다. 이후 단계가 계속 복제할 기반 파일이라 지금 걷어낸다. 브라우저에서 확인했다. 토큰을 만료시킨 뒤 /notification에 진입하면 401과 갱신 실패를 거쳐 ApiError가 호출부로 전파되고, 경로는 유지되며 세션과 스토어만 정리된다. 이후 이동 시 protectRoute가 '/'로 보낸다. Co-Authored-By: Claude Opus 5 --- src/app/api/_lib/requireAuth.ts | 4 ++-- src/app/api/_lib/response.ts | 2 -- src/lib/apiClient.ts | 14 +++++++------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/app/api/_lib/requireAuth.ts b/src/app/api/_lib/requireAuth.ts index d43f568..9658e02 100644 --- a/src/app/api/_lib/requireAuth.ts +++ b/src/app/api/_lib/requireAuth.ts @@ -7,7 +7,7 @@ import { } from './supabaseRouteClient' export type AuthResult = - | { ok: true; user: User; client: SupabaseClient; accessToken: string } + | { ok: true; user: User; client: SupabaseClient } | { ok: false; response: NextResponse } const parseBearerToken = (request: Request) => { @@ -49,5 +49,5 @@ export const requireAuth = async (request: Request): Promise => { return { ok: false, response: fail(401, '유효하지 않은 인증 토큰입니다.') } } - return { ok: true, user, client, accessToken } + return { ok: true, user, client } } diff --git a/src/app/api/_lib/response.ts b/src/app/api/_lib/response.ts index 1e141c1..c8ade01 100644 --- a/src/app/api/_lib/response.ts +++ b/src/app/api/_lib/response.ts @@ -7,8 +7,6 @@ import { NextResponse } from 'next/server' export const ok = (data: T, status = 200) => NextResponse.json({ data }, { status }) -export const created = (data: T) => ok(data, 201) - export const noContent = () => new NextResponse(null, { status: 204 }) export const fail = (status: number, message: string, code?: string) => diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index ad18a0c..f67b6d4 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -47,16 +47,16 @@ const getAccessToken = async () => { /** * 재시도까지 실패한 401은 세션이 살아날 수 없는 상태로 본다. - * 로컬 세션과 스토어를 비우고 로그인 화면으로 돌려보낸다. + * 로컬 세션과 스토어만 비우고 화면 이동은 하지 않는다. + * + * 이동까지 여기서 처리하면 protectRoute의 판정과 경쟁한다. + * 배경 요청 하나가 실패했다고 사용자를 페이지 밖으로 밀어내지 않도록, + * 어디로 보낼지는 기존대로 protectRoute가 결정하게 둔다. */ -const forceLogout = async () => { +const clearDeadSession = async () => { await supabase.auth.signOut({ scope: 'local' }).catch(() => {}) useUserStore.getState().clearUser() clearLegacyAuthStorage() - - if (typeof window !== 'undefined') { - window.location.replace('/login') - } } const parseErrorMessage = async (response: Response) => { @@ -121,7 +121,7 @@ export const apiRequest = async ( } if (response.status === 401) { - await forceLogout() + await clearDeadSession() const { message, code } = await parseErrorMessage(response) throw new ApiError(401, message, code) } From 972cb05bdc20a2e41cd4d6f87860c9afde9bad38 Mon Sep 17 00:00:00 2001 From: junghogil Date: Tue, 18 Aug 2026 08:19:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=97=94=EB=B9=B5/=EB=82=A9?= =?UTF-8?q?=EB=B6=80=EA=B8=B0=EB=A1=9D=20Supabase=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20app/api=20Route=20Handler=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #170 1단계. nbread 8함수와 nbreadRecord 2함수를 옮긴다. Route Handler 6개 - GET/POST /api/nbreads - GET /api/nbreads/summary - GET /api/nbreads/records - GET/PATCH/DELETE /api/nbreads/[nbreadId] - GET/PATCH /api/nbreads/[nbreadId]/records - POST /api/nbreads/[nbreadId]/invites/link 0단계에서 만든 requireAuth, createRouteClient, 응답 헬퍼를 그대로 쓴다. 클라이언트 lib은 시그니처를 유지해 호출부 16곳을 수정하지 않았다. 설계서의 개별 처리 항목 - fetchNbreadData의 Date 변환은 클라이언트 lib에 남겼다. 서버는 원시 문자열을 준다 - createLinkInvite는 서버가 invite_token만 주고 URL 조립은 클라이언트가 한다 - getUserNbreads의 paidCount N+1과 실패 시 빈 배열 반환은 그대로 옮겼다 설계서와 다른 부분 - getUserNbreads에 currentMonth 쿼리 파라미터를 추가했다. new Date().getMonth()를 서버로 옮기면 배포 서버 시간대(UTC) 기준이 되어, 한국 시간 매월 1일 0시부터 9시 사이에 연간 결제 엔빵이 이번 달 목록에서 빠지는 회귀가 생긴다. 현지 시간 판정을 유지하려고 클라이언트가 계산해 넘긴다 이관 중 확인한 후속 과제 - NbreadDetail이 같은 조건의 getNbreadRecords를 참여자 수만큼 반복한다. 상세 화면 한 번을 여는 데 동일 조회가 7회 나간다. 설계서 반영은 새 버전 문서로 따로 정리한다 로컬 Supabase에서 확인했다. 월 필터, paidCount와 DB 일치, 비참여자 격리, 생성부터 삭제까지의 왕복, 납부 상태 변경, 입력 검증 400을 확인했고 브라우저에서 /home, /calendar, /nbread/[id] 렌더를 확인했다. Co-Authored-By: Claude Opus 5 --- llm-wiki/log.md | 2 + .../nbreads/[nbreadId]/invites/link/route.ts | 27 +++++ .../api/nbreads/[nbreadId]/records/route.ts | 90 ++++++++++++++++ src/app/api/nbreads/[nbreadId]/route.ts | 71 +++++++++++++ src/app/api/nbreads/records/route.ts | 23 ++++ src/app/api/nbreads/route.ts | 60 +++++++++++ src/app/api/nbreads/summary/route.ts | 26 +++++ src/lib/nbread/deleteNbread.ts | 14 +-- src/lib/nbread/fetchNbreadData.ts | 37 ++++--- src/lib/nbread/getNbread.ts | 34 +----- src/lib/nbread/getUserNbread.ts | 87 ++------------- src/lib/nbread/getUserTotalNbreadAmount.ts | 50 +++------ src/lib/nbread/insertLink.ts | 24 ++--- src/lib/nbread/insertNbread.ts | 26 ++--- src/lib/nbread/updateNbread.ts | 25 ++--- src/lib/nbreadRecord/getNbreadRecords.ts | 36 ++----- src/lib/nbreadRecord/updateNbreadRecord.ts | 26 ++--- src/lib/server/nbread/createLinkInvite.ts | 28 +++++ src/lib/server/nbread/deleteNbread.ts | 13 +++ src/lib/server/nbread/fetchNbreadData.ts | 32 ++++++ src/lib/server/nbread/getNbread.ts | 36 +++++++ src/lib/server/nbread/getUserNbreads.ts | 100 ++++++++++++++++++ .../server/nbread/getUserTotalNbreadAmount.ts | 42 ++++++++ src/lib/server/nbread/insertNbread.ts | 28 +++++ src/lib/server/nbread/updateNbread.ts | 26 +++++ .../server/nbreadRecord/getNbreadRecords.ts | 30 ++++++ .../server/nbreadRecord/updateNbreadRecord.ts | 23 ++++ 27 files changed, 743 insertions(+), 273 deletions(-) create mode 100644 src/app/api/nbreads/[nbreadId]/invites/link/route.ts create mode 100644 src/app/api/nbreads/[nbreadId]/records/route.ts create mode 100644 src/app/api/nbreads/[nbreadId]/route.ts create mode 100644 src/app/api/nbreads/records/route.ts create mode 100644 src/app/api/nbreads/route.ts create mode 100644 src/app/api/nbreads/summary/route.ts create mode 100644 src/lib/server/nbread/createLinkInvite.ts create mode 100644 src/lib/server/nbread/deleteNbread.ts create mode 100644 src/lib/server/nbread/fetchNbreadData.ts create mode 100644 src/lib/server/nbread/getNbread.ts create mode 100644 src/lib/server/nbread/getUserNbreads.ts create mode 100644 src/lib/server/nbread/getUserTotalNbreadAmount.ts create mode 100644 src/lib/server/nbread/insertNbread.ts create mode 100644 src/lib/server/nbread/updateNbread.ts create mode 100644 src/lib/server/nbreadRecord/getNbreadRecords.ts create mode 100644 src/lib/server/nbreadRecord/updateNbreadRecord.ts diff --git a/llm-wiki/log.md b/llm-wiki/log.md index 6465457..3eed36e 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -19,3 +19,5 @@ | 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` 쿼리 파라미터를 추가해 설계서 매핑표와 달라짐 | diff --git a/src/app/api/nbreads/[nbreadId]/invites/link/route.ts b/src/app/api/nbreads/[nbreadId]/invites/link/route.ts new file mode 100644 index 0000000..d90a5ff --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/invites/link/route.ts @@ -0,0 +1,27 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { createLinkInvite } from '@/lib/server/nbread/createLinkInvite' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function POST(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + + try { + const inviteToken = await createLinkInvite(auth.client, nbreadId) + return ok({ inviteToken }, 201) + } catch (error) { + captureAppError(error, { + action: 'invite.create_link', + tags: { nbreadId }, + }) + return fail(500, '초대 링크를 만들지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/[nbreadId]/records/route.ts b/src/app/api/nbreads/[nbreadId]/records/route.ts new file mode 100644 index 0000000..1e72361 --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/records/route.ts @@ -0,0 +1,90 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, noContent, ok } from '@/app/api/_lib/response' +import { getNbreadRecords } from '@/lib/server/nbreadRecord/getNbreadRecords' +import { updateNbreadRecord } from '@/lib/server/nbreadRecord/updateNbreadRecord' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +/** 기존 lib이 하던 new Date(startDate).toISOString().split('T')[0] 변환을 그대로 옮긴다. */ +const toPaymentDate = (value: string | null) => { + if (!value) return null + + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) + ? null + : parsed.toISOString().split('T')[0] +} + +export async function GET(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const paymentDate = toPaymentDate( + new URL(request.url).searchParams.get('startDate'), + ) + + if (!paymentDate) { + return fail(400, '조회 기준일이 올바르지 않습니다.') + } + + try { + const records = await getNbreadRecords(auth.client, nbreadId, paymentDate) + return ok(records) + } catch (error) { + captureAppError(error, { + action: 'nbread_record.list', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '납부 현황을 불러오지 못했습니다.') + } +} + +export async function PATCH(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const body = (await request.json().catch(() => null)) as { + userId?: unknown + isPaid?: unknown + startDate?: unknown + } | null + + // 다른 참여자의 납부 상태를 바꾸는 기능이라 userId를 본문으로 받는다. + if ( + !body || + typeof body.userId !== 'string' || + typeof body.isPaid !== 'boolean' || + typeof body.startDate !== 'string' + ) { + return fail(400, '납부 상태 값이 올바르지 않습니다.') + } + + const paymentDate = toPaymentDate(body.startDate) + if (!paymentDate) { + return fail(400, '기준일이 올바르지 않습니다.') + } + + try { + await updateNbreadRecord( + auth.client, + nbreadId, + body.userId, + body.isPaid, + paymentDate, + ) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'nbread_record.update', + tags: { nbreadId, userId: body.userId, isPaid: body.isPaid }, + extra: { paymentDate }, + }) + return fail(500, '납부 상태를 변경하지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/[nbreadId]/route.ts b/src/app/api/nbreads/[nbreadId]/route.ts new file mode 100644 index 0000000..3d8e28d --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/route.ts @@ -0,0 +1,71 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, noContent, ok } from '@/app/api/_lib/response' +import { getNbread } from '@/lib/server/nbread/getNbread' +import { updateNbread } from '@/lib/server/nbread/updateNbread' +import { deleteNbread } from '@/lib/server/nbread/deleteNbread' +import { captureAppError } from '@/lib/sentry/sentry' +import { Nbread } from '@/types/nbread' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function GET(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + + try { + const nbread = await getNbread(auth.client, nbreadId) + return ok(nbread) + } catch (error) { + captureAppError(error, { + action: 'nbread.get', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '엔빵을 불러오지 못했습니다.') + } +} + +export async function PATCH(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const body = (await request.json().catch(() => null)) as Nbread | null + + if (!body || typeof body.title !== 'string') { + return fail(400, '엔빵 정보가 올바르지 않습니다.') + } + + try { + await updateNbread(auth.client, nbreadId, body) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'nbread.update', + tags: { nbreadId, leaderId: body.leaderId ?? undefined }, + }) + return fail(500, '엔빵을 수정하지 못했습니다.') + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + + try { + await deleteNbread(auth.client, nbreadId) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'nbread.delete', + tags: { nbreadId }, + }) + return fail(500, '엔빵을 삭제하지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/records/route.ts b/src/app/api/nbreads/records/route.ts new file mode 100644 index 0000000..ca4b7ff --- /dev/null +++ b/src/app/api/nbreads/records/route.ts @@ -0,0 +1,23 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { fetchNbreadData } from '@/lib/server/nbread/fetchNbreadData' +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 records = await fetchNbreadData(auth.client, auth.user.id) + return ok(records) + } catch (error) { + captureAppError(error, { + action: 'nbread.records', + tags: { userId: auth.user.id }, + }) + return fail(500, '납부 기록을 불러오지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/route.ts b/src/app/api/nbreads/route.ts new file mode 100644 index 0000000..3fe6d41 --- /dev/null +++ b/src/app/api/nbreads/route.ts @@ -0,0 +1,60 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getUserNbreads } from '@/lib/server/nbread/getUserNbreads' +import { insertNbread } from '@/lib/server/nbread/insertNbread' +import { captureAppError } from '@/lib/sentry/sentry' +import { Nbread } from '@/types/nbread' + +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 + + // 월 판정은 사용자의 현지 시간대를 따라야 하므로 클라이언트가 넘긴 값을 쓴다. + const currentMonth = Number( + new URL(request.url).searchParams.get('currentMonth'), + ) + + if (!Number.isInteger(currentMonth) || currentMonth < 1 || currentMonth > 12) { + return fail(400, '조회할 월이 올바르지 않습니다.') + } + + try { + const nbreads = await getUserNbreads(auth.client, auth.user.id, currentMonth) + return ok(nbreads) + } catch (error) { + captureAppError(error, { + action: 'nbread.list', + tags: { userId: auth.user.id }, + }) + return fail(500, '엔빵 목록을 불러오지 못했습니다.') + } +} + +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 Nbread | null + + if (!body || typeof body.title !== 'string') { + return fail(400, '엔빵 정보가 올바르지 않습니다.') + } + + try { + const id = await insertNbread(auth.client, body) + return ok({ id }, 201) + } catch (error) { + captureAppError(error, { + action: 'nbread.insert', + tags: { leaderId: body.leaderId ?? undefined }, + extra: { + paymentPeriod: body.paymentPeriod, + participantCount: body.participantCount, + }, + }) + return fail(500, '엔빵을 만들지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/summary/route.ts b/src/app/api/nbreads/summary/route.ts new file mode 100644 index 0000000..4db40ae --- /dev/null +++ b/src/app/api/nbreads/summary/route.ts @@ -0,0 +1,26 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getUserTotalNbreadAmount } from '@/lib/server/nbread/getUserTotalNbreadAmount' +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 totalAmount = await getUserTotalNbreadAmount( + auth.client, + auth.user.id, + ) + return ok({ totalAmount }) + } catch (error) { + captureAppError(error, { + action: 'nbread.summary', + tags: { userId: auth.user.id }, + }) + return fail(500, '정산 합계를 불러오지 못했습니다.') + } +} diff --git a/src/lib/nbread/deleteNbread.ts b/src/lib/nbread/deleteNbread.ts index 42bd693..7b414e4 100644 --- a/src/lib/nbread/deleteNbread.ts +++ b/src/lib/nbread/deleteNbread.ts @@ -1,19 +1,11 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' export const deleteNbread = async (nbreadId: string) => { try { - const { data, error } = await supabase - .from('nbread') - .delete() - .eq('id', nbreadId) + await apiRequest(`/api/nbreads/${nbreadId}`, { method: 'DELETE' }) - if (error) { - console.error('Error inserting nbread:', error) - throw error - } - - return data + return null } catch (error) { captureAppError(error, { action: 'nbread.delete', diff --git a/src/lib/nbread/fetchNbreadData.ts b/src/lib/nbread/fetchNbreadData.ts index 456d0da..530424f 100644 --- a/src/lib/nbread/fetchNbreadData.ts +++ b/src/lib/nbread/fetchNbreadData.ts @@ -1,23 +1,28 @@ -import { supabase } from "@/lib/supabaseClient" +import { apiRequest } from '@/lib/apiClient' -export const fetchNbreadData = async (userId: string): Promise<{ nbread_id: string; payment_date: string | Date | null }[] | null> => { +export const fetchNbreadData = async ( + userId: string, +): Promise<{ nbread_id: string; payment_date: string | Date | null }[] | null> => { if (!userId) { - console.error("userId가 유효하지 않습니다.") - return null; + console.error('userId가 유효하지 않습니다.') + return null } - const { data, error } = await supabase - .from("nbread_records") - .select("nbread_id, payment_date") - .eq("user_id", userId) + try { + const records = await apiRequest< + { nbreadId: string; paymentDate: string | null }[] | null + >('/api/nbreads/records') - if (error) { - console.error("데이터 가져오기 실패:", error) - return null; - } + if (!records) return null - return data?.map((item) => ({ - ...item, - payment_date: item.payment_date ? new Date(item.payment_date) : null, - })) ?? [] + // Date 변환은 JSON으로 실어 나를 수 없어 여기서 수행한다. + return records.map((record) => ({ + nbread_id: record.nbreadId, + payment_date: record.paymentDate ? new Date(record.paymentDate) : null, + })) + } catch (error) { + // 실패 시 null을 돌려주던 기존 동작을 유지한다. + console.error('데이터 가져오기 실패:', error) + return null + } } diff --git a/src/lib/nbread/getNbread.ts b/src/lib/nbread/getNbread.ts index 55ffa21..cc75a8f 100644 --- a/src/lib/nbread/getNbread.ts +++ b/src/lib/nbread/getNbread.ts @@ -1,39 +1,9 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Nbread } from '@/types/nbread' -import { NbreadRow } from '@/types/supabase' -import { PostgrestError } from '@supabase/supabase-js' - -type GetNbreadType = { data: NbreadRow | null; error: PostgrestError | null } export const getNbread = async (nbreadId: string) => { try { - const { data, error }: GetNbreadType = await supabase - .from('nbread') - .select('*') - .eq('id', nbreadId) - .single() - - if (error || !data) { - console.error('Error select nbread:', error) - throw error - } - - const nbread: Nbread = { - id: data.id, - title: data.title, - participantCount: data.participant_count, - amount: data.amount, - paymentDate: data.payment_date, - paymentMonth: data.payment_month, - paymentPeriod: data.payment_period as 'year' | 'month', - leaderId: data.leader_id, - participants: null, - startDate: data.start_date, - endDate: data.end_date, - paidCount: null, - } - - return nbread + return await apiRequest(`/api/nbreads/${nbreadId}`) } catch (error) { console.error('Error fetching nbread:', error) throw error diff --git a/src/lib/nbread/getUserNbread.ts b/src/lib/nbread/getUserNbread.ts index c93d6b9..0b3da75 100644 --- a/src/lib/nbread/getUserNbread.ts +++ b/src/lib/nbread/getUserNbread.ts @@ -1,91 +1,22 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Nbread } from '@/types/nbread' -import { NbreadRow } from '@/types/supabase' export const getUserNbreads = async ( userId: string, ): Promise<{ monthlyNbreads: Nbread[]; myNbreads: Nbread[] }> => { if (!userId) return { monthlyNbreads: [], myNbreads: [] } + // 이번 달 판정은 사용자의 현지 시간대를 기준으로 해야 하므로 여기서 계산해 넘긴다. const currentMonth = new Date().getMonth() + 1 - // 1. 현재 로그인한 유저의 참여 정보를 가져옴 - const { data: participantEntries, error: participantError } = await supabase - .from('participant') - .select('nbread_id') - .eq('user_id', userId) - - if (participantError) { - console.error( - '❌ Failed to fetch participant entries:', - participantError.message, + try { + return await apiRequest<{ monthlyNbreads: Nbread[]; myNbreads: Nbread[] }>( + '/api/nbreads', + { query: { currentMonth } }, ) - - return { monthlyNbreads: [], myNbreads: [] } - } - - const nbreadIds = participantEntries?.map((entry) => entry.nbread_id) || [] - if (nbreadIds.length === 0) { - return { monthlyNbreads: [], myNbreads: [] } - } - - // 2. 현재 로그인한 유저가 참여 중인 모든 엔빵 가져오기 - const { data: nbreads, error } = await supabase - .from('nbread') - .select('*') - .in('id', nbreadIds) - - if (error || !nbreads) { - console.error('❌ Failed to fetch nbreads:', error?.message) + } catch (error) { + // 실패 시 빈 목록을 돌려주던 기존 동작을 유지한다. + console.error('❌ Failed to fetch nbreads:', error) return { monthlyNbreads: [], myNbreads: [] } } - - // 3. Supabase에서 가져온 엔빵 정보를 type에 맞게 변환 - const allNbreads: Nbread[] = (nbreads as NbreadRow[]).map((nbread) => ({ - id: nbread.id, - title: nbread.title, - amount: nbread.amount, - participantCount: nbread.participant_count, - paymentDate: nbread.payment_date, - paymentMonth: nbread.payment_month, - paymentPeriod: nbread.payment_period as 'year' | 'month', - leaderId: nbread.leader_id, - participants: null, - startDate: nbread.start_date, - endDate: nbread.end_date, - })) - - // 4. 각 nbread 객체에 paidCount 값을 추가 - const nbreadWithPaidCounts = await Promise.all( - allNbreads.map(async (nbread) => { - const { count, error } = await supabase - .from('nbread_records') - .select('*', { count: 'exact' }) - .eq('nbread_id', nbread.id) - .eq('payment_date', nbread.startDate) - .eq('is_paid', true) - - if (error) { - console.error( - `❌ Failed to fetch paid count for nbread_id: ${nbread.id}`, - error.message, - ) - return { ...nbread, paidCount: 0 } - } - - return { ...nbread, paidCount: count || 0 } - }), - ) - - // 5. 필터링: 이번 달 엔빵과 나의 엔빵 구분 - const monthlyNbreads = nbreadWithPaidCounts.filter((nbread) => { - return ( - nbread.paymentPeriod === 'month' || - (nbread.paymentPeriod === 'year' && nbread.paymentMonth === currentMonth) // 연간 결제 엔빵은 해당 월에만 포함 - ) - }) - - const myNbreads = nbreadWithPaidCounts - - return { monthlyNbreads, myNbreads } } diff --git a/src/lib/nbread/getUserTotalNbreadAmount.ts b/src/lib/nbread/getUserTotalNbreadAmount.ts index 313060b..510eee8 100644 --- a/src/lib/nbread/getUserTotalNbreadAmount.ts +++ b/src/lib/nbread/getUserTotalNbreadAmount.ts @@ -1,39 +1,17 @@ -import { supabase } from "@/lib/supabaseClient"; +import { apiRequest } from '@/lib/apiClient' export const getUserTotalNbreadAmount = async (userId: string) => { - if (!userId) return 0; - - // 사용자가 속한 nbread_records에서 is_paid=true인 데이터 가져오기 - const { data: paidRecords, error: paidError } = await supabase - .from("nbread_records") - .select("nbread_id") - .eq("user_id", userId) - .eq("is_paid", true); - - if (paidError) { - console.error("❌ Failed to fetch paid records:", paidError.message); - return 0; + if (!userId) return 0 + + try { + const { totalAmount } = await apiRequest<{ totalAmount: number }>( + '/api/nbreads/summary', + ) + + return totalAmount + } catch (error) { + // 실패 시 0을 돌려주던 기존 동작을 유지한다. + console.error('❌ Failed to fetch total nbread amount:', error) + return 0 } - - if (!paidRecords || paidRecords.length === 0) return 0; - - const nbreadIds = paidRecords.map((record) => record.nbread_id); - - // 해당 nbread_id에 대한 엔빵 정보 가져오기 - const { data: nbreads, error: nbreadError } = await supabase - .from("nbread") - .select("id, amount, participant_count") - .in("id", nbreadIds); - - if (nbreadError) { - console.error("❌ Failed to fetch nbreads:", nbreadError.message); - return 0; - } - - const totalAmount = nbreads.reduce((sum, nbread) => { - const individualShare = Math.floor(nbread.amount / Math.max(nbread.participant_count, 1)); - return sum + individualShare; - }, 0); - - return totalAmount; -}; +} diff --git a/src/lib/nbread/insertLink.ts b/src/lib/nbread/insertLink.ts index a3b4802..de7e966 100644 --- a/src/lib/nbread/insertLink.ts +++ b/src/lib/nbread/insertLink.ts @@ -1,19 +1,16 @@ +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' -import { supabase } from '@/lib/supabaseClient' export const createLinkInvite = async (nbreadId: string) => { - // 링크 초대도 공유 전에 대상 사용자 없는 초대 레코드를 생성한다. - const { data, error } = await supabase - .from('nbread_invite') - .insert({ - nbread_id: nbreadId, - target_user_id: null, - status: 'pending', - }) - .select('invite_token') - .single() + let inviteToken: string - if (error) { + try { + const result = await apiRequest<{ inviteToken: string }>( + `/api/nbreads/${nbreadId}/invites/link`, + { method: 'POST' }, + ) + inviteToken = result.inviteToken + } catch (error) { captureAppError(error, { action: 'invite.create_link', tags: { nbreadId }, @@ -21,9 +18,10 @@ export const createLinkInvite = async (nbreadId: string) => { throw error } + // origin을 아는 쪽에서 URL을 조립한다. const baseUrl = process.env.NEXT_PUBLIC_BASE_URL?.replace(/\/$/, '') ?? window.location.origin - return `${baseUrl}/invite/${data.invite_token}` + return `${baseUrl}/invite/${inviteToken}` } diff --git a/src/lib/nbread/insertNbread.ts b/src/lib/nbread/insertNbread.ts index c7cc2d2..6aa7825 100644 --- a/src/lib/nbread/insertNbread.ts +++ b/src/lib/nbread/insertNbread.ts @@ -1,29 +1,15 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Nbread } from '@/types/nbread' import { captureAppError } from '@/lib/sentry/sentry' export const insertNbread = async (nbread: Nbread) => { try { - const { data, error } = await supabase - .from('nbread') - .insert({ - title: nbread.title, - participant_count: nbread.participantCount, - amount: nbread.amount, - payment_period: nbread.paymentPeriod, - payment_date: nbread.paymentDate, - payment_month: nbread.paymentMonth, - leader_id: nbread.leaderId, - }) - .select('id') - .single() - - if (error) { - console.error('Error inserting nbread:', error) - throw error - } + const { id } = await apiRequest<{ id: string }>('/api/nbreads', { + method: 'POST', + body: nbread, + }) - return data.id + return id } catch (error) { captureAppError(error, { action: 'nbread.insert', diff --git a/src/lib/nbread/updateNbread.ts b/src/lib/nbread/updateNbread.ts index b798e15..b4af0d8 100644 --- a/src/lib/nbread/updateNbread.ts +++ b/src/lib/nbread/updateNbread.ts @@ -1,28 +1,15 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Nbread } from '@/types/nbread' import { captureAppError } from '@/lib/sentry/sentry' export const updateNbread = async (nbread: Nbread) => { try { - const { data, error } = await supabase - .from('nbread') - .update({ - title: nbread.title, - participant_count: nbread.participantCount, - amount: nbread.amount, - payment_period: nbread.paymentPeriod, - payment_date: nbread.paymentDate, - payment_month: nbread.paymentMonth, - leader_id: nbread.leaderId, - }) - .eq('id', nbread.id) - - if (error) { - console.error('Error updating nbread:', error) - throw error - } + await apiRequest(`/api/nbreads/${nbread.id}`, { + method: 'PATCH', + body: nbread, + }) - return data + return null } catch (error) { console.error('Error updating nbread:', error) captureAppError(error, { diff --git a/src/lib/nbreadRecord/getNbreadRecords.ts b/src/lib/nbreadRecord/getNbreadRecords.ts index 59346f2..4fc988b 100644 --- a/src/lib/nbreadRecord/getNbreadRecords.ts +++ b/src/lib/nbreadRecord/getNbreadRecords.ts @@ -1,34 +1,12 @@ -import { supabase } from '@/lib/supabaseClient' -import { Nbread, NbreadRecord } from '@/types/nbread' - -export const getNbreadRecords = async ( - nbreadId: string, - startDate: string, -) => { - const translatedStartDate = new Date(startDate) - .toISOString() - .split('T')[0] +import { apiRequest } from '@/lib/apiClient' +import { NbreadRecord } from '@/types/nbread' +export const getNbreadRecords = async (nbreadId: string, startDate: string) => { try { - const { data, error } = await supabase - .from('nbread_records') - .select('*') - .eq('nbread_id', nbreadId) - .eq('payment_date', translatedStartDate) - - if (error) { - console.error('Error fetching nbread record:', error) - throw error - } - - const renamedNbreadData: NbreadRecord[] = data.map((item) => ({ - userId: item.user_id, - nbreadId: item.nbread_id, - paymentDate: item.payment_date, - isPaid: item.is_paid, - })) - - return renamedNbreadData + return await apiRequest( + `/api/nbreads/${nbreadId}/records`, + { query: { startDate } }, + ) } catch (error) { console.error('Error fetching nbread record:', error) throw error diff --git a/src/lib/nbreadRecord/updateNbreadRecord.ts b/src/lib/nbreadRecord/updateNbreadRecord.ts index 09bb39a..11d8039 100644 --- a/src/lib/nbreadRecord/updateNbreadRecord.ts +++ b/src/lib/nbreadRecord/updateNbreadRecord.ts @@ -1,5 +1,4 @@ -import { supabase } from '@/lib/supabaseClient' -import { Nbread } from '@/types/nbread' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' export const updateNbreadRecord = async ( @@ -8,24 +7,13 @@ export const updateNbreadRecord = async ( isPaid: boolean, startDate: string, ) => { - const translatedStartDate = new Date(startDate).toISOString().split('T')[0] - try { - const { data, error } = await supabase - .from('nbread_records') - .update({ - is_paid: isPaid, - }) - .eq('nbread_id', nbreadId) - .eq('user_id', userId) - .eq('payment_date', translatedStartDate) - - if (error) { - console.error('Error updating nbread record:', error) - throw error - } + await apiRequest(`/api/nbreads/${nbreadId}/records`, { + method: 'PATCH', + body: { userId, isPaid, startDate }, + }) - return data + return null } catch (error) { console.error('Error updating nbread record:', error) captureAppError(error, { @@ -36,7 +24,7 @@ export const updateNbreadRecord = async ( isPaid, }, extra: { - paymentDate: translatedStartDate, + startDate, }, }) throw error diff --git a/src/lib/server/nbread/createLinkInvite.ts b/src/lib/server/nbread/createLinkInvite.ts new file mode 100644 index 0000000..cadc8e7 --- /dev/null +++ b/src/lib/server/nbread/createLinkInvite.ts @@ -0,0 +1,28 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * invite_token만 돌려준다. + * 초대 URL 조립은 origin을 아는 클라이언트 lib에 남긴다. + */ +export const createLinkInvite = async ( + client: SupabaseClient, + nbreadId: string, +): Promise => { + // 링크 초대도 공유 전에 대상 사용자 없는 초대 레코드를 생성한다. + const { data, error } = await client + .from('nbread_invite') + .insert({ + nbread_id: nbreadId, + target_user_id: null, + status: 'pending', + }) + .select('invite_token') + .single() + + if (error) { + console.error('Error creating link invite:', error) + throw error + } + + return data.invite_token +} diff --git a/src/lib/server/nbread/deleteNbread.ts b/src/lib/server/nbread/deleteNbread.ts new file mode 100644 index 0000000..bdea0f1 --- /dev/null +++ b/src/lib/server/nbread/deleteNbread.ts @@ -0,0 +1,13 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const deleteNbread = async ( + client: SupabaseClient, + nbreadId: string, +) => { + const { error } = await client.from('nbread').delete().eq('id', nbreadId) + + if (error) { + console.error('Error deleting nbread:', error) + throw error + } +} diff --git a/src/lib/server/nbread/fetchNbreadData.ts b/src/lib/server/nbread/fetchNbreadData.ts new file mode 100644 index 0000000..e8e3d5d --- /dev/null +++ b/src/lib/server/nbread/fetchNbreadData.ts @@ -0,0 +1,32 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * payment_date는 원시 문자열 그대로 내려보낸다. + * Date 변환은 JSON으로 직렬화할 수 없으므로 클라이언트 lib이 담당한다. + */ +export const fetchNbreadData = async ( + client: SupabaseClient, + userId: string, +): Promise<{ nbreadId: string; paymentDate: string | null }[] | null> => { + if (!userId) { + console.error('userId가 유효하지 않습니다.') + return null + } + + const { data, error } = await client + .from('nbread_records') + .select('nbread_id, payment_date') + .eq('user_id', userId) + + if (error) { + console.error('데이터 가져오기 실패:', error) + return null + } + + return ( + data?.map((item) => ({ + nbreadId: item.nbread_id, + paymentDate: item.payment_date, + })) ?? [] + ) +} diff --git a/src/lib/server/nbread/getNbread.ts b/src/lib/server/nbread/getNbread.ts new file mode 100644 index 0000000..26b6bbc --- /dev/null +++ b/src/lib/server/nbread/getNbread.ts @@ -0,0 +1,36 @@ +import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js' +import { Nbread } from '@/types/nbread' +import { NbreadRow } from '@/types/supabase' + +type GetNbreadType = { data: NbreadRow | null; error: PostgrestError | null } + +export const getNbread = async ( + client: SupabaseClient, + nbreadId: string, +): Promise => { + const { data, error }: GetNbreadType = await client + .from('nbread') + .select('*') + .eq('id', nbreadId) + .single() + + if (error || !data) { + console.error('Error select nbread:', error) + throw error ?? new Error('Error select nbread') + } + + return { + id: data.id, + title: data.title, + participantCount: data.participant_count, + amount: data.amount, + paymentDate: data.payment_date, + paymentMonth: data.payment_month, + paymentPeriod: data.payment_period as 'year' | 'month', + leaderId: data.leader_id, + participants: null, + startDate: data.start_date, + endDate: data.end_date, + paidCount: null, + } +} diff --git a/src/lib/server/nbread/getUserNbreads.ts b/src/lib/server/nbread/getUserNbreads.ts new file mode 100644 index 0000000..d9eb54a --- /dev/null +++ b/src/lib/server/nbread/getUserNbreads.ts @@ -0,0 +1,100 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { Nbread } from '@/types/nbread' +import { NbreadRow } from '@/types/supabase' + +const EMPTY = { monthlyNbreads: [], myNbreads: [] } + +/** + * currentMonth는 클라이언트가 넘긴다. + * 서버에서 new Date().getMonth()를 쓰면 배포 서버 시간대(UTC) 기준이 되어 + * 월이 바뀌는 구간에 한국 사용자와 판정이 어긋난다. + * + * paidCount를 엔빵 개수만큼 반복 조회하는 N+1은 그대로 옮긴다. 제거는 후속 이슈다. + */ +export const getUserNbreads = async ( + client: SupabaseClient, + userId: string, + currentMonth: number, +): Promise<{ monthlyNbreads: Nbread[]; myNbreads: Nbread[] }> => { + if (!userId) return EMPTY + + // 1. 현재 로그인한 유저의 참여 정보를 가져옴 + const { data: participantEntries, error: participantError } = await client + .from('participant') + .select('nbread_id') + .eq('user_id', userId) + + if (participantError) { + console.error( + '❌ Failed to fetch participant entries:', + participantError.message, + ) + + return EMPTY + } + + const nbreadIds = participantEntries?.map((entry) => entry.nbread_id) || [] + if (nbreadIds.length === 0) { + return EMPTY + } + + // 2. 현재 로그인한 유저가 참여 중인 모든 엔빵 가져오기 + const { data: nbreads, error } = await client + .from('nbread') + .select('*') + .in('id', nbreadIds) + + if (error || !nbreads) { + console.error('❌ Failed to fetch nbreads:', error?.message) + return EMPTY + } + + // 3. Supabase에서 가져온 엔빵 정보를 type에 맞게 변환 + const allNbreads: Nbread[] = (nbreads as NbreadRow[]).map((nbread) => ({ + id: nbread.id, + title: nbread.title, + amount: nbread.amount, + participantCount: nbread.participant_count, + paymentDate: nbread.payment_date, + paymentMonth: nbread.payment_month, + paymentPeriod: nbread.payment_period as 'year' | 'month', + leaderId: nbread.leader_id, + participants: null, + startDate: nbread.start_date, + endDate: nbread.end_date, + })) + + // 4. 각 nbread 객체에 paidCount 값을 추가 + const nbreadWithPaidCounts = await Promise.all( + allNbreads.map(async (nbread) => { + const { count, error } = await client + .from('nbread_records') + .select('*', { count: 'exact' }) + .eq('nbread_id', nbread.id) + .eq('payment_date', nbread.startDate) + .eq('is_paid', true) + + if (error) { + console.error( + `❌ Failed to fetch paid count for nbread_id: ${nbread.id}`, + error.message, + ) + return { ...nbread, paidCount: 0 } + } + + return { ...nbread, paidCount: count || 0 } + }), + ) + + // 5. 필터링: 이번 달 엔빵과 나의 엔빵 구분 + const monthlyNbreads = nbreadWithPaidCounts.filter((nbread) => { + return ( + nbread.paymentPeriod === 'month' || + (nbread.paymentPeriod === 'year' && nbread.paymentMonth === currentMonth) // 연간 결제 엔빵은 해당 월에만 포함 + ) + }) + + const myNbreads = nbreadWithPaidCounts + + return { monthlyNbreads, myNbreads } +} diff --git a/src/lib/server/nbread/getUserTotalNbreadAmount.ts b/src/lib/server/nbread/getUserTotalNbreadAmount.ts new file mode 100644 index 0000000..58c5c4e --- /dev/null +++ b/src/lib/server/nbread/getUserTotalNbreadAmount.ts @@ -0,0 +1,42 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const getUserTotalNbreadAmount = async ( + client: SupabaseClient, + userId: string, +): Promise => { + if (!userId) return 0 + + // 사용자가 속한 nbread_records에서 is_paid=true인 데이터 가져오기 + const { data: paidRecords, error: paidError } = await client + .from('nbread_records') + .select('nbread_id') + .eq('user_id', userId) + .eq('is_paid', true) + + if (paidError) { + console.error('❌ Failed to fetch paid records:', paidError.message) + return 0 + } + + if (!paidRecords || paidRecords.length === 0) return 0 + + const nbreadIds = paidRecords.map((record) => record.nbread_id) + + // 해당 nbread_id에 대한 엔빵 정보 가져오기 + const { data: nbreads, error: nbreadError } = await client + .from('nbread') + .select('id, amount, participant_count') + .in('id', nbreadIds) + + if (nbreadError) { + console.error('❌ Failed to fetch nbreads:', nbreadError.message) + return 0 + } + + return nbreads.reduce((sum, nbread) => { + const individualShare = Math.floor( + nbread.amount / Math.max(nbread.participant_count, 1), + ) + return sum + individualShare + }, 0) +} diff --git a/src/lib/server/nbread/insertNbread.ts b/src/lib/server/nbread/insertNbread.ts new file mode 100644 index 0000000..c081276 --- /dev/null +++ b/src/lib/server/nbread/insertNbread.ts @@ -0,0 +1,28 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { Nbread } from '@/types/nbread' + +export const insertNbread = async ( + client: SupabaseClient, + nbread: Nbread, +): Promise => { + const { data, error } = await client + .from('nbread') + .insert({ + title: nbread.title, + participant_count: nbread.participantCount, + amount: nbread.amount, + payment_period: nbread.paymentPeriod, + payment_date: nbread.paymentDate, + payment_month: nbread.paymentMonth, + leader_id: nbread.leaderId, + }) + .select('id') + .single() + + if (error) { + console.error('Error inserting nbread:', error) + throw error + } + + return data.id +} diff --git a/src/lib/server/nbread/updateNbread.ts b/src/lib/server/nbread/updateNbread.ts new file mode 100644 index 0000000..4b30cba --- /dev/null +++ b/src/lib/server/nbread/updateNbread.ts @@ -0,0 +1,26 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { Nbread } from '@/types/nbread' + +export const updateNbread = async ( + client: SupabaseClient, + nbreadId: string, + nbread: Nbread, +) => { + const { error } = await client + .from('nbread') + .update({ + title: nbread.title, + participant_count: nbread.participantCount, + amount: nbread.amount, + payment_period: nbread.paymentPeriod, + payment_date: nbread.paymentDate, + payment_month: nbread.paymentMonth, + leader_id: nbread.leaderId, + }) + .eq('id', nbreadId) + + if (error) { + console.error('Error updating nbread:', error) + throw error + } +} diff --git a/src/lib/server/nbreadRecord/getNbreadRecords.ts b/src/lib/server/nbreadRecord/getNbreadRecords.ts new file mode 100644 index 0000000..3fca538 --- /dev/null +++ b/src/lib/server/nbreadRecord/getNbreadRecords.ts @@ -0,0 +1,30 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { NbreadRecord } from '@/types/nbread' + +/** + * startDate는 'YYYY-MM-DD'로 정규화해서 넘긴다. + * toISOString은 항상 UTC 기준이라 브라우저에서 하던 변환과 결과가 같다. + */ +export const getNbreadRecords = async ( + client: SupabaseClient, + nbreadId: string, + startDate: string, +): Promise => { + const { data, error } = await client + .from('nbread_records') + .select('*') + .eq('nbread_id', nbreadId) + .eq('payment_date', startDate) + + if (error) { + console.error('Error fetching nbread record:', error) + throw error + } + + return data.map((item) => ({ + userId: item.user_id, + nbreadId: item.nbread_id, + paymentDate: item.payment_date, + isPaid: item.is_paid, + })) +} diff --git a/src/lib/server/nbreadRecord/updateNbreadRecord.ts b/src/lib/server/nbreadRecord/updateNbreadRecord.ts new file mode 100644 index 0000000..e0eecaa --- /dev/null +++ b/src/lib/server/nbreadRecord/updateNbreadRecord.ts @@ -0,0 +1,23 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const updateNbreadRecord = async ( + client: SupabaseClient, + nbreadId: string, + userId: string, + isPaid: boolean, + paymentDate: string, +) => { + const { error } = await client + .from('nbread_records') + .update({ + is_paid: isPaid, + }) + .eq('nbread_id', nbreadId) + .eq('user_id', userId) + .eq('payment_date', paymentDate) + + if (error) { + console.error('Error updating nbread record:', error) + throw error + } +} From 983ed78ba642823fc80e1f3da8eea6146571e828 Mon Sep 17 00:00:00 2001 From: junghogil Date: Tue, 18 Aug 2026 10:16:23 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90/?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20Supabase=20=ED=98=B8=EC=B6=9C=EC=9D=84=20a?= =?UTF-8?q?pp/api=20Route=20Handler=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #170 2단계. participant 5함수와 invite 5함수를 옮긴다. Route Handler 6개 - GET/POST/DELETE /api/nbreads/[nbreadId]/participants - POST /api/nbreads/[nbreadId]/invites - GET /api/nbreads/[nbreadId]/invites/candidates - GET /api/invites/pending - GET /api/invites/[token] 인증 선택 - POST /api/invites/[token]/response 설계서의 개별 처리 항목 - getInviteUser는 useUserStore 대신 토큰의 user.id를 쓴다. 응답 키는 호출부가 그대로 읽으므로 profile_image를 유지한다 - isGetParticipantsUser와 participantUsers는 서버 내부 헬퍼로만 두고 API로 노출하지 않는다. 클라이언트 lib에서는 제거했다 - insertParticipant는 네 번 왕복하던 로직을 한 Route Handler로 옮겼다. 정원 검사와 삽입 사이의 경쟁 조건은 그대로 남는다 - respondToInvite는 respond_to_nbread_invite RPC를 그대로 호출한다 apiClient의 auth 옵션 제거 - 처음에 getInviteByToken을 auth: false로 보냈더니 로그인한 사용자도 토큰 없이 요청해 RLS에 막혔고 초대 화면이 비어 보였다. 서버가 토큰을 요구하지 않는 것과 클라이언트가 토큰을 보내지 않는 것은 다르다 - 세션이 있으면 항상 토큰을 붙이도록 바꾸고 옵션 자체를 없앴다. 로그아웃 상태면 getSession이 null을 주므로 헤더가 붙지 않는다 호출부 수정 1건 - InviteBottomSheet의 지역 User 타입에서 avatar를 string | null로 넓혔다. profile_image가 nullable인데 기존에는 응답이 any라 드러나지 않았다. InviteUserListItem은 이미 string | null을 받으므로 런타임 영향은 없다 비로그인 초대 조회는 nbread_invite의 SELECT 정책이 authenticated만 허용해 이관 전과 동일하게 막힌다. 엔드포인트는 anon을 허용하지만 실제로 공개하려면 정책 변경이 필요하며 이번 범위에서는 다루지 않는다. 로컬 Supabase에서 확인했다. 참여자 조회와 내보내기, 정원 초과 처리, 중복 초대 방지, 초대 후보 검색의 본인 제외, 입력 검증 400을 확인했고 브라우저에서 초대 수락을 눌러 참여자 반영까지 확인했다. Co-Authored-By: Claude Opus 5 --- llm-wiki/log.md | 1 + src/app/api/_lib/requireAuth.ts | 11 +++ src/app/api/invites/[token]/response/route.ts | 36 ++++++++ src/app/api/invites/[token]/route.ts | 26 ++++++ src/app/api/invites/pending/route.ts | 23 +++++ .../[nbreadId]/invites/candidates/route.ts | 37 ++++++++ .../api/nbreads/[nbreadId]/invites/route.ts | 38 +++++++++ .../nbreads/[nbreadId]/participants/route.ts | 85 +++++++++++++++++++ src/components/invite/InviteBottomSheet.tsx | 3 +- src/lib/apiClient.ts | 12 +-- src/lib/invite/getInviteByToken.ts | 52 +++--------- src/lib/invite/getInviteUser.ts | 63 +++----------- src/lib/invite/getPendingInvites.ts | 61 +------------ src/lib/invite/respondToInvite.ts | 32 ++++--- src/lib/invite/sendInviteRequest.ts | 81 +++--------------- src/lib/participant/deleteParticipant.ts | 17 ++-- src/lib/participant/getParticipants.ts | 62 ++------------ src/lib/participant/insertParticipant.ts | 60 ++++--------- src/lib/server/invite/getInviteByToken.ts | 52 ++++++++++++ src/lib/server/invite/getInviteUser.ts | 68 +++++++++++++++ src/lib/server/invite/getPendingInvites.ts | 64 ++++++++++++++ src/lib/server/invite/respondToInvite.ts | 31 +++++++ src/lib/server/invite/sendInviteRequest.ts | 52 ++++++++++++ .../server/participant/deleteParticipant.ts | 18 ++++ src/lib/server/participant/getParticipants.ts | 75 ++++++++++++++++ .../server/participant/insertParticipant.ts | 66 ++++++++++++++ 26 files changed, 775 insertions(+), 351 deletions(-) create mode 100644 src/app/api/invites/[token]/response/route.ts create mode 100644 src/app/api/invites/[token]/route.ts create mode 100644 src/app/api/invites/pending/route.ts create mode 100644 src/app/api/nbreads/[nbreadId]/invites/candidates/route.ts create mode 100644 src/app/api/nbreads/[nbreadId]/invites/route.ts create mode 100644 src/app/api/nbreads/[nbreadId]/participants/route.ts create mode 100644 src/lib/server/invite/getInviteByToken.ts create mode 100644 src/lib/server/invite/getInviteUser.ts create mode 100644 src/lib/server/invite/getPendingInvites.ts create mode 100644 src/lib/server/invite/respondToInvite.ts create mode 100644 src/lib/server/invite/sendInviteRequest.ts create mode 100644 src/lib/server/participant/deleteParticipant.ts create mode 100644 src/lib/server/participant/getParticipants.ts create mode 100644 src/lib/server/participant/insertParticipant.ts diff --git a/llm-wiki/log.md b/llm-wiki/log.md index 3eed36e..db9eed1 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -21,3 +21,4 @@ | 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만 허용해 이관 전과 동일하게 막힘 | diff --git a/src/app/api/_lib/requireAuth.ts b/src/app/api/_lib/requireAuth.ts index 9658e02..d1d6747 100644 --- a/src/app/api/_lib/requireAuth.ts +++ b/src/app/api/_lib/requireAuth.ts @@ -51,3 +51,14 @@ export const requireAuth = async (request: Request): Promise => { return { ok: true, user, client } } + +/** + * 인증이 선택적인 엔드포인트용이다. + * 토큰이 있으면 그 토큰을 바인딩한 클라이언트를, 없으면 anon 클라이언트를 돌려준다. + * 비로그인 상태에서도 열리는 초대 링크 조회에만 쓴다. + */ +export const optionalAuth = (request: Request): SupabaseClient => { + const accessToken = parseBearerToken(request) + + return accessToken ? createRouteClient(accessToken) : createAnonRouteClient() +} diff --git a/src/app/api/invites/[token]/response/route.ts b/src/app/api/invites/[token]/response/route.ts new file mode 100644 index 0000000..f0c2a66 --- /dev/null +++ b/src/app/api/invites/[token]/response/route.ts @@ -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, '초대에 응답하지 못했습니다.') + } +} diff --git a/src/app/api/invites/[token]/route.ts b/src/app/api/invites/[token]/route.ts new file mode 100644 index 0000000..e41e4f5 --- /dev/null +++ b/src/app/api/invites/[token]/route.ts @@ -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, '초대 정보를 불러오지 못했습니다.') + } +} diff --git a/src/app/api/invites/pending/route.ts b/src/app/api/invites/pending/route.ts new file mode 100644 index 0000000..00029bf --- /dev/null +++ b/src/app/api/invites/pending/route.ts @@ -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, '받은 초대를 불러오지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/[nbreadId]/invites/candidates/route.ts b/src/app/api/nbreads/[nbreadId]/invites/candidates/route.ts new file mode 100644 index 0000000..81651e1 --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/invites/candidates/route.ts @@ -0,0 +1,37 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getInviteUser } from '@/lib/server/invite/getInviteUser' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function GET(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const tag = new URL(request.url).searchParams.get('tag') + + if (!tag) { + return fail(400, '검색할 태그가 없습니다.') + } + + try { + const candidates = await getInviteUser( + auth.client, + tag, + nbreadId, + auth.user.id, + ) + return ok(candidates) + } catch (error) { + captureAppError(error, { + action: 'invite.get_candidates', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '초대할 사용자를 불러오지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/[nbreadId]/invites/route.ts b/src/app/api/nbreads/[nbreadId]/invites/route.ts new file mode 100644 index 0000000..07dce9e --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/invites/route.ts @@ -0,0 +1,38 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { sendInviteRequest } from '@/lib/server/invite/sendInviteRequest' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function POST(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const body = (await request.json().catch(() => null)) as { + targetUserId?: unknown + } | null + + if (!body || typeof body.targetUserId !== 'string' || !body.targetUserId) { + return fail(400, '초대할 사용자가 올바르지 않습니다.') + } + + try { + const invites = await sendInviteRequest( + auth.client, + nbreadId, + body.targetUserId, + ) + return ok(invites) + } catch (error) { + captureAppError(error, { + action: 'invite.send_request', + tags: { nbreadId, targetUserId: body.targetUserId }, + }) + return fail(500, '초대를 보내지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/[nbreadId]/participants/route.ts b/src/app/api/nbreads/[nbreadId]/participants/route.ts new file mode 100644 index 0000000..a92319f --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/participants/route.ts @@ -0,0 +1,85 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, noContent, ok } from '@/app/api/_lib/response' +import { getParticipants } from '@/lib/server/participant/getParticipants' +import { insertParticipant } from '@/lib/server/participant/insertParticipant' +import { deleteParticipants } from '@/lib/server/participant/deleteParticipant' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function GET(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + + try { + const participants = await getParticipants(auth.client, nbreadId) + return ok(participants) + } catch (error) { + captureAppError(error, { + action: 'participant.list', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '참여자를 불러오지 못했습니다.') + } +} + +export async function POST(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const body = (await request.json().catch(() => null)) as { + isLeader?: unknown + } | null + + if (!body || typeof body.isLeader !== 'boolean') { + return fail(400, '참여 정보가 올바르지 않습니다.') + } + + try { + // 참여자는 항상 토큰의 사용자다. + const result = await insertParticipant( + auth.client, + nbreadId, + auth.user.id, + body.isLeader, + ) + return ok(result) + } catch (error) { + captureAppError(error, { + action: 'participant.insert', + tags: { nbreadId, userId: auth.user.id }, + extra: { isLeader: body.isLeader }, + }) + return fail(500, '엔빵에 참여하지 못했습니다.') + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + // 리더가 다른 참여자를 내보내는 기능이라 userId를 쿼리로 받는다. + const userId = new URL(request.url).searchParams.get('userId') + + if (!userId) { + return fail(400, '내보낼 참여자가 지정되지 않았습니다.') + } + + try { + await deleteParticipants(auth.client, userId, nbreadId) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'participant.delete', + tags: { userId, nbreadId }, + }) + return fail(500, '참여자를 내보내지 못했습니다.') + } +} diff --git a/src/components/invite/InviteBottomSheet.tsx b/src/components/invite/InviteBottomSheet.tsx index 31bf202..abd7cbd 100644 --- a/src/components/invite/InviteBottomSheet.tsx +++ b/src/components/invite/InviteBottomSheet.tsx @@ -12,7 +12,8 @@ interface InviteBottomSheetProps { user: string | null } interface User { - avatar: string + // profile_image는 nullable이라 InviteUserListItem의 avatar 타입과 맞춘다. + avatar: string | null name: string status: string userId: string diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index f67b6d4..78c3690 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -22,8 +22,6 @@ export interface ApiRequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' body?: unknown query?: Record - /** 인증이 선택적인 엔드포인트에서만 false로 둔다. */ - auth?: boolean } const buildUrl = (path: string, query?: Record) => { @@ -99,19 +97,23 @@ const send = async ( * app/api Route Handler를 호출한다. * 성공 응답의 { data }를 벗겨서 돌려주고, 204는 undefined를 돌려준다. * 실패하면 ApiError를 던진다. + * + * 세션이 있으면 항상 토큰을 붙인다. + * 인증이 선택인 엔드포인트도 마찬가지다. 로그인 상태에서 토큰을 빼면 + * 서버가 anon으로 조회해 RLS에 막히므로 로그인 전과 같은 결과만 보게 된다. */ export const apiRequest = async ( path: string, options: ApiRequestOptions = {}, ): Promise => { - const { method = 'GET', body, query, auth = true } = options + const { method = 'GET', body, query } = options const url = buildUrl(path, query) - let accessToken = auth ? await getAccessToken() : null + let accessToken = await getAccessToken() let response = await send(url, method, body, accessToken) // 토큰 만료로 401을 받으면 세션을 갱신해 1회만 재시도한다. - if (response.status === 401 && auth) { + if (response.status === 401) { const { data } = await supabase.auth.refreshSession() const refreshedToken = data.session?.access_token ?? null diff --git a/src/lib/invite/getInviteByToken.ts b/src/lib/invite/getInviteByToken.ts index b6b70ee..6c11f0d 100644 --- a/src/lib/invite/getInviteByToken.ts +++ b/src/lib/invite/getInviteByToken.ts @@ -1,51 +1,21 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' +import type { + InviteDetails, + InviteStatus, +} from '@/lib/server/invite/getInviteByToken' -export type InviteStatus = 'pending' | 'accepted' | 'rejected' | 'expired' - -export interface InviteDetails { - nbreadId: string - status: InviteStatus - nbreadTitle: string - leaderName: string -} +export type { InviteDetails, InviteStatus } export const getInviteByToken = async ( inviteToken: string, ): Promise => { try { - // 공개 링크에는 엔빵 ID 대신 초대 토큰만 노출하고 실제 초대 정보를 조회한다. - const { data: invite, error: inviteError } = await supabase - .from('nbread_invite') - .select('nbread_id, status') - .eq('invite_token', inviteToken) - .maybeSingle() - - if (inviteError) throw inviteError - if (!invite) return null - - const { data: nbread, error: nbreadError } = await supabase - .from('nbread') - .select('title, leader_id') - .eq('id', invite.nbread_id) - .single() - - if (nbreadError) throw nbreadError - - const { data: leader, error: leaderError } = await supabase - .from('user') - .select('name') - .eq('id', nbread.leader_id) - .single() - - if (leaderError) throw leaderError - - return { - nbreadId: invite.nbread_id, - status: invite.status as InviteStatus, - nbreadTitle: nbread.title, - leaderName: leader.name, - } + // 로그인 전에도 열리는 공개 링크라 서버는 토큰을 요구하지 않는다. + // 다만 로그인 상태라면 토큰이 붙어야 RLS를 통과하므로 apiRequest 기본 동작을 쓴다. + return await apiRequest( + `/api/invites/${inviteToken}`, + ) } catch (error) { captureAppError(error, { action: 'invite.get_by_token', diff --git a/src/lib/invite/getInviteUser.ts b/src/lib/invite/getInviteUser.ts index 4ab3141..2dfe7e9 100644 --- a/src/lib/invite/getInviteUser.ts +++ b/src/lib/invite/getInviteUser.ts @@ -1,55 +1,16 @@ -import { supabase } from '../supabaseClient' -import useUserStore from '@/stores/useAuthStore' +import { apiRequest } from '@/lib/apiClient' +import type { InviteCandidate } from '@/lib/server/invite/getInviteUser' + export const getInviteUser = async (tag: string, nbreadId: string) => { - const { user } = useUserStore.getState() - const currentUserId = user?.id try { - const { data: users, error } = await supabase - .from('user') - .select('id,profile_image,name') - .eq('tag', tag) - .neq('id', currentUserId) - - if (error) { - console.error('유저 데이터 요청 실패', error) - return - } - - // 2. 각 유저의 invite 상태 확인 - const usersWithInviteStatus = await Promise.all( - users.map(async (user) => { - const { data: nbreadData } = await supabase - .from('nbread_records') - .select('*') - .eq('user_id', user.id) - .eq('nbread_id', nbreadId) - .maybeSingle() - - const { data: inviteData } = await supabase - .from('nbread_invite') - .select('status') - .eq('target_user_id', user.id) - .eq('nbread_id', nbreadId) - // 재초대 기록이 여러 개면 가장 최근 초대 상태를 사용한다. - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle() - let status = '초대 하기' - if (nbreadData === 'accept') { - status = '참여 중' - } else if (inviteData?.status == 'pending') { - status = '초대 완료' - } else if (inviteData?.status == 'rejected') { - status = '초대 하기' - } - const result = { - ...user, - status: status, - } - - return result - }), + const candidates = await apiRequest( + `/api/nbreads/${nbreadId}/invites/candidates`, + { query: { tag } }, ) - return usersWithInviteStatus - } catch (error) {} + + return candidates ?? undefined + } catch (error) { + // 실패 시 아무 값도 돌려주지 않던 기존 동작을 유지한다. + console.error('유저 데이터 요청 실패', error) + } } diff --git a/src/lib/invite/getPendingInvites.ts b/src/lib/invite/getPendingInvites.ts index 808ff99..be9bec5 100644 --- a/src/lib/invite/getPendingInvites.ts +++ b/src/lib/invite/getPendingInvites.ts @@ -1,67 +1,14 @@ +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' -import { supabase } from '@/lib/supabaseClient' +import type { PendingInvite } from '@/lib/server/invite/getPendingInvites' -export interface PendingInvite { - id: string - inviteToken: string - nbreadId: string - nbreadTitle: string - leaderName: string - createdAt: string -} +export type { PendingInvite } export const getPendingInvites = async ( userId: string, ): Promise => { try { - const { data: invites, error: inviteError } = await supabase - .from('nbread_invite') - .select('id, invite_token, nbread_id, created_at') - .eq('target_user_id', userId) - .eq('status', 'pending') - .order('created_at', { ascending: false }) - - if (inviteError) throw inviteError - if (!invites || invites.length === 0) return [] - - const nbreadIds = [...new Set(invites.map((invite) => invite.nbread_id))] - const { data: nbreads, error: nbreadError } = await supabase - .from('nbread') - .select('id, title, leader_id') - .in('id', nbreadIds) - - if (nbreadError) throw nbreadError - if (!nbreads || nbreads.length === 0) return [] - - const leaderIds = [...new Set(nbreads.map((nbread) => nbread.leader_id))] - const { data: leaders, error: leaderError } = await supabase - .from('user') - .select('id, name') - .in('id', leaderIds) - - if (leaderError) throw leaderError - - const nbreadMap = new Map(nbreads.map((nbread) => [nbread.id, nbread])) - const leaderMap = new Map( - (leaders ?? []).map((leader) => [leader.id, leader.name]), - ) - - // 홈 배너와 목록 페이지가 동일한 pending 초대 기준을 사용한다. - return invites.flatMap((invite) => { - const nbread = nbreadMap.get(invite.nbread_id) - if (!nbread) return [] - - return [ - { - id: invite.id, - inviteToken: invite.invite_token, - nbreadId: invite.nbread_id, - nbreadTitle: nbread.title, - leaderName: leaderMap.get(nbread.leader_id) ?? '알 수 없는 사용자', - createdAt: invite.created_at, - }, - ] - }) + return await apiRequest('/api/invites/pending') } catch (error) { captureAppError(error, { action: 'invite.get_pending', diff --git a/src/lib/invite/respondToInvite.ts b/src/lib/invite/respondToInvite.ts index 9f1d43e..6a7b82c 100644 --- a/src/lib/invite/respondToInvite.ts +++ b/src/lib/invite/respondToInvite.ts @@ -1,30 +1,28 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' +import type { + InviteResponse, + InviteResponseResult, +} from '@/lib/server/invite/respondToInvite' -export type InviteResponse = 'accepted' | 'rejected' - -interface InviteResponseResult { - invite_id: string - nbread_id: string - status: InviteResponse - outcome: 'joined' | 'already_participant' | 'rejected' -} +export type { InviteResponse } export const respondToInvite = async ( inviteToken: string, response: InviteResponse, ) => { - const { data, error } = await supabase.rpc('respond_to_nbread_invite', { - p_invite_token: inviteToken, - p_response: response, - }) - - if (error) { + try { + return await apiRequest( + `/api/invites/${inviteToken}/response`, + { + method: 'POST', + body: { response }, + }, + ) + } catch (error) { captureAppError(error, { action: `invite.${response}`, }) throw error } - - return data as unknown as InviteResponseResult } diff --git a/src/lib/invite/sendInviteRequest.ts b/src/lib/invite/sendInviteRequest.ts index 07c78b8..731b0a3 100644 --- a/src/lib/invite/sendInviteRequest.ts +++ b/src/lib/invite/sendInviteRequest.ts @@ -1,82 +1,21 @@ -import { supabase } from '../supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' +import type { InviteRequestResult } from '@/lib/server/invite/sendInviteRequest' export const sendInviteRequest = async ( nbreadId: string, targetUserId: string, ) => { try { - const { data, error } = await supabase - .from('nbread_invite') - .select('status, invite_token') - .eq('nbread_id', nbreadId) - .eq('target_user_id', targetUserId) - .order('created_at', { ascending: false }) - - if (error) { - captureAppError(error, { - action: 'invite.select', - tags: { nbreadId, targetUserId }, - }) - throw error - } - - const activeInvites = - data?.filter( - (invite) => invite.status === 'pending' || invite.status === 'accepted', - ) ?? [] - - if (activeInvites.length > 0) { - // 처리 중이거나 이미 수락된 초대가 있으면 중복 초대를 생성하지 않는다. - return activeInvites - } - - if (!data || data.length === 0) { - // 최초 친구 초대는 대상 사용자를 연결한 pending 레코드를 생성한다. - const { data: insertedData, error } = await supabase - .from('nbread_invite') - .insert([ - { - nbread_id: nbreadId, - target_user_id: targetUserId, - status: 'pending', - }, - ]) - .select('status, invite_token') - - if (error) { - captureAppError(error, { - action: 'invite.insert', - tags: { nbreadId, targetUserId }, - }) - throw error - } - - return insertedData - } - - // 거절되거나 만료된 초대는 기록을 보존하고 새 pending 초대를 생성한다. - const { data: insertedData, error: insertError } = await supabase - .from('nbread_invite') - .insert([ - { - nbread_id: nbreadId, - target_user_id: targetUserId, - status: 'pending', - }, - ]) - .select('status, invite_token') - - if (insertError) { - captureAppError(insertError, { - action: 'invite.reinvite', - tags: { nbreadId, targetUserId }, - }) - throw insertError - } - - return insertedData + return await apiRequest( + `/api/nbreads/${nbreadId}/invites`, + { + method: 'POST', + body: { targetUserId }, + }, + ) } catch (error) { + // 실패 시 아무 값도 돌려주지 않던 기존 동작을 유지한다. console.error('초대 요청 중 오류 발생:', error) captureAppError(error, { action: 'invite.send_request', diff --git a/src/lib/participant/deleteParticipant.ts b/src/lib/participant/deleteParticipant.ts index d80072d..58716d2 100644 --- a/src/lib/participant/deleteParticipant.ts +++ b/src/lib/participant/deleteParticipant.ts @@ -1,14 +1,15 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { captureAppError } from '@/lib/sentry/sentry' export const deleteParticipants = async (userId: string, nbreadId: string) => { - const { data, error } = await supabase - .from('participant') - .delete() - .eq('user_id', userId) - .eq('nbread_id', nbreadId) + try { + await apiRequest(`/api/nbreads/${nbreadId}/participants`, { + method: 'DELETE', + query: { userId }, + }) - if (error) { + return null + } catch (error) { console.error('error deleting participants', error) captureAppError(error, { action: 'participant.delete', @@ -16,6 +17,4 @@ export const deleteParticipants = async (userId: string, nbreadId: string) => { }) throw error } - - return data } diff --git a/src/lib/participant/getParticipants.ts b/src/lib/participant/getParticipants.ts index ffeffe5..7fe3605 100644 --- a/src/lib/participant/getParticipants.ts +++ b/src/lib/participant/getParticipants.ts @@ -1,65 +1,15 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Participant } from '@/types/nbread' -import { UserRow } from '@/types/supabase' - -type GetParticipantsType = { user: UserRow; is_leader: boolean }[] export const getParticipants = async ( nbreadId: string, ): Promise => { - const { data, error } = await supabase - .from('participant') - .select('user!inner(*), is_leader') - .eq('nbread_id', nbreadId) - - if (error) { + try { + return await apiRequest( + `/api/nbreads/${nbreadId}/participants`, + ) + } catch (error) { console.error('error fetching participants', error) throw error } - - const participants: Participant[] = ( - data as unknown as GetParticipantsType - ).map((item) => ({ - user: { - id: item.user.id, - name: item.user.name, - profileImage: item.user.profile_image, - email: item.user.email, - socialType: item.user.social_type as 'kakao' | 'google', - tag: Number(item.user.tag), - }, - isLeader: item.is_leader, - })) - - return participants -} -export const isGetParticipantsUser = async ( - participant: Participant, - nbreadId: string, -) => { - const { data, error } = await supabase - .from('participant') - .select('*') - .eq('nbread_id', nbreadId) - .eq('user_id', participant.user.id) - .maybeSingle() - - if (error) { - console.error(error) - return - } - - return data -} -export const participantUsers = async (nbreadId: string) => { - const { data, error } = await supabase - .from('participant') - .select('*') // 'exact'를 사용하여 정확한 개수 반환 - .eq('nbread_id', nbreadId) - - if (error) { - console.error(error) - return - } - return data } diff --git a/src/lib/participant/insertParticipant.ts b/src/lib/participant/insertParticipant.ts index dda81bd..22597f2 100644 --- a/src/lib/participant/insertParticipant.ts +++ b/src/lib/participant/insertParticipant.ts @@ -1,55 +1,29 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Participant } from '@/types/nbread' -import { isGetParticipantsUser, participantUsers } from './getParticipants' -import { getNbread } from '../nbread' import { captureAppError } from '@/lib/sentry/sentry' +interface InsertParticipantResult { + isInsert: string + title: string + subTitle: string + buttonTitle: string +} + export const insertParticipant = async ( participant: Participant, nbreadId: string, ) => { try { - const participantCount = await getNbread(nbreadId) - - const usersData = await participantUsers(nbreadId) - - if (usersData) { - if (participantCount.participantCount <= usersData.length) { - return { - isInsert: '만료', - title: '엔빵 초대가 만료되었어요.', - subTitle: '링크가 만료되어 초대를 수락할 수 없어요.', - buttonTitle: '홈으로 가기', - } - } else { - const isUser = await isGetParticipantsUser(participant, nbreadId) - - if (isUser == null) { - const { data, error } = await supabase.from('participant').insert({ - nbread_id: nbreadId, - user_id: participant.user.id, - is_leader: participant.isLeader, - }) - - if (error) { - throw error - } + const result = await apiRequest( + `/api/nbreads/${nbreadId}/participants`, + { + method: 'POST', + body: { isLeader: participant.isLeader }, + }, + ) - return { - isInsert: '참여', - title: '엔빵 참여가 완료되었어요.', - subTitle: '참여한 엔빵 정보를 바로 확인할 수 있어요.', - buttonTitle: '엔빵 확인하러 가기', - } - } - return { - isInsert: '이미 참여', - title: '이미 참여 중인 엔빵이에요.', - subTitle: '참여한 엔빵 정보를 바로 확인할 수 있어요.', - buttonTitle: '엔빵 확인하러 가기', - } - } - } + // 참여자 조회가 실패하면 아무 값도 돌려주지 않던 기존 동작을 유지한다. + return result ?? undefined } catch (error) { captureAppError(error, { action: 'participant.insert', diff --git a/src/lib/server/invite/getInviteByToken.ts b/src/lib/server/invite/getInviteByToken.ts new file mode 100644 index 0000000..4a8747c --- /dev/null +++ b/src/lib/server/invite/getInviteByToken.ts @@ -0,0 +1,52 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export type InviteStatus = 'pending' | 'accepted' | 'rejected' | 'expired' + +export interface InviteDetails { + nbreadId: string + status: InviteStatus + nbreadTitle: string + leaderName: string +} + +/** + * 비로그인 상태에서도 열리는 유일한 조회다. + * 인증이 없으면 anon 클라이언트가 주입된다. + */ +export const getInviteByToken = async ( + client: SupabaseClient, + inviteToken: string, +): Promise => { + // 공개 링크에는 엔빵 ID 대신 초대 토큰만 노출하고 실제 초대 정보를 조회한다. + const { data: invite, error: inviteError } = await client + .from('nbread_invite') + .select('nbread_id, status') + .eq('invite_token', inviteToken) + .maybeSingle() + + if (inviteError) throw inviteError + if (!invite) return null + + const { data: nbread, error: nbreadError } = await client + .from('nbread') + .select('title, leader_id') + .eq('id', invite.nbread_id) + .single() + + if (nbreadError) throw nbreadError + + const { data: leader, error: leaderError } = await client + .from('user') + .select('name') + .eq('id', nbread.leader_id) + .single() + + if (leaderError) throw leaderError + + return { + nbreadId: invite.nbread_id, + status: invite.status as InviteStatus, + nbreadTitle: nbread.title, + leaderName: leader.name, + } +} diff --git a/src/lib/server/invite/getInviteUser.ts b/src/lib/server/invite/getInviteUser.ts new file mode 100644 index 0000000..a46ef2c --- /dev/null +++ b/src/lib/server/invite/getInviteUser.ts @@ -0,0 +1,68 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface InviteCandidate { + id: string + profile_image: string | null + name: string + status: string +} + +/** + * 기존 lib은 useUserStore에서 현재 사용자를 꺼냈다. + * 서버에는 스토어가 없으므로 토큰에서 얻은 currentUserId를 받는다. + * + * 응답 키를 profile_image 그대로 두는 이유는 호출부가 그 이름을 쓰기 때문이다. + */ +export const getInviteUser = async ( + client: SupabaseClient, + tag: string, + nbreadId: string, + currentUserId: string, +): Promise => { + const { data: users, error } = await client + .from('user') + .select('id,profile_image,name') + .eq('tag', tag) + .neq('id', currentUserId) + + if (error) { + console.error('유저 데이터 요청 실패', error) + return null + } + + // 2. 각 유저의 invite 상태 확인 + return Promise.all( + users.map(async (user) => { + const { data: nbreadData } = await client + .from('nbread_records') + .select('*') + .eq('user_id', user.id) + .eq('nbread_id', nbreadId) + .maybeSingle() + + const { data: inviteData } = await client + .from('nbread_invite') + .select('status') + .eq('target_user_id', user.id) + .eq('nbread_id', nbreadId) + // 재초대 기록이 여러 개면 가장 최근 초대 상태를 사용한다. + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + + let status = '초대 하기' + if (nbreadData === 'accept') { + status = '참여 중' + } else if (inviteData?.status == 'pending') { + status = '초대 완료' + } else if (inviteData?.status == 'rejected') { + status = '초대 하기' + } + + return { + ...user, + status, + } + }), + ) +} diff --git a/src/lib/server/invite/getPendingInvites.ts b/src/lib/server/invite/getPendingInvites.ts new file mode 100644 index 0000000..ef5d6ce --- /dev/null +++ b/src/lib/server/invite/getPendingInvites.ts @@ -0,0 +1,64 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface PendingInvite { + id: string + inviteToken: string + nbreadId: string + nbreadTitle: string + leaderName: string + createdAt: string +} + +export const getPendingInvites = async ( + client: SupabaseClient, + userId: string, +): Promise => { + const { data: invites, error: inviteError } = await client + .from('nbread_invite') + .select('id, invite_token, nbread_id, created_at') + .eq('target_user_id', userId) + .eq('status', 'pending') + .order('created_at', { ascending: false }) + + if (inviteError) throw inviteError + if (!invites || invites.length === 0) return [] + + const nbreadIds = [...new Set(invites.map((invite) => invite.nbread_id))] + const { data: nbreads, error: nbreadError } = await client + .from('nbread') + .select('id, title, leader_id') + .in('id', nbreadIds) + + if (nbreadError) throw nbreadError + if (!nbreads || nbreads.length === 0) return [] + + const leaderIds = [...new Set(nbreads.map((nbread) => nbread.leader_id))] + const { data: leaders, error: leaderError } = await client + .from('user') + .select('id, name') + .in('id', leaderIds) + + if (leaderError) throw leaderError + + const nbreadMap = new Map(nbreads.map((nbread) => [nbread.id, nbread])) + const leaderMap = new Map( + (leaders ?? []).map((leader) => [leader.id, leader.name]), + ) + + // 홈 배너와 목록 페이지가 동일한 pending 초대 기준을 사용한다. + return invites.flatMap((invite) => { + const nbread = nbreadMap.get(invite.nbread_id) + if (!nbread) return [] + + return [ + { + id: invite.id, + inviteToken: invite.invite_token, + nbreadId: invite.nbread_id, + nbreadTitle: nbread.title, + leaderName: leaderMap.get(nbread.leader_id) ?? '알 수 없는 사용자', + createdAt: invite.created_at, + }, + ] + }) +} diff --git a/src/lib/server/invite/respondToInvite.ts b/src/lib/server/invite/respondToInvite.ts new file mode 100644 index 0000000..de1a2b3 --- /dev/null +++ b/src/lib/server/invite/respondToInvite.ts @@ -0,0 +1,31 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export type InviteResponse = 'accepted' | 'rejected' + +export interface InviteResponseResult { + invite_id: string + nbread_id: string + status: InviteResponse + outcome: 'joined' | 'already_participant' | 'rejected' +} + +/** + * respond_to_nbread_invite RPC를 그대로 호출한다. + * 함수의 보안 속성은 건드리지 않는다. + */ +export const respondToInvite = async ( + client: SupabaseClient, + inviteToken: string, + response: InviteResponse, +): Promise => { + const { data, error } = await client.rpc('respond_to_nbread_invite', { + p_invite_token: inviteToken, + p_response: response, + }) + + if (error) { + throw error + } + + return data as unknown as InviteResponseResult +} diff --git a/src/lib/server/invite/sendInviteRequest.ts b/src/lib/server/invite/sendInviteRequest.ts new file mode 100644 index 0000000..d2306d2 --- /dev/null +++ b/src/lib/server/invite/sendInviteRequest.ts @@ -0,0 +1,52 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface InviteRequestResult { + status: string + invite_token: string +} + +export const sendInviteRequest = async ( + client: SupabaseClient, + nbreadId: string, + targetUserId: string, +): Promise => { + const { data, error } = await client + .from('nbread_invite') + .select('status, invite_token') + .eq('nbread_id', nbreadId) + .eq('target_user_id', targetUserId) + .order('created_at', { ascending: false }) + + if (error) { + throw error + } + + const activeInvites = + data?.filter( + (invite) => invite.status === 'pending' || invite.status === 'accepted', + ) ?? [] + + if (activeInvites.length > 0) { + // 처리 중이거나 이미 수락된 초대가 있으면 중복 초대를 생성하지 않는다. + return activeInvites + } + + // 최초 초대와 재초대 모두 대상 사용자를 연결한 pending 레코드를 새로 만든다. + // 거절되거나 만료된 기록은 지우지 않고 보존한다. + const { data: insertedData, error: insertError } = await client + .from('nbread_invite') + .insert([ + { + nbread_id: nbreadId, + target_user_id: targetUserId, + status: 'pending', + }, + ]) + .select('status, invite_token') + + if (insertError) { + throw insertError + } + + return insertedData +} diff --git a/src/lib/server/participant/deleteParticipant.ts b/src/lib/server/participant/deleteParticipant.ts new file mode 100644 index 0000000..3c313b8 --- /dev/null +++ b/src/lib/server/participant/deleteParticipant.ts @@ -0,0 +1,18 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const deleteParticipants = async ( + client: SupabaseClient, + userId: string, + nbreadId: string, +) => { + const { error } = await client + .from('participant') + .delete() + .eq('user_id', userId) + .eq('nbread_id', nbreadId) + + if (error) { + console.error('error deleting participants', error) + throw error + } +} diff --git a/src/lib/server/participant/getParticipants.ts b/src/lib/server/participant/getParticipants.ts new file mode 100644 index 0000000..ac45501 --- /dev/null +++ b/src/lib/server/participant/getParticipants.ts @@ -0,0 +1,75 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { Participant } from '@/types/nbread' +import { UserRow } from '@/types/supabase' + +type GetParticipantsType = { user: UserRow; is_leader: boolean }[] + +export const getParticipants = async ( + client: SupabaseClient, + nbreadId: string, +): Promise => { + const { data, error } = await client + .from('participant') + .select('user!inner(*), is_leader') + .eq('nbread_id', nbreadId) + + if (error) { + console.error('error fetching participants', error) + throw error + } + + return (data as unknown as GetParticipantsType).map((item) => ({ + user: { + id: item.user.id, + name: item.user.name, + profileImage: item.user.profile_image, + email: item.user.email, + socialType: item.user.social_type as 'kakao' | 'google', + tag: Number(item.user.tag), + }, + isLeader: item.is_leader, + })) +} + +/** + * insertParticipant 전용 헬퍼다. API로 노출하지 않는다. + */ +export const isGetParticipantsUser = async ( + client: SupabaseClient, + userId: string, + nbreadId: string, +) => { + const { data, error } = await client + .from('participant') + .select('*') + .eq('nbread_id', nbreadId) + .eq('user_id', userId) + .maybeSingle() + + if (error) { + console.error(error) + return + } + + return data +} + +/** + * insertParticipant 전용 헬퍼다. API로 노출하지 않는다. + */ +export const participantUsers = async ( + client: SupabaseClient, + nbreadId: string, +) => { + const { data, error } = await client + .from('participant') + .select('*') + .eq('nbread_id', nbreadId) + + if (error) { + console.error(error) + return + } + + return data +} diff --git a/src/lib/server/participant/insertParticipant.ts b/src/lib/server/participant/insertParticipant.ts new file mode 100644 index 0000000..32577ce --- /dev/null +++ b/src/lib/server/participant/insertParticipant.ts @@ -0,0 +1,66 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { getNbread } from '@/lib/server/nbread/getNbread' +import { isGetParticipantsUser, participantUsers } from './getParticipants' + +export interface InsertParticipantResult { + isInsert: string + title: string + subTitle: string + buttonTitle: string +} + +/** + * 정원 검사와 삽입 사이의 경쟁 조건은 기존 그대로 남아 있다. + * 원자적으로 처리하는 작업은 후속 이슈로 분리한다. + * + * usersData 조회가 실패하면 아무 값도 돌려주지 않던 기존 동작을 유지하기 위해 + * null을 돌려주고, 클라이언트 lib이 undefined로 되돌린다. + */ +export const insertParticipant = async ( + client: SupabaseClient, + nbreadId: string, + userId: string, + isLeader: boolean, +): Promise => { + const nbread = await getNbread(client, nbreadId) + const usersData = await participantUsers(client, nbreadId) + + if (!usersData) return null + + if (nbread.participantCount <= usersData.length) { + return { + isInsert: '만료', + title: '엔빵 초대가 만료되었어요.', + subTitle: '링크가 만료되어 초대를 수락할 수 없어요.', + buttonTitle: '홈으로 가기', + } + } + + const isUser = await isGetParticipantsUser(client, userId, nbreadId) + + if (isUser != null) { + return { + isInsert: '이미 참여', + title: '이미 참여 중인 엔빵이에요.', + subTitle: '참여한 엔빵 정보를 바로 확인할 수 있어요.', + buttonTitle: '엔빵 확인하러 가기', + } + } + + const { error } = await client.from('participant').insert({ + nbread_id: nbreadId, + user_id: userId, + is_leader: isLeader, + }) + + if (error) { + throw error + } + + return { + isInsert: '참여', + title: '엔빵 참여가 완료되었어요.', + subTitle: '참여한 엔빵 정보를 바로 확인할 수 있어요.', + buttonTitle: '엔빵 확인하러 가기', + } +} From c85ab0c48694aecff518aef66b71118649ec75bf Mon Sep 17 00:00:00 2001 From: junghogil Date: Tue, 18 Aug 2026 11:06:22 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC/=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EA=B2=80=EC=83=89=20Supabase=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20app/api=20Route=20Handler=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #170 3단계. friend 6함수 중 5개를 옮기고 1개는 노출하지 않는다. Route Handler 3개 - GET /api/users/search?tag= - GET /api/friends?nbreadId= - POST/PATCH /api/friends/requests sendFriendRequest는 POST, updateAcceptFriend와 updateRejectedFriend는 같은 PATCH에 status로 갈라 붙였다. 보내는 쪽과 응답하는 쪽 모두 토큰의 사용자이므로 상대방 id만 본문으로 받는다. getInviteFriendList는 노출하지 않는다 - 저장소 전체에서 호출부가 0곳이라 엔드포인트를 만들지 않았다. 설계서의 확인 필요 항목을 이렇게 정리한다. 함수 자체의 제거는 미사용 코드 정리 후속 이슈에서 함께 다룬다 설계서 개별 처리 항목 - .or() 필터를 문자열로 조립하는 두 곳을 그대로 옮기고 주석으로 남겼다 - 실패 시 빈 배열이나 undefined를 돌려주던 동작을 클라이언트 lib에서 유지했다 호출부 수정 2건 - PlusFriendBottomSheet의 searchFriendProps.profileImage와 PlusFriendListItem의 profile을 string | null로 넓혔다. profile_image가 nullable인데 기존에는 응답이 any라 드러나지 않았다. 렌더링에 이미 falsy 가드가 있어 런타임 영향은 없다 lint 오류가 22개에서 21개로 줄었다. getFriendList의 any[] 지역 변수를 서버로 옮기며 타입을 붙였기 때문이다. 로컬 Supabase에서 확인했다. 친구 목록과 inviteState 부착, 태그 검색의 본인 제외와 기존 요청 상태 반영, 신규 요청과 중복 요청, 수락과 거절, 거절 후 재요청의 update 분기, 입력 검증 400을 확인했고 브라우저에서 /friendList 렌더와 태그 검색을 확인했다. Co-Authored-By: Claude Opus 5 --- llm-wiki/log.md | 1 + src/app/api/friends/requests/route.ts | 83 ++++++++++ src/app/api/friends/route.ts | 26 ++++ src/app/api/users/search/route.ts | 29 ++++ .../friend/PlusFriendBottomSheet.tsx | 3 +- src/components/friend/PlusFriendListItem.tsx | 3 +- src/lib/friend/getSearchFriend.ts | 133 +++------------- src/lib/friend/sendFriendRequest.ts | 54 ++----- src/lib/friend/updateFriend.ts | 39 ++--- src/lib/server/friend/getSearchFriend.ts | 146 ++++++++++++++++++ src/lib/server/friend/sendFriendRequest.ts | 60 +++++++ src/lib/server/friend/updateFriend.ts | 48 ++++++ 12 files changed, 453 insertions(+), 172 deletions(-) create mode 100644 src/app/api/friends/requests/route.ts create mode 100644 src/app/api/friends/route.ts create mode 100644 src/app/api/users/search/route.ts create mode 100644 src/lib/server/friend/getSearchFriend.ts create mode 100644 src/lib/server/friend/sendFriendRequest.ts create mode 100644 src/lib/server/friend/updateFriend.ts diff --git a/llm-wiki/log.md b/llm-wiki/log.md index db9eed1..2554d1e 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -22,3 +22,4 @@ | 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번 | diff --git a/src/app/api/friends/requests/route.ts b/src/app/api/friends/requests/route.ts new file mode 100644 index 0000000..60a2448 --- /dev/null +++ b/src/app/api/friends/requests/route.ts @@ -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, '친구 요청에 응답하지 못했습니다.') + } +} diff --git a/src/app/api/friends/route.ts b/src/app/api/friends/route.ts new file mode 100644 index 0000000..98d115c --- /dev/null +++ b/src/app/api/friends/route.ts @@ -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, '친구 목록을 불러오지 못했습니다.') + } +} diff --git a/src/app/api/users/search/route.ts b/src/app/api/users/search/route.ts new file mode 100644 index 0000000..4e4f23d --- /dev/null +++ b/src/app/api/users/search/route.ts @@ -0,0 +1,29 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getSearchFriend } 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 + + const tag = new URL(request.url).searchParams.get('tag') + + if (!tag) { + return fail(400, '검색할 태그가 없습니다.') + } + + try { + const users = await getSearchFriend(auth.client, tag, auth.user.id) + return ok(users) + } catch (error) { + captureAppError(error, { + action: 'friend.search', + tags: { userId: auth.user.id }, + }) + return fail(500, '사용자를 검색하지 못했습니다.') + } +} diff --git a/src/components/friend/PlusFriendBottomSheet.tsx b/src/components/friend/PlusFriendBottomSheet.tsx index e531d81..bea7047 100644 --- a/src/components/friend/PlusFriendBottomSheet.tsx +++ b/src/components/friend/PlusFriendBottomSheet.tsx @@ -11,7 +11,8 @@ interface PlusFreindeBottomSheetProps { } interface searchFriendProps { name: string - profileImage: string + // profile_image는 nullable이라 PlusFriendListItem의 profile 타입과 맞춘다. + profileImage: string | null status: string senderId: string receiverId: string diff --git a/src/components/friend/PlusFriendListItem.tsx b/src/components/friend/PlusFriendListItem.tsx index 2be7e83..e1f975b 100644 --- a/src/components/friend/PlusFriendListItem.tsx +++ b/src/components/friend/PlusFriendListItem.tsx @@ -5,7 +5,8 @@ import { GA_EVENTS, trackEvent } from '@/lib/analytics/events' interface PlusFriendListItemProps { name: string status: string - profile: string + // profile_image는 nullable이며 아래 렌더링에서 이미 falsy를 걸러 낸다. + profile: string | null senderId: string receiverId: string } diff --git a/src/lib/friend/getSearchFriend.ts b/src/lib/friend/getSearchFriend.ts index 77020cb..fcb5aef 100644 --- a/src/lib/friend/getSearchFriend.ts +++ b/src/lib/friend/getSearchFriend.ts @@ -1,136 +1,45 @@ +import { apiRequest } from '@/lib/apiClient' import { supabase } from '../supabaseClient' +import type { + FriendListItem, + SearchFriendItem, +} from '@/lib/server/friend/getSearchFriend' -export interface FriendListItem { - name: string - profileImage: string | null - tag: string - id: string - inviteState?: string -} +export type { FriendListItem } export const getSearchFriend = async (tag: string, senderId: string) => { try { - const { data: user, error } = await supabase - .from('user') - .select('name,profile_image,id') - .eq('tag', tag) - .neq('id', senderId) - - if (error) { - console.error(error) - return [] - } - const userIds = user?.map((u) => u.id) || [] - - if (userIds.length === 0) { - return [] - } - - const userIdsFilter = userIds.join(',') - const { data: friends, error: friendsError } = await supabase - .from('friend_request') - .select('sender_id, receiver_id, status') - .or( - `and(sender_id.eq.${senderId},receiver_id.in.(${userIdsFilter})),and(sender_id.in.(${userIdsFilter}),receiver_id.eq.${senderId})`, - ) - - if (friendsError) { - console.error(friendsError) - return [] - } - - return (user ?? []).map((user) => ({ - name: user.name, - profileImage: user.profile_image, - senderId: senderId, - receiverId: user.id, - status: - friends?.find( - (friend) => - (friend.sender_id === senderId && friend.receiver_id === user.id) || - (friend.sender_id === user.id && friend.receiver_id === senderId), - )?.status || '친구 추가하기', - })) + return await apiRequest('/api/users/search', { + query: { tag }, + }) } catch (error) { + // 실패 시 빈 배열을 돌려주던 기존 동작을 유지한다. console.error(error) return [] } } + export const getFriendList = async ( user: string | null, nbreadId: string | null, ): Promise => { if (!user) return [] - try { - const { data, error } = await supabase - .from('friend') - .select('user_id_1,user_id_2') - .or(`user_id_1.eq.${user},user_id_2.eq.${user}`) - - if (error) { - console.error('친구리스트 불러오기error : ', error) - return [] - } - const friends = - data?.map((f) => (f.user_id_1 === user ? f.user_id_2 : f.user_id_1)) || [] - - if (friends.length > 0) { - const { data, error } = await supabase - .from('user') - .select('name,profile_image,tag,id') - .in('id', friends) - - if (error) { - console.error('error~~~~ : ', error) - return [] - } - const friendInfoList = data || [] - - // 필요에 따라 map으로 구조 변환 - const processedFriends: FriendListItem[] = friendInfoList.map((f) => ({ - name: f.name, - profileImage: f.profile_image, - tag: f.tag, - id: f.id, - })) - const friendIds = processedFriends.map((f) => f.id) - let inviteData: any[] = [] - if (nbreadId) { - const { data, error } = await supabase - .from('nbread_invite') - .select('status,target_user_id,created_at') - .in('target_user_id', friendIds) - .eq('nbread_id', nbreadId) - // find가 사용자별 최신 초대를 선택할 수 있도록 최신순으로 정렬한다. - .order('created_at', { ascending: false }) - if (error) { - console.error('error~~', error) - return [] - } - inviteData = data || [] - const mergedFriends = processedFriends.map((friend) => { - const invite = inviteData.find((i) => i.target_user_id === friend.id) - return { - ...friend, - inviteState: invite - ? invite.status === 'pending' - ? '초대 완료' // ✅ pending → 초대 완료로 변환 - : invite.status // 그 외 상태는 그대로 유지 - : '초대 하기', // 초대 기록 없으면 null - } - }) - return mergedFriends - } - - return processedFriends - } - return [] + try { + return await apiRequest('/api/friends', { + query: { nbreadId }, + }) } catch (error) { + // 실패 시 빈 배열을 돌려주던 기존 동작을 유지한다. console.error(error) return [] } } + +/** + * 저장소 전체에서 호출부가 없어 엔드포인트로 노출하지 않았다. + * 제거는 미사용 코드 정리 후속 이슈에서 함께 다룬다. + */ export const getInviteFriendList = async ( userId: string, inviteNbreadId: string, diff --git a/src/lib/friend/sendFriendRequest.ts b/src/lib/friend/sendFriendRequest.ts index a8ee24a..7f187f8 100644 --- a/src/lib/friend/sendFriendRequest.ts +++ b/src/lib/friend/sendFriendRequest.ts @@ -1,49 +1,23 @@ -import { supabase } from '../supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { sendFriendProps } from '@/components/friend/PlusFriendListItem' +import type { FriendRequestStatus } from '@/lib/server/friend/sendFriendRequest' + export const sendFriendRequest = async ({ receiverId, - senderId, status, }: sendFriendProps) => { - const { data, error } = await supabase - .from('friend_request') - .select('status') - .or( - `and(sender_id.eq.${senderId},receiver_id.eq.${receiverId}),and(sender_id.eq.${receiverId},receiver_id.eq.${senderId})`, + try { + const result = await apiRequest( + '/api/friends/requests', + { + method: 'POST', + body: { receiverId, status }, + }, ) - if (error) { - console.error('error : ', error) - return - } - // return data - if (!data || data.length === 0) { - // 데이터 없으면 insert - const { data: insertedData, error } = await supabase - .from('friend_request') - .insert([ - { - sender_id: senderId, - receiver_id: receiverId, - status: status, - }, - ]) - .select('status') - if (error) console.error(error) - return insertedData - } else if (data.some((item) => item.status === 'rejected')) { - // 기존 데이터 중 rejected이면 update - const { data: updatedData, error } = await supabase - .from('friend_request') - .update({ status }) - .eq('sender_id',senderId) - .eq('receiver_id',receiverId) - .select('status') - - if (error) console.error(error) - return updatedData + return result ?? undefined + } catch (error) { + // 실패 시 아무 값도 돌려주지 않던 기존 동작을 유지한다. + console.error('error : ', error) } - - // 이미 pending이나 accepted 상태라면 그대로 반환 - return data } diff --git a/src/lib/friend/updateFriend.ts b/src/lib/friend/updateFriend.ts index 0a99bdb..467e1c1 100644 --- a/src/lib/friend/updateFriend.ts +++ b/src/lib/friend/updateFriend.ts @@ -1,30 +1,33 @@ -import { supabase } from '../supabaseClient' +import { apiRequest } from '@/lib/apiClient' export const updateAcceptFriend = async ( receiverId: string, senderId: string | null, ) => { - const { error } = await supabase - .from('friend_request') - .update({ status: 'accepted' }) - .eq('sender_id', senderId) - .eq('receiver_id', receiverId) + try { + await apiRequest('/api/friends/requests', { + method: 'PATCH', + body: { senderId, status: 'accepted' }, + }) - if (error) { + // 기존에도 insert 결과가 null이라 항상 null을 돌려줬다. + return null + } catch (error) { console.error('친구 수락 업데이트 실패!', error) return null } - if(!error) { - const {data,error} =await supabase.from('friend').insert([{user_id_1 : receiverId, user_id_2 : senderId}]) +} - if(error) { - console.error('친구 추가 에러 : ',error) - return null - } - return data +export const updateRejectedFriend = async ( + receiverId: string, + senderId: string | null, +) => { + try { + await apiRequest('/api/friends/requests', { + method: 'PATCH', + body: { senderId, status: 'rejected' }, + }) + } catch (error) { + console.error('친구 거절 업데이트 실패!', error) } } -export const updateRejectedFriend = async(receiverId: string, - senderId: string | null,) => { - const {data, error} = await supabase.from('friend_request').update({status:'rejected'}).eq('sender_id',senderId).eq('receiver_id',receiverId) -} diff --git a/src/lib/server/friend/getSearchFriend.ts b/src/lib/server/friend/getSearchFriend.ts new file mode 100644 index 0000000..d9fd433 --- /dev/null +++ b/src/lib/server/friend/getSearchFriend.ts @@ -0,0 +1,146 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface FriendListItem { + name: string + profileImage: string | null + tag: string + id: string + inviteState?: string +} + +export interface SearchFriendItem { + name: string + profileImage: string | null + senderId: string + receiverId: string + status: string +} + +interface NbreadInviteRow { + status: string + target_user_id: string + created_at: string +} + +/** + * .or() 필터를 문자열로 조립하는 기존 방식을 그대로 옮긴다. + * 값이 UUID라 위험은 낮다고 보고 이번에는 손대지 않는다. + */ +export const getSearchFriend = async ( + client: SupabaseClient, + tag: string, + senderId: string, +): Promise => { + const { data: user, error } = await client + .from('user') + .select('name,profile_image,id') + .eq('tag', tag) + .neq('id', senderId) + + if (error) { + console.error(error) + return [] + } + + const userIds = user?.map((u) => u.id) || [] + + if (userIds.length === 0) { + return [] + } + + const userIdsFilter = userIds.join(',') + const { data: friends, error: friendsError } = await client + .from('friend_request') + .select('sender_id, receiver_id, status') + .or( + `and(sender_id.eq.${senderId},receiver_id.in.(${userIdsFilter})),and(sender_id.in.(${userIdsFilter}),receiver_id.eq.${senderId})`, + ) + + if (friendsError) { + console.error(friendsError) + return [] + } + + return (user ?? []).map((user) => ({ + name: user.name, + profileImage: user.profile_image, + senderId: senderId, + receiverId: user.id, + status: + friends?.find( + (friend) => + (friend.sender_id === senderId && friend.receiver_id === user.id) || + (friend.sender_id === user.id && friend.receiver_id === senderId), + )?.status || '친구 추가하기', + })) +} + +export const getFriendList = async ( + client: SupabaseClient, + user: string | null, + nbreadId: string | null, +): Promise => { + if (!user) return [] + + const { data, error } = await client + .from('friend') + .select('user_id_1,user_id_2') + .or(`user_id_1.eq.${user},user_id_2.eq.${user}`) + + if (error) { + console.error('친구리스트 불러오기error : ', error) + return [] + } + + const friends = + data?.map((f) => (f.user_id_1 === user ? f.user_id_2 : f.user_id_1)) || [] + + if (friends.length === 0) return [] + + const { data: friendInfo, error: friendInfoError } = await client + .from('user') + .select('name,profile_image,tag,id') + .in('id', friends) + + if (friendInfoError) { + console.error('error~~~~ : ', friendInfoError) + return [] + } + + const processedFriends: FriendListItem[] = (friendInfo || []).map((f) => ({ + name: f.name, + profileImage: f.profile_image, + tag: f.tag, + id: f.id, + })) + + if (!nbreadId) return processedFriends + + const friendIds = processedFriends.map((f) => f.id) + const { data: invites, error: inviteError } = await client + .from('nbread_invite') + .select('status,target_user_id,created_at') + .in('target_user_id', friendIds) + .eq('nbread_id', nbreadId) + // find가 사용자별 최신 초대를 선택할 수 있도록 최신순으로 정렬한다. + .order('created_at', { ascending: false }) + + if (inviteError) { + console.error('error~~', inviteError) + return [] + } + + const inviteData: NbreadInviteRow[] = invites || [] + + return processedFriends.map((friend) => { + const invite = inviteData.find((i) => i.target_user_id === friend.id) + return { + ...friend, + inviteState: invite + ? invite.status === 'pending' + ? '초대 완료' // pending은 초대 완료로 바꿔 보여준다. + : invite.status + : '초대 하기', + } + }) +} diff --git a/src/lib/server/friend/sendFriendRequest.ts b/src/lib/server/friend/sendFriendRequest.ts new file mode 100644 index 0000000..eb4fdcb --- /dev/null +++ b/src/lib/server/friend/sendFriendRequest.ts @@ -0,0 +1,60 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface FriendRequestStatus { + status: string +} + +/** + * .or() 필터를 문자열로 조립하는 기존 방식을 그대로 옮긴다. + */ +export const sendFriendRequest = async ( + client: SupabaseClient, + senderId: string, + receiverId: string, + status: string, +): Promise => { + const { data, error } = await client + .from('friend_request') + .select('status') + .or( + `and(sender_id.eq.${senderId},receiver_id.eq.${receiverId}),and(sender_id.eq.${receiverId},receiver_id.eq.${senderId})`, + ) + + if (error) { + console.error('error : ', error) + return null + } + + if (!data || data.length === 0) { + // 데이터 없으면 insert + const { data: insertedData, error: insertError } = await client + .from('friend_request') + .insert([ + { + sender_id: senderId, + receiver_id: receiverId, + status: status, + }, + ]) + .select('status') + + if (insertError) console.error(insertError) + return insertedData + } + + if (data.some((item) => item.status === 'rejected')) { + // 기존 데이터 중 rejected이면 update + const { data: updatedData, error: updateError } = await client + .from('friend_request') + .update({ status }) + .eq('sender_id', senderId) + .eq('receiver_id', receiverId) + .select('status') + + if (updateError) console.error(updateError) + return updatedData + } + + // 이미 pending이나 accepted 상태라면 그대로 반환 + return data +} diff --git a/src/lib/server/friend/updateFriend.ts b/src/lib/server/friend/updateFriend.ts new file mode 100644 index 0000000..4757929 --- /dev/null +++ b/src/lib/server/friend/updateFriend.ts @@ -0,0 +1,48 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * receiverId는 요청을 받아 수락하는 쪽, 즉 현재 사용자다. + * 오류를 삼키고 null을 돌려주던 기존 동작은 클라이언트 lib에서 유지한다. + */ +export const updateAcceptFriend = async ( + client: SupabaseClient, + receiverId: string, + senderId: string, +) => { + const { error } = await client + .from('friend_request') + .update({ status: 'accepted' }) + .eq('sender_id', senderId) + .eq('receiver_id', receiverId) + + if (error) { + console.error('친구 수락 업데이트 실패!', error) + throw error + } + + const { error: insertError } = await client + .from('friend') + .insert([{ user_id_1: receiverId, user_id_2: senderId }]) + + if (insertError) { + console.error('친구 추가 에러 : ', insertError) + throw insertError + } +} + +export const updateRejectedFriend = async ( + client: SupabaseClient, + receiverId: string, + senderId: string, +) => { + const { error } = await client + .from('friend_request') + .update({ status: 'rejected' }) + .eq('sender_id', senderId) + .eq('receiver_id', receiverId) + + if (error) { + console.error('친구 거절 업데이트 실패!', error) + throw error + } +} From 033845af1fb62e11e6a35181aac6f4a5ff0a24a7 Mon Sep 17 00:00:00 2001 From: junghogil Date: Tue, 18 Aug 2026 12:10:59 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80/?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20Supabase=20=ED=98=B8=EC=B6=9C=EC=9D=84=20a?= =?UTF-8?q?pp/api=20Route=20Handler=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #170 4단계. post 4함수와 chatMessage 2함수를 옮긴다. 이것으로 설계서의 이관 대상 39함수가 모두 끝난다. Route Handler 3개 - GET/POST /api/nbreads/[nbreadId]/posts - PATCH/DELETE /api/posts/[postId] - GET/POST /api/nbreads/[nbreadId]/messages 설계서와 다른 부분 - 게시글 수정과 삭제를 /api/posts/[postId]에 두었다. 설계서는 /api/nbreads/[nbreadId]/posts/[postId]였으나 deletePost 호출부가 postId만 넘겨 nbreadId를 알 수 없다. 경로에 의미 없는 조각을 채워 넣는 대신 게시글 식별자만으로 접근한다. 접근 제어는 기존과 동일하게 RLS가 담당한다 설계서 개별 처리 항목 - formattedTime은 표시용 값이라 클라이언트 lib에서 만든다. 서버는 createdAt 원시 값만 내려보낸다 - ChatRoom의 실시간 구독은 그대로 클라이언트에 남긴다 getPost 응답 타입 - 처음에는 snake_case 원본 행을 그대로 내려보냈으나 응답 본문은 camelCase를 쓴다는 규약에 어긋났다. 이미 있는 Post 타입으로 맞췄다. - 그에 따라 Community의 mapToPost가 하던 snake에서 camel 변환이 서버로 넘어가고, 호출부에는 표시용 날짜 포맷만 남는 formatPostDate가 남았다 lint 오류가 21개에서 18개로 줄었다. getPost와 insertPost, Community의 mapToPost에 있던 any가 사라졌다. deletePost와 UpdatePost의 any는 시그니처 유지를 위해 남기고 eslint-disable 주석을 붙였다. 타입 정리는 후속 이슈로 남는다. 로컬 Supabase에서 확인했다. 게시글 조회와 작성, 수정, 삭제, 작성자가 본문이 아니라 토큰 사용자로 저장되는 점, 메시지 조회와 전송, 입력 검증 400을 확인했고 브라우저에서 게시판 렌더와 채팅방의 formattedTime 표시, UI를 통한 메시지 전송을 확인했다. Co-Authored-By: Claude Opus 5 --- llm-wiki/log.md | 1 + .../api/nbreads/[nbreadId]/messages/route.ts | 70 ++++++++++++++++++ src/app/api/nbreads/[nbreadId]/posts/route.ts | 53 ++++++++++++++ src/app/api/posts/[postId]/route.ts | 73 +++++++++++++++++++ src/components/community/Community.tsx | 14 ++-- src/lib/chatMessage/getChatMessages.tsx | 31 +++----- src/lib/chatMessage/insertChatMessage.tsx | 46 ++++-------- src/lib/post/deletePost.ts | 16 ++-- src/lib/post/getPost.ts | 17 ++--- src/lib/post/insertPost.ts | 28 ++----- src/lib/post/updatePost.ts | 29 +++----- src/lib/server/chatMessage/getChatMessages.ts | 34 +++++++++ .../server/chatMessage/insertChatMessage.ts | 38 ++++++++++ src/lib/server/post/deletePost.ts | 10 +++ src/lib/server/post/getPost.ts | 32 ++++++++ src/lib/server/post/insertPost.ts | 25 +++++++ src/lib/server/post/updatePost.ts | 17 +++++ 17 files changed, 415 insertions(+), 119 deletions(-) create mode 100644 src/app/api/nbreads/[nbreadId]/messages/route.ts create mode 100644 src/app/api/nbreads/[nbreadId]/posts/route.ts create mode 100644 src/app/api/posts/[postId]/route.ts create mode 100644 src/lib/server/chatMessage/getChatMessages.ts create mode 100644 src/lib/server/chatMessage/insertChatMessage.ts create mode 100644 src/lib/server/post/deletePost.ts create mode 100644 src/lib/server/post/getPost.ts create mode 100644 src/lib/server/post/insertPost.ts create mode 100644 src/lib/server/post/updatePost.ts diff --git a/llm-wiki/log.md b/llm-wiki/log.md index 2554d1e..e4c7628 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -23,3 +23,4 @@ | 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]`로 두어 설계서 경로와 달라짐 | diff --git a/src/app/api/nbreads/[nbreadId]/messages/route.ts b/src/app/api/nbreads/[nbreadId]/messages/route.ts new file mode 100644 index 0000000..119b043 --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/messages/route.ts @@ -0,0 +1,70 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getChatMessages } from '@/lib/server/chatMessage/getChatMessages' +import { insertChatMessage } from '@/lib/server/chatMessage/insertChatMessage' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function GET(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + + try { + const messages = await getChatMessages(auth.client, nbreadId) + return ok(messages) + } catch (error) { + captureAppError(error, { + action: 'chat_message.list', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '메시지를 불러오지 못했습니다.') + } +} + +export async function POST(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const body = (await request.json().catch(() => null)) as { + content?: unknown + userName?: unknown + userProfileImage?: unknown + } | null + + if (!body || typeof body.content !== 'string' || !body.content) { + return fail(400, '메시지 내용이 올바르지 않습니다.') + } + + // 표시용 이름과 프로필은 기존과 동일하게 클라이언트가 준 값을 저장한다. + if (typeof body.userName !== 'string') { + return fail(400, '작성자 정보가 올바르지 않습니다.') + } + + const userProfileImage = + typeof body.userProfileImage === 'string' ? body.userProfileImage : null + + try { + const message = await insertChatMessage( + auth.client, + nbreadId, + auth.user.id, + body.userName, + userProfileImage, + body.content, + ) + return ok(message, 201) + } catch (error) { + captureAppError(error, { + action: 'chat_message.insert', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '메시지를 보내지 못했습니다.') + } +} diff --git a/src/app/api/nbreads/[nbreadId]/posts/route.ts b/src/app/api/nbreads/[nbreadId]/posts/route.ts new file mode 100644 index 0000000..46c0a6e --- /dev/null +++ b/src/app/api/nbreads/[nbreadId]/posts/route.ts @@ -0,0 +1,53 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, ok } from '@/app/api/_lib/response' +import { getPost } from '@/lib/server/post/getPost' +import { insertPost } from '@/lib/server/post/insertPost' +import { captureAppError } from '@/lib/sentry/sentry' +import { PostInsert } from '@/types/post' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ nbreadId: string }> } + +export async function GET(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + + try { + const posts = await getPost(auth.client, nbreadId) + return ok(posts) + } catch (error) { + captureAppError(error, { + action: 'post.list', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '게시글을 불러오지 못했습니다.') + } +} + +export async function POST(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const { nbreadId } = await context.params + const body = (await request.json().catch(() => null)) as PostInsert | null + + if (!body || typeof body.content !== 'string') { + return fail(400, '게시글 내용이 올바르지 않습니다.') + } + + try { + // 작성자는 항상 토큰의 사용자다. + await insertPost(auth.client, nbreadId, auth.user.id, body) + return ok(null, 201) + } catch (error) { + captureAppError(error, { + action: 'post.insert', + tags: { nbreadId, userId: auth.user.id }, + }) + return fail(500, '게시글을 작성하지 못했습니다.') + } +} diff --git a/src/app/api/posts/[postId]/route.ts b/src/app/api/posts/[postId]/route.ts new file mode 100644 index 0000000..147c447 --- /dev/null +++ b/src/app/api/posts/[postId]/route.ts @@ -0,0 +1,73 @@ +import { requireAuth } from '@/app/api/_lib/requireAuth' +import { fail, noContent } from '@/app/api/_lib/response' +import { updatePost } from '@/lib/server/post/updatePost' +import { deletePost } from '@/lib/server/post/deletePost' +import { captureAppError } from '@/lib/sentry/sentry' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * 설계서는 이 두 메서드를 /api/nbreads/[nbreadId]/posts/[postId]에 두었으나 + * deletePost 호출부가 postId만 넘겨 nbreadId를 알 수 없다. + * 경로에 의미 없는 조각을 채워 넣는 대신 게시글 식별자만으로 접근한다. + * 접근 제어는 기존과 동일하게 RLS가 담당한다. + */ +type RouteContext = { params: Promise<{ postId: string }> } + +const parsePostId = async (context: RouteContext) => { + const { postId } = await context.params + const parsed = Number(postId) + + return Number.isInteger(parsed) ? parsed : null +} + +export async function PATCH(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const postId = await parsePostId(context) + if (postId === null) { + return fail(400, '게시글 식별자가 올바르지 않습니다.') + } + + const body = (await request.json().catch(() => null)) as { + content?: unknown + } | null + + if (!body || typeof body.content !== 'string') { + return fail(400, '게시글 내용이 올바르지 않습니다.') + } + + try { + await updatePost(auth.client, postId, body.content) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'post.update', + tags: { postId: String(postId), userId: auth.user.id }, + }) + return fail(500, '게시글을 수정하지 못했습니다.') + } +} + +export async function DELETE(request: Request, context: RouteContext) { + const auth = await requireAuth(request) + if (!auth.ok) return auth.response + + const postId = await parsePostId(context) + if (postId === null) { + return fail(400, '게시글 식별자가 올바르지 않습니다.') + } + + try { + await deletePost(auth.client, postId) + return noContent() + } catch (error) { + captureAppError(error, { + action: 'post.delete', + tags: { postId: String(postId), userId: auth.user.id }, + }) + return fail(500, '게시글을 삭제하지 못했습니다.') + } +} diff --git a/src/components/community/Community.tsx b/src/components/community/Community.tsx index 26381fe..ca8a69a 100644 --- a/src/components/community/Community.tsx +++ b/src/components/community/Community.tsx @@ -49,22 +49,18 @@ const Community = () => { const [hasFetched, setHasFetched] = useState(false) const params = useParams() const nbreadId = params.nbreadId as string - const mapToPost = (raw: any): Post => ({ - id: raw.id, - content: raw.content, - userName: raw.user_name, - userProfileImage: raw.profile_image, - createdAt: new Date(raw.created_at) + // 서버가 Post 형태로 내려주므로 표시용 날짜 포맷만 남긴다. + const formatPostDate = (post: Post): Post => ({ + ...post, + createdAt: new Date(post.createdAt) .toISOString() .slice(0, 10) .replace(/-/g, '.'), - userId: raw.user_id, - nbreadId: raw.nbread_id, }) const fetchPosts = async () => { setHasFetched(false) const data = await getPost(nbreadId) - const mapped = data?.map(mapToPost) ?? [] + const mapped = data?.map(formatPostDate) ?? [] setPosts(mapped) setTimeout(() => { setHasFetched(true) diff --git a/src/lib/chatMessage/getChatMessages.tsx b/src/lib/chatMessage/getChatMessages.tsx index 25dd6e4..16f3e4a 100644 --- a/src/lib/chatMessage/getChatMessages.tsx +++ b/src/lib/chatMessage/getChatMessages.tsx @@ -1,30 +1,19 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { ChatMessage } from '@/types/chatMessage' +import type { ChatMessagePayload } from '@/lib/server/chatMessage/getChatMessages' import { formatChatMessageTime } from '@/utils/formatChatMessageTime' -/* getChatMessages: DB로부터 그룹 메시지 내역을 불러옴 */ +/* getChatMessages: 그룹 메시지 내역을 불러옴 */ export const getChatMessages = async (nbreadId: string) => { try { - const { data, error } = await supabase - .from('chat_messages') - .select('*') - .eq('nbread_id', nbreadId) + const messages = await apiRequest( + `/api/nbreads/${nbreadId}/messages`, + ) - if (error || !data) { - console.error('Error get chat messages:', error) - throw error - } - - // 불러온 row data를 ChatMessage 타입으로 매핑 - const chatMessages: ChatMessage[] = data.map((item) => ({ - id: item.id, - content: item.content, - nbreadId: item.nbread_id, - userId: item.user_id ?? '', - userName: item.user_name, - userProfileImage: item.user_profile_image, - createdAt: item.created_at, - formattedTime: formatChatMessageTime(item.created_at), + // formattedTime은 표시용 값이라 여기서 만든다. + const chatMessages: ChatMessage[] = messages.map((message) => ({ + ...message, + formattedTime: formatChatMessageTime(message.createdAt), })) return chatMessages diff --git a/src/lib/chatMessage/insertChatMessage.tsx b/src/lib/chatMessage/insertChatMessage.tsx index f7771ae..374c8fe 100644 --- a/src/lib/chatMessage/insertChatMessage.tsx +++ b/src/lib/chatMessage/insertChatMessage.tsx @@ -1,5 +1,6 @@ -import { supabase } from '@/lib/supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { ChatMessage } from '@/types/chatMessage' +import type { ChatMessagePayload } from '@/lib/server/chatMessage/getChatMessages' import { User } from '@/types/user' import { formatChatMessageTime } from '@/utils/formatChatMessageTime' @@ -8,35 +9,20 @@ export const insertChatMessage = async ( nbreadId: string, content: string, ): Promise => { - try { - const { data, error } = await supabase - .from('chat_messages') - .insert({ - nbread_id: nbreadId, - user_id: user.id, - user_name: user.name, - user_profile_image: user.profileImage, - content: content, - }) - .select('*') - .single() + const message = await apiRequest( + `/api/nbreads/${nbreadId}/messages`, + { + method: 'POST', + body: { + content, + userName: user.name, + userProfileImage: user.profileImage, + }, + }, + ) - if (error || !data) { - console.error('Error inserting chat messages:', error) - throw error - } - - return { - id: data.id, - content: data.content, - nbreadId: data.nbread_id, - userId: data.user_id ?? '', - userName: data.user_name, - userProfileImage: data.user_profile_image, - createdAt: data.created_at, - formattedTime: formatChatMessageTime(data.created_at), - } - } catch (error) { - throw error + return { + ...message, + formattedTime: formatChatMessageTime(message.createdAt), } } diff --git a/src/lib/post/deletePost.ts b/src/lib/post/deletePost.ts index d597070..0f853af 100644 --- a/src/lib/post/deletePost.ts +++ b/src/lib/post/deletePost.ts @@ -1,13 +1,11 @@ -import { supabase } from '../supabaseClient' +import { apiRequest } from '@/lib/apiClient' + +// 호출부가 postId만 넘기므로 게시글 식별자만으로 접근한다. +// eslint-disable-next-line @typescript-eslint/no-explicit-any export const deletePost = async (post: any) => { try { - const { data, error } = await supabase - .from('post') - .delete() - .eq('id', post) - if(error){ - console.error(error) - } - return data + await apiRequest(`/api/posts/${post}`, { method: 'DELETE' }) + + return null } catch (error) {} } diff --git a/src/lib/post/getPost.ts b/src/lib/post/getPost.ts index f7d8477..068a169 100644 --- a/src/lib/post/getPost.ts +++ b/src/lib/post/getPost.ts @@ -1,18 +1,11 @@ -import { supabase } from '../supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { Post } from '@/types/post' + export const getPost = async (nbreadId: string) => { try { - const { data, error } = await supabase - .from('post') - .select('*') - .eq('nbread_id', nbreadId) - .order('created_at', { ascending: false }) - if(error){ - console.error("게시글을 찾을수 없어!",error) - return - } - return data + return await apiRequest(`/api/nbreads/${nbreadId}/posts`) } catch (error) { - + // 실패 시 아무 값도 돌려주지 않던 기존 동작을 유지한다. + console.error('게시글을 찾을수 없어!', error) } } diff --git a/src/lib/post/insertPost.ts b/src/lib/post/insertPost.ts index 676a3d4..7281531 100644 --- a/src/lib/post/insertPost.ts +++ b/src/lib/post/insertPost.ts @@ -1,27 +1,15 @@ -import { supabase } from '../supabaseClient' +import { apiRequest } from '@/lib/apiClient' import { PostInsert } from '@/types/post' export const InsertPost = async (post: PostInsert) => { try { - const { data, error } = await supabase - .from('post') - .insert([ - { - content: post.content, - user_id: post.userId, - user_name: post.userName, - profile_image: post.userProfileImage, - nbread_id: post.nbreadId, - created_at: post.createdAt, - }, - ]) - - if (error) { - console.error('Error inserting post:', error) - throw error - } - - return data + await apiRequest(`/api/nbreads/${post.nbreadId}/posts`, { + method: 'POST', + body: post, + }) + + // 기존에도 insert 결과가 null이라 항상 null을 돌려줬다. + return null } catch (error) { console.error(error) } diff --git a/src/lib/post/updatePost.ts b/src/lib/post/updatePost.ts index 67c5fde..dd70355 100644 --- a/src/lib/post/updatePost.ts +++ b/src/lib/post/updatePost.ts @@ -1,20 +1,13 @@ -import { supabase } from "../supabaseClient" +import { apiRequest } from '@/lib/apiClient' -export const UpdatePost = async (post : any) => { - try { - const { data, error } = await supabase - .from('post') - .update({ - content : post.content, - }) - .eq('id', post.id) - if (error) { - console.error('Error updating post:', error) - throw error - } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const UpdatePost = async (post: any) => { + try { + await apiRequest(`/api/posts/${post.id}`, { + method: 'PATCH', + body: { content: post.content }, + }) - return data - } catch (error) { - - } -} \ No newline at end of file + return null + } catch (error) {} +} diff --git a/src/lib/server/chatMessage/getChatMessages.ts b/src/lib/server/chatMessage/getChatMessages.ts new file mode 100644 index 0000000..c774937 --- /dev/null +++ b/src/lib/server/chatMessage/getChatMessages.ts @@ -0,0 +1,34 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { ChatMessage } from '@/types/chatMessage' + +/** + * formattedTime은 JSON으로 실어 나를 수 없는 표시용 값이라 클라이언트 lib이 만든다. + * 서버는 원시 값만 내려보낸다. + */ +export type ChatMessagePayload = Omit + +/* getChatMessages: DB로부터 그룹 메시지 내역을 불러옴 */ +export const getChatMessages = async ( + client: SupabaseClient, + nbreadId: string, +): Promise => { + const { data, error } = await client + .from('chat_messages') + .select('*') + .eq('nbread_id', nbreadId) + + if (error || !data) { + console.error('Error get chat messages:', error) + throw error ?? new Error('Error get chat messages') + } + + return data.map((item) => ({ + id: item.id, + content: item.content, + nbreadId: item.nbread_id, + userId: item.user_id ?? '', + userName: item.user_name, + userProfileImage: item.user_profile_image, + createdAt: item.created_at, + })) +} diff --git a/src/lib/server/chatMessage/insertChatMessage.ts b/src/lib/server/chatMessage/insertChatMessage.ts new file mode 100644 index 0000000..21a8594 --- /dev/null +++ b/src/lib/server/chatMessage/insertChatMessage.ts @@ -0,0 +1,38 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { ChatMessagePayload } from './getChatMessages' + +export const insertChatMessage = async ( + client: SupabaseClient, + nbreadId: string, + userId: string, + userName: string, + userProfileImage: string | null, + content: string, +): Promise => { + const { data, error } = await client + .from('chat_messages') + .insert({ + nbread_id: nbreadId, + user_id: userId, + user_name: userName, + user_profile_image: userProfileImage, + content: content, + }) + .select('*') + .single() + + if (error || !data) { + console.error('Error inserting chat messages:', error) + throw error ?? new Error('Error inserting chat messages') + } + + return { + id: data.id, + content: data.content, + nbreadId: data.nbread_id, + userId: data.user_id ?? '', + userName: data.user_name, + userProfileImage: data.user_profile_image, + createdAt: data.created_at, + } +} diff --git a/src/lib/server/post/deletePost.ts b/src/lib/server/post/deletePost.ts new file mode 100644 index 0000000..deb250f --- /dev/null +++ b/src/lib/server/post/deletePost.ts @@ -0,0 +1,10 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const deletePost = async (client: SupabaseClient, postId: number) => { + const { error } = await client.from('post').delete().eq('id', postId) + + if (error) { + console.error('Error deleting post:', error) + throw error + } +} diff --git a/src/lib/server/post/getPost.ts b/src/lib/server/post/getPost.ts new file mode 100644 index 0000000..9590c4e --- /dev/null +++ b/src/lib/server/post/getPost.ts @@ -0,0 +1,32 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { Post } from '@/types/post' + +/** + * 응답은 camelCase 규약을 따라 Post 타입으로 맞춘다. + * createdAt은 원시 값을 그대로 주고, 표시용 날짜 포맷은 호출부가 담당한다. + */ +export const getPost = async ( + client: SupabaseClient, + nbreadId: string, +): Promise => { + const { data, error } = await client + .from('post') + .select('*') + .eq('nbread_id', nbreadId) + .order('created_at', { ascending: false }) + + if (error) { + console.error('게시글을 찾을수 없어!', error) + throw error + } + + return (data ?? []).map((row) => ({ + id: row.id, + content: row.content ?? '', + userId: row.user_id ?? '', + userName: row.user_name ?? '', + userProfileImage: row.profile_image ?? '', + nbreadId: row.nbread_id, + createdAt: row.created_at, + })) +} diff --git a/src/lib/server/post/insertPost.ts b/src/lib/server/post/insertPost.ts new file mode 100644 index 0000000..b9f43ec --- /dev/null +++ b/src/lib/server/post/insertPost.ts @@ -0,0 +1,25 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { PostInsert } from '@/types/post' + +export const insertPost = async ( + client: SupabaseClient, + nbreadId: string, + userId: string, + post: PostInsert, +) => { + const { error } = await client.from('post').insert([ + { + content: post.content, + user_id: userId, + user_name: post.userName, + profile_image: post.userProfileImage, + nbread_id: nbreadId, + created_at: post.createdAt, + }, + ]) + + if (error) { + console.error('Error inserting post:', error) + throw error + } +} diff --git a/src/lib/server/post/updatePost.ts b/src/lib/server/post/updatePost.ts new file mode 100644 index 0000000..7383f52 --- /dev/null +++ b/src/lib/server/post/updatePost.ts @@ -0,0 +1,17 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export const updatePost = async ( + client: SupabaseClient, + postId: number, + content: string, +) => { + const { error } = await client + .from('post') + .update({ content }) + .eq('id', postId) + + if (error) { + console.error('Error updating post:', error) + throw error + } +} From 6f209b0da97665e2971b01054a830facf4898b6e Mon Sep 17 00:00:00 2001 From: junghogil Date: Tue, 18 Aug 2026 14:10:38 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20startDate=EA=B0=80=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EB=82=A9=EB=B6=80=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B3=B4=EB=82=B4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 리뷰에서 나온 회귀를 고친다. Nbread.startDate는 string | null인데 두 호출부가 !로 넘긴다. 이관 전에는 new Date(null)이 1970-01-01이 되어 조회 결과가 항상 비어 있었고 갱신은 아무 행도 건드리지 않았다. 이관 후에는 apiClient의 buildUrl이 null 쿼리 값을 빼 버리므로 startDate 없이 요청이 나가 라우트가 400을 돌려주고 클라이언트 lib이 다시 던졌다. 두 호출부 모두 try/catch도 .catch도 없어 미처리 rejection이 됐다. 요청을 보내지 않고 이관 전과 같은 값을 돌려주도록 바꿨다. 서버의 startDate 검증은 그대로 두어 잘못된 요청은 여전히 400이다. start_date가 null인 엔빵으로 재현해 확인했다. 상세 화면이 오류 없이 렌더되고 records 요청이 나가지 않는다. Co-Authored-By: Claude Opus 5 --- src/lib/nbreadRecord/getNbreadRecords.ts | 5 +++++ src/lib/nbreadRecord/updateNbreadRecord.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/lib/nbreadRecord/getNbreadRecords.ts b/src/lib/nbreadRecord/getNbreadRecords.ts index 4fc988b..7a7cad2 100644 --- a/src/lib/nbreadRecord/getNbreadRecords.ts +++ b/src/lib/nbreadRecord/getNbreadRecords.ts @@ -2,6 +2,11 @@ import { apiRequest } from '@/lib/apiClient' import { NbreadRecord } from '@/types/nbread' export const getNbreadRecords = async (nbreadId: string, startDate: string) => { + // startDate는 타입상 string이지만 Nbread.startDate가 null일 수 있어 호출부가 !로 넘긴다. + // 이관 전에는 이 경우 1970-01-01로 조회되어 결과가 항상 비어 있었다. + // 요청을 보내지 않고 같은 값을 돌려주어 기존 동작을 유지한다. + if (!startDate) return [] + try { return await apiRequest( `/api/nbreads/${nbreadId}/records`, diff --git a/src/lib/nbreadRecord/updateNbreadRecord.ts b/src/lib/nbreadRecord/updateNbreadRecord.ts index 11d8039..77bf298 100644 --- a/src/lib/nbreadRecord/updateNbreadRecord.ts +++ b/src/lib/nbreadRecord/updateNbreadRecord.ts @@ -7,6 +7,10 @@ export const updateNbreadRecord = async ( isPaid: boolean, startDate: string, ) => { + // startDate가 없으면 이관 전에도 1970-01-01로 조회되어 아무 행도 갱신하지 않았다. + // 요청을 보내지 않고 같은 값을 돌려주어 기존 동작을 유지한다. + if (!startDate) return null + try { await apiRequest(`/api/nbreads/${nbreadId}/records`, { method: 'PATCH',