diff --git a/llm-wiki/log.md b/llm-wiki/log.md index 6465457..e4c7628 100644 --- a/llm-wiki/log.md +++ b/llm-wiki/log.md @@ -19,3 +19,8 @@ | 2026-08-16 | 개발 환경 검증 세션 내용을 `raw/2026-08-16-supabase-dev-environment-verification-session-log.md`에 저장 | 로컬 env 전환 및 Playwright 검증 과정, auth.users → public.user 트리거 누락 발견과 수정, db push 스크립트 버그 수정, 웹훅 구성 방식을 Dashboard 방식으로 재전환한 경위 보존 | 운영 마이그레이션 이력 repair와 두 수정 마이그레이션의 운영 반영 미실행, dev Dashboard 웹훅 미생성 | | 2026-08-16 | `wiki/supabase-schema-change-migration-strategy.md`에 "구조 변경과 환경별 설정을 구분하는 기준" 절 추가 | 테이블/함수/트리거/RLS/cron 같은 구조 변경은 마이그레이션 필수, OAuth Provider·Database Webhook 인증값·Vault 시크릿 같은 환경별 자격증명은 Dashboard가 맞다는 원칙을 실제 사례(트리거 누락, 웹훅 중복 위험) 근거로 정리 | 이 구분을 CI/리뷰 체크리스트에 반영할지 미논의 | | 2026-08-18 | 랜딩페이지와 검색 관련 코드 조사 후 `output/2026-08-18-faq-landing-page-implementation-plan.md` 작성 | FAQ 섹션, 공용 콘텐츠 원본, 구조화 데이터와 플레이wright 검증 범위 설계 | 최종 문항과 답변, 콘텐츠 목업, 펼침 방식 확인 | +| 2026-08-18 | 설계서 0단계(notification, fcmToken) 구현 후 코드 리뷰 반영 | 401 강제 리다이렉트 제거, 미사용 응답 헬퍼 정리 | `getFCMDeviceToken`의 `catch` 누락은 후속 이슈 6번으로 미룸 | +| 2026-08-18 | 설계서 1단계(nbread, nbreadRecord 10함수) 구현 및 로컬 검증 | Route Handler 6개 추가, curl과 브라우저로 동작 확인 | `getUserNbreads`에 `currentMonth` 쿼리 파라미터를 추가해 설계서 매핑표와 달라짐 | +| 2026-08-18 | 설계서 2단계(participant, invite 10함수) 구현 및 로컬 검증 | Route Handler 6개 추가, 초대 수락까지 브라우저로 확인 | 비로그인 초대 조회는 `nbread_invite` RLS가 authenticated만 허용해 이관 전과 동일하게 막힘 | +| 2026-08-18 | 설계서 3단계(friend, users/search 6함수) 구현 및 로컬 검증 | Route Handler 3개 추가, 친구 검색과 수락/거절을 브라우저로 확인 | `getInviteFriendList`은 호출부가 없어 노출하지 않음. 제거는 후속 이슈 5번 | +| 2026-08-18 | 설계서 4단계(post, chatMessage 6함수) 구현 및 로컬 검증 | Route Handler 3개 추가로 이관 대상 39함수 완료, 게시판과 채팅을 브라우저로 확인 | 게시글 수정·삭제는 `/api/posts/[postId]`로 두어 설계서 경로와 달라짐 | diff --git a/src/app/api/_lib/requireAuth.ts b/src/app/api/_lib/requireAuth.ts new file mode 100644 index 0000000..d1d6747 --- /dev/null +++ b/src/app/api/_lib/requireAuth.ts @@ -0,0 +1,64 @@ +import type { NextResponse } from 'next/server' +import type { SupabaseClient, User } from '@supabase/supabase-js' +import { fail } from './response' +import { + createAnonRouteClient, + createRouteClient, +} from './supabaseRouteClient' + +export type AuthResult = + | { ok: true; user: User; client: SupabaseClient } + | { ok: false; response: NextResponse } + +const parseBearerToken = (request: Request) => { + const authorization = request.headers.get('authorization') + + return authorization?.startsWith('Bearer ') + ? authorization.slice('Bearer '.length) + : null +} + +/** + * Authorization 헤더의 Bearer 토큰을 검증하고 + * 그 토큰을 바인딩한 Supabase 클라이언트를 함께 돌려준다. + */ +export const requireAuth = async (request: Request): Promise => { + const accessToken = parseBearerToken(request) + + if (!accessToken) { + return { ok: false, response: fail(401, '인증 토큰이 없습니다.') } + } + + let client: SupabaseClient + + try { + client = createRouteClient(accessToken) + } catch { + return { + ok: false, + response: fail(500, 'Supabase 서버 환경 변수가 설정되지 않았습니다.'), + } + } + + const { + data: { user }, + error, + } = await createAnonRouteClient().auth.getUser(accessToken) + + if (error || !user) { + return { ok: false, response: fail(401, '유효하지 않은 인증 토큰입니다.') } + } + + return { ok: true, user, client } +} + +/** + * 인증이 선택적인 엔드포인트용이다. + * 토큰이 있으면 그 토큰을 바인딩한 클라이언트를, 없으면 anon 클라이언트를 돌려준다. + * 비로그인 상태에서도 열리는 초대 링크 조회에만 쓴다. + */ +export const optionalAuth = (request: Request): SupabaseClient => { + const accessToken = parseBearerToken(request) + + return accessToken ? createRouteClient(accessToken) : createAnonRouteClient() +} diff --git a/src/app/api/_lib/response.ts b/src/app/api/_lib/response.ts new file mode 100644 index 0000000..c8ade01 --- /dev/null +++ b/src/app/api/_lib/response.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' + +/** + * Route Handler 공통 응답 헬퍼. + * 성공 응답은 항상 { data } 로 감싸고, 실패 응답은 { message, code? } 형태를 쓴다. + */ +export const ok = (data: T, status = 200) => + NextResponse.json({ data }, { status }) + +export const noContent = () => new NextResponse(null, { status: 204 }) + +export const fail = (status: number, message: string, code?: string) => + NextResponse.json(code ? { message, code } : { message }, { status }) 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/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/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/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]/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]/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]/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/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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..78c3690 --- /dev/null +++ b/src/lib/apiClient.ts @@ -0,0 +1,143 @@ +'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 +} + +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은 세션이 살아날 수 없는 상태로 본다. + * 로컬 세션과 스토어만 비우고 화면 이동은 하지 않는다. + * + * 이동까지 여기서 처리하면 protectRoute의 판정과 경쟁한다. + * 배경 요청 하나가 실패했다고 사용자를 페이지 밖으로 밀어내지 않도록, + * 어디로 보낼지는 기존대로 protectRoute가 결정하게 둔다. + */ +const clearDeadSession = async () => { + await supabase.auth.signOut({ scope: 'local' }).catch(() => {}) + useUserStore.getState().clearUser() + clearLegacyAuthStorage() +} + +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를 던진다. + * + * 세션이 있으면 항상 토큰을 붙인다. + * 인증이 선택인 엔드포인트도 마찬가지다. 로그인 상태에서 토큰을 빼면 + * 서버가 anon으로 조회해 RLS에 막히므로 로그인 전과 같은 결과만 보게 된다. + */ +export const apiRequest = async ( + path: string, + options: ApiRequestOptions = {}, +): Promise => { + const { method = 'GET', body, query } = options + const url = buildUrl(path, query) + + let accessToken = await getAccessToken() + let response = await send(url, method, body, accessToken) + + // 토큰 만료로 401을 받으면 세션을 갱신해 1회만 재시도한다. + if (response.status === 401) { + 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 clearDeadSession() + 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/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/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/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/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/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..7a7cad2 100644 --- a/src/lib/nbreadRecord/getNbreadRecords.ts +++ b/src/lib/nbreadRecord/getNbreadRecords.ts @@ -1,34 +1,17 @@ -import { supabase } from '@/lib/supabaseClient' -import { Nbread, NbreadRecord } from '@/types/nbread' +import { apiRequest } from '@/lib/apiClient' +import { NbreadRecord } from '@/types/nbread' -export const getNbreadRecords = async ( - nbreadId: string, - startDate: string, -) => { - const translatedStartDate = new Date(startDate) - .toISOString() - .split('T')[0] +export const getNbreadRecords = async (nbreadId: string, startDate: string) => { + // startDate는 타입상 string이지만 Nbread.startDate가 null일 수 있어 호출부가 !로 넘긴다. + // 이관 전에는 이 경우 1970-01-01로 조회되어 결과가 항상 비어 있었다. + // 요청을 보내지 않고 같은 값을 돌려주어 기존 동작을 유지한다. + if (!startDate) return [] 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..77bf298 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,17 @@ export const updateNbreadRecord = async ( isPaid: boolean, startDate: string, ) => { - const translatedStartDate = new Date(startDate).toISOString().split('T')[0] + // startDate가 없으면 이관 전에도 1970-01-01로 조회되어 아무 행도 갱신하지 않았다. + // 요청을 보내지 않고 같은 값을 돌려주어 기존 동작을 유지한다. + if (!startDate) return null 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 +28,7 @@ export const updateNbreadRecord = async ( isPaid, }, extra: { - paymentDate: translatedStartDate, + startDate, }, }) throw error 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/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/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/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/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 + } +} 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/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 + } +} 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) +} 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: '엔빵 확인하러 가기', + } +} 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 + } +}