From 730ccf96f7175bda46bb411ccaa208636e61a886 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 16:37:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=20feat:=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EB=B3=B4,=EB=A9=A4?= =?UTF-8?q?=EB=B2=84,=ED=98=84=EC=9E=AC=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/settings/page.tsx | 19 ++++---- .../api/update-my-nickname.ts | 45 +++++++++++++++++++ src/entities/workspace-member/index.ts | 3 ++ .../workspace/api/get-workspace-by-id.ts | 3 +- .../workspace/api/update-workspace-info.ts | 37 +++++++++++++++ src/entities/workspace/index.ts | 4 ++ .../workspace/model/workspace.types.ts | 2 + .../ui/MemberProfileForm.tsx | 35 +++++++++++---- .../ui/WorkspaceInfoForm.tsx | 42 ++++++++++++----- .../model/use-member-management.ts | 28 +++++++++--- .../ui/MemberManagementPanel.tsx | 4 +- src/views/settings/ui/SettingsView.tsx | 5 ++- 12 files changed, 192 insertions(+), 35 deletions(-) create mode 100644 src/entities/workspace-member/api/update-my-nickname.ts create mode 100644 src/entities/workspace/api/update-workspace-info.ts diff --git a/src/app/workspaces/[workspaceId]/settings/page.tsx b/src/app/workspaces/[workspaceId]/settings/page.tsx index f9afe8e..a9f14af 100644 --- a/src/app/workspaces/[workspaceId]/settings/page.tsx +++ b/src/app/workspaces/[workspaceId]/settings/page.tsx @@ -1,11 +1,8 @@ // 설정 페이지 라우트 — 활성 탭을 searchParam(?tab=)으로 읽고, 표시에 필요한 데이터를 RSC에서 조회해 주입한다. -// 실 API 전환 시 아래 조회부만 async(Supabase)로 교체하면 되고, 하위 뷰/훅은 그대로 둔다. import { notFound } from 'next/navigation'; import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; -import { - getMockWorkspaceMembersByWorkspaceId, - mockCurrentWorkspaceMember, -} from '@/entities/workspace-member'; +import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; import { SettingsView, parseSettingsTab } from '@/views/settings'; interface WorkspaceSettingsPageProps { @@ -29,7 +26,13 @@ export default async function WorkspaceSettingsPage({ notFound(); } - const members = getMockWorkspaceMembersByWorkspaceId(workspaceId); + // 멤버 목록과 현재 사용자 식별자를 병렬 조회하고, 현재 사용자의 닉네임은 멤버 목록에서 파생한다. + const [members, currentUserId] = await Promise.all([ + getWorkspaceMembersByWorkspaceId(workspaceId), + getCurrentUserId(), + ]); + const currentNickname = + members.find((member) => member.userId === currentUserId)?.workspaceNickname ?? ''; return ( ); } diff --git a/src/entities/workspace-member/api/update-my-nickname.ts b/src/entities/workspace-member/api/update-my-nickname.ts new file mode 100644 index 0000000..4047120 --- /dev/null +++ b/src/entities/workspace-member/api/update-my-nickname.ts @@ -0,0 +1,45 @@ +'use server'; + +// 현재 사용자의 워크스페이스 닉네임 수정 서버액션 — workspace_members update +// RLS(members_update_self)에 의해 본인 멤버십만 수정할 수 있다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const updateMyNicknameInputSchema = z.object({ + workspaceId: z.guid(), + nickname: z + .string() + .trim() + .min(1, '닉네임을 입력해주세요') + .max(20, '닉네임은 20자 이내로 입력해주세요'), +}); + +export type UpdateMyNicknameInput = z.infer; + +export async function updateMyNickname(input: UpdateMyNicknameInput): Promise { + const parsed = updateMyNicknameInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const userId = await getCurrentUserId(); + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('workspace_members') + .update({ workspace_nickname: parsed.data.nickname }) + .eq('workspace_id', parsed.data.workspaceId) + .eq('user_id', userId); + + if (error) { + // (workspace_id, workspace_nickname) 유니크 제약 위반은 사용자 친화적 메시지로 안내한다. + if (error.code === '23505') { + throw new Error('이미 사용 중인 닉네임이에요. 다른 닉네임을 입력해주세요.'); + } + console.error('[updateMyNickname] update 실패:', error); + throw new Error('닉네임 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.workspaceId}/settings`); +} diff --git a/src/entities/workspace-member/index.ts b/src/entities/workspace-member/index.ts index a4e1128..a703d05 100644 --- a/src/entities/workspace-member/index.ts +++ b/src/entities/workspace-member/index.ts @@ -9,6 +9,9 @@ export { getMockWorkspaceMembersByWorkspaceId, mockWorkspaceMembers, } from './model/mock-workspace-members'; +// 서버 전용 조회 함수(next/headers 의존)는 클라이언트 번들 오염을 피하려 barrel에서 제외하고 +// RSC에서 직접 경로로 import한다. (getWorkspaceById와 동일한 컨벤션) +export { updateMyNickname, type UpdateMyNicknameInput } from './api/update-my-nickname'; export { WORKSPACE_MEMBER_ROLE_META, WORKSPACE_MEMBER_STATUS_META, diff --git a/src/entities/workspace/api/get-workspace-by-id.ts b/src/entities/workspace/api/get-workspace-by-id.ts index dc1b68d..c1027b3 100644 --- a/src/entities/workspace/api/get-workspace-by-id.ts +++ b/src/entities/workspace/api/get-workspace-by-id.ts @@ -8,7 +8,7 @@ export const getWorkspaceById = cache(async (workspaceId: string): Promise; + +export async function updateWorkspaceInfo(input: UpdateWorkspaceInfoInput): Promise { + // 클라이언트 검증과 별개로 서버에서 재검증한다. + const parsed = updateWorkspaceInfoInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('workspaces') + .update({ + name: parsed.data.name, + description: parsed.data.description ?? null, + }) + .eq('id', parsed.data.id); + + if (error) { + console.error('[updateWorkspaceInfo] update 실패:', error); + throw new Error('워크스페이스 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.id}/settings`); +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index f7f8771..0f2da3c 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -25,3 +25,7 @@ export { export { getMyWorkspaces } from './api/get-my-workspaces'; export { useMyWorkspaces, myWorkspacesQueryKey } from './api/use-my-workspaces'; export { createWorkspace } from './api/create-workspace'; +export { + updateWorkspaceInfo, + type UpdateWorkspaceInfoInput, +} from './api/update-workspace-info'; diff --git a/src/entities/workspace/model/workspace.types.ts b/src/entities/workspace/model/workspace.types.ts index d3a11a1..ac10e7d 100644 --- a/src/entities/workspace/model/workspace.types.ts +++ b/src/entities/workspace/model/workspace.types.ts @@ -7,6 +7,8 @@ export interface Workspace { purpose: WorkspacePurpose; // 설정 페이지에서 편집하는 워크스페이스 소개 문구입니다. (선택 값) description?: string; + // 초대 링크 구성에 사용하는 초대 코드입니다. 생성 시 발급되며 없을 수 있습니다. + inviteCode?: string | null; } // get_my_workspaces RPC 반환 형태 (내 워크스페이스 목록) diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 7c7bb45..3eaa808 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -1,32 +1,49 @@ 'use client'; // 프로필 탭 — 현재 사용자의 워크스페이스 닉네임을 수정한다. -// 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 백엔드 연동 전까지 로컬 상태를 확정하는 목업으로 동작한다. +// 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 updateMyNickname 서버액션을 호출한다. import { useState } from 'react'; +import { toast } from 'sonner'; +import { updateMyNickname } from '@/entities/workspace-member'; interface MemberProfileFormProps { + workspaceId: string; initialNickname: string; } -export function MemberProfileForm({ initialNickname }: MemberProfileFormProps) { +export function MemberProfileForm({ workspaceId, initialNickname }: MemberProfileFormProps) { const [committedNickname, setCommittedNickname] = useState(initialNickname); const [nickname, setNickname] = useState(initialNickname); const [isSaved, setIsSaved] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const isDirty = nickname !== committedNickname; - const canSubmit = nickname.trim().length > 0 && isDirty; + const canSubmit = nickname.trim().length > 0 && isDirty && !isSubmitting; - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!canSubmit) { return; } - // TODO: 백엔드 연동 시 닉네임 저장 API 호출로 교체한다. const nextNickname = nickname.trim(); - setNickname(nextNickname); - setCommittedNickname(nextNickname); - setIsSaved(true); + + setIsSubmitting(true); + try { + await updateMyNickname({ workspaceId, nickname: nextNickname }); + setNickname(nextNickname); + setCommittedNickname(nextNickname); + setIsSaved(true); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '닉네임 저장에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsSubmitting(false); + } }; return ( @@ -56,7 +73,7 @@ export function MemberProfileForm({ initialNickname }: MemberProfileFormProps) { disabled={!canSubmit} className="h-10 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50" > - 저장 + {isSubmitting ? '저장 중…' : '저장'} {isSaved && !isDirty ? ( 저장되었습니다. diff --git a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx index fc4229a..6b26b71 100644 --- a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx +++ b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx @@ -1,9 +1,10 @@ 'use client'; // 워크스페이스 정보(이름·설명) 수정 폼입니다. -// 백엔드 연동 전이므로 저장은 로컬 상태를 확정(commit)하는 목업으로 동작합니다. +// 저장은 updateWorkspaceInfo 서버액션을 호출하고, 성공 시 로컬 committed 값을 확정한다. import { useState, type FormEvent } from 'react'; -import type { Workspace } from '@/entities/workspace'; +import { toast } from 'sonner'; +import { updateWorkspaceInfo, type Workspace } from '@/entities/workspace'; interface WorkspaceInfoFormProps { workspace: Workspace; @@ -17,22 +18,43 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { const [name, setName] = useState(workspace.name); const [description, setDescription] = useState(workspace.description ?? ''); const [isSaved, setIsSaved] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const isDirty = name !== committedName || description !== committedDescription; - const canSubmit = name.trim().length > 0 && isDirty; + const canSubmit = name.trim().length > 0 && isDirty && !isSubmitting; - const handleSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (!canSubmit) { return; } - // TODO: 백엔드 연동 시 워크스페이스 정보 저장 API 호출로 교체한다. const nextName = name.trim(); - setName(nextName); - setCommittedName(nextName); - setCommittedDescription(description); - setIsSaved(true); + const nextDescription = description.trim(); + + setIsSubmitting(true); + try { + await updateWorkspaceInfo({ + id: workspace.id, + name: nextName, + description: nextDescription.length > 0 ? nextDescription : undefined, + }); + // 성공 시에만 committed 값을 확정해 dirty 판단 기준을 갱신한다. + setName(nextName); + setDescription(nextDescription); + setCommittedName(nextName); + setCommittedDescription(nextDescription); + setIsSaved(true); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '워크스페이스 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsSubmitting(false); + } }; return ( @@ -74,7 +96,7 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { disabled={!canSubmit} className="h-10 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50" > - 저장 + {isSubmitting ? '저장 중…' : '저장'} {isSaved && !isDirty ? ( 저장되었습니다. diff --git a/src/features/manage-workspace-members/model/use-member-management.ts b/src/features/manage-workspace-members/model/use-member-management.ts index 88e8ed1..13ddf5c 100644 --- a/src/features/manage-workspace-members/model/use-member-management.ts +++ b/src/features/manage-workspace-members/model/use-member-management.ts @@ -2,8 +2,9 @@ // 팀원 초대(이메일/링크)와 멤버 목록 상태를 관리합니다. // 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받고, 훅은 로컬 상태로 seed만 한다. -// 백엔드 연동 전이므로 초대는 목록에 '초대됨' 상태의 멤버를 추가하는 목업으로 동작합니다. -import { useMemo, useState } from 'react'; +// 초대 링크는 서버에서 발급된 inviteCode와 런타임 origin으로 구성한다. +// 이메일 초대는 백엔드 미비로 아직 목록에 '초대됨' 멤버를 추가하는 목업으로 동작한다. +import { useMemo, useState, useSyncExternalStore } from 'react'; import type { WorkspaceMember } from '@/entities/workspace-member'; export type InviteMode = 'email' | 'link'; @@ -11,6 +12,7 @@ export type InviteMode = 'email' | 'link'; interface UseMemberManagementParams { workspaceId: string; initialMembers: WorkspaceMember[]; + inviteCode: string | null; } const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -31,13 +33,29 @@ function toAvatarLabel(email: string) { return email.charAt(0).toUpperCase(); } -export function useMemberManagement({ workspaceId, initialMembers }: UseMemberManagementParams) { +// origin은 클라이언트에서만 알 수 있는 값이라 useSyncExternalStore로 읽는다. +// 서버 스냅샷은 빈 문자열이라 하이드레이션 불일치가 발생하지 않고, origin은 변하지 않으므로 구독은 no-op이다. +const subscribeToOrigin = () => () => {}; +const getOriginSnapshot = () => window.location.origin; +const getOriginServerSnapshot = () => ''; + +export function useMemberManagement({ + workspaceId, + initialMembers, + inviteCode, +}: UseMemberManagementParams) { const [members, setMembers] = useState(initialMembers); const [inviteMode, setInviteMode] = useState('email'); const [email, setEmail] = useState(''); - // 백엔드 연동 시 서버에서 발급한 초대 링크로 교체한다. - const inviteLink = `https://syncly.app/invite/${workspaceId}`; + const origin = useSyncExternalStore( + subscribeToOrigin, + getOriginSnapshot, + getOriginServerSnapshot, + ); + + // 서버에서 발급된 초대 코드와 런타임 origin으로 초대 링크를 구성한다. + const inviteLink = inviteCode && origin ? `${origin}/invite/${inviteCode}` : ''; const trimmedEmail = email.trim(); const isDuplicate = useMemo( diff --git a/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx b/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx index 9af4c43..1ff2b9a 100644 --- a/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx +++ b/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx @@ -11,12 +11,14 @@ interface MemberManagementPanelProps { workspaceId: string; initialMembers: WorkspaceMember[]; currentUserId: string; + inviteCode: string | null; } export function MemberManagementPanel({ workspaceId, initialMembers, currentUserId, + inviteCode, }: MemberManagementPanelProps) { const { members, @@ -28,7 +30,7 @@ export function MemberManagementPanel({ canInvite, isDuplicate, inviteByEmail, - } = useMemberManagement({ workspaceId, initialMembers }); + } = useMemberManagement({ workspaceId, initialMembers, inviteCode }); return (
diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx index 0dddc57..1ef51c4 100644 --- a/src/views/settings/ui/SettingsView.tsx +++ b/src/views/settings/ui/SettingsView.tsx @@ -42,9 +42,12 @@ export function SettingsView({ workspaceId={workspaceId} initialMembers={members} currentUserId={currentUserId} + inviteCode={workspace.inviteCode ?? null} /> )} - {activeTab === 'profile' && } + {activeTab === 'profile' && ( + + )}
); From 210db0f9cf293d1ca55984dc2d336feb14275899 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Mon, 13 Jul 2026 17:28:10 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4,=ED=94=84=EB=A1=9C=ED=95=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20api=EC=97=B0=EA=B2=B0,=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=B4=88=EB=8C=80=EB=A7=81=ED=81=AC/=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8C=85/RPC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/invite/[code]/page.tsx | 45 +++++++++ .../workspace/api/get-invite-preview.ts | 30 ++++++ .../workspace/api/get-workspace-by-id.ts | 3 +- .../api/join-workspace-by-invite-code.ts | 28 ++++++ .../api/set-workspace-invite-enabled.ts | 36 +++++++ src/entities/workspace/index.ts | 5 + .../workspace/model/workspace.types.ts | 2 + .../ui/WorkspaceInfoForm.tsx | 44 +++++---- .../model/use-member-management.ts | 35 +++++++ .../ui/MemberInviteSection.tsx | 98 ++++++++++++++----- .../ui/MemberManagementPanel.tsx | 15 ++- src/shared/model/database.types.ts | 12 +++ src/views/invite/index.ts | 1 + src/views/invite/ui/InviteAcceptView.tsx | 58 +++++++++++ src/views/settings/ui/SettingsView.tsx | 8 +- .../20260713010000_create_invite_rpcs.sql | 82 ++++++++++++++++ 16 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 src/app/invite/[code]/page.tsx create mode 100644 src/entities/workspace/api/get-invite-preview.ts create mode 100644 src/entities/workspace/api/join-workspace-by-invite-code.ts create mode 100644 src/entities/workspace/api/set-workspace-invite-enabled.ts create mode 100644 src/views/invite/index.ts create mode 100644 src/views/invite/ui/InviteAcceptView.tsx create mode 100644 supabase/migrations/20260713010000_create_invite_rpcs.sql diff --git a/src/app/invite/[code]/page.tsx b/src/app/invite/[code]/page.tsx new file mode 100644 index 0000000..980c04a --- /dev/null +++ b/src/app/invite/[code]/page.tsx @@ -0,0 +1,45 @@ +// 초대 참여 라우트 — 초대 코드로 워크스페이스 요약을 조회해 참여 화면을 보여준다. +// 비멤버는 RLS로 워크스페이스를 직접 조회할 수 없어 security definer RPC(get_invite_preview)를 사용한다. +import Link from 'next/link'; +import { getInvitePreview } from '@/entities/workspace/api/get-invite-preview'; +import { plusJakartaSans } from '@/shared/lib/fonts'; +import { InviteAcceptView } from '@/views/invite'; + +interface InvitePageProps { + params: Promise<{ code: string }>; +} + +export default async function InvitePage({ params }: InvitePageProps) { + const { code } = await params; + const preview = await getInvitePreview(code); + + if (!preview) { + return ( +
+
+

유효하지 않은 초대예요

+

+ 링크가 만료되었거나 초대가 비활성화되었을 수 있어요. 초대한 사람에게 새 링크를 + 요청해 주세요. +

+ + 내 워크스페이스로 + +
+
+ ); + } + + return ( + + ); +} diff --git a/src/entities/workspace/api/get-invite-preview.ts b/src/entities/workspace/api/get-invite-preview.ts new file mode 100644 index 0000000..229bac3 --- /dev/null +++ b/src/entities/workspace/api/get-invite-preview.ts @@ -0,0 +1,30 @@ +// 초대 미리보기 조회 — get_invite_preview RPC (security definer로 비멤버도 활성 코드면 요약 조회) +// 서버 전용(next/headers 의존)이므로 barrel에 넣지 않고 RSC에서 직접 경로로 import한다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +export interface InvitePreview { + workspaceId: string; + name: string; + memberCount: number; +} + +export const getInvitePreview = cache(async (code: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.rpc('get_invite_preview', { p_code: code }); + + if (error) { + throw new Error(`초대 정보 조회에 실패했습니다: ${error.message}`); + } + + const preview = data?.[0]; + if (!preview) { + return null; + } + + return { + workspaceId: preview.workspace_id, + name: preview.name, + memberCount: preview.member_count, + }; +}); diff --git a/src/entities/workspace/api/get-workspace-by-id.ts b/src/entities/workspace/api/get-workspace-by-id.ts index c1027b3..8a5b7c5 100644 --- a/src/entities/workspace/api/get-workspace-by-id.ts +++ b/src/entities/workspace/api/get-workspace-by-id.ts @@ -8,7 +8,7 @@ export const getWorkspaceById = cache(async (workspaceId: string): Promise { + const trimmed = code.trim(); + if (!trimmed) { + throw new Error('유효하지 않은 초대 링크입니다.'); + } + + const userId = await getCurrentUserId(); + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.rpc('join_workspace_by_invite_code', { + p_user_id: userId, + p_code: trimmed, + }); + + if (error) { + // 유효하지 않거나 비활성화된 초대는 RPC가 던진 메시지를 그대로 전달한다. + console.error('[joinWorkspaceByInviteCode] RPC 실패:', error); + throw new Error(error.message || '워크스페이스 참여에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + return { workspaceId: data }; +} diff --git a/src/entities/workspace/api/set-workspace-invite-enabled.ts b/src/entities/workspace/api/set-workspace-invite-enabled.ts new file mode 100644 index 0000000..4485821 --- /dev/null +++ b/src/entities/workspace/api/set-workspace-invite-enabled.ts @@ -0,0 +1,36 @@ +'use server'; + +// 초대 링크 활성화/비활성화 서버액션 — workspaces.invite_enabled update +// RLS(workspaces_update_owner)에 의해 소유자만 변경할 수 있다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const setInviteEnabledInputSchema = z.object({ + workspaceId: z.guid(), + enabled: z.boolean(), +}); + +export type SetWorkspaceInviteEnabledInput = z.infer; + +export async function setWorkspaceInviteEnabled( + input: SetWorkspaceInviteEnabledInput, +): Promise { + const parsed = setInviteEnabledInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('workspaces') + .update({ invite_enabled: parsed.data.enabled }) + .eq('id', parsed.data.workspaceId); + + if (error) { + console.error('[setWorkspaceInviteEnabled] update 실패:', error); + throw new Error('초대 링크 설정 변경에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.workspaceId}/settings`); +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index 0f2da3c..ff1f560 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -29,3 +29,8 @@ export { updateWorkspaceInfo, type UpdateWorkspaceInfoInput, } from './api/update-workspace-info'; +export { joinWorkspaceByInviteCode } from './api/join-workspace-by-invite-code'; +export { + setWorkspaceInviteEnabled, + type SetWorkspaceInviteEnabledInput, +} from './api/set-workspace-invite-enabled'; diff --git a/src/entities/workspace/model/workspace.types.ts b/src/entities/workspace/model/workspace.types.ts index ac10e7d..911d49a 100644 --- a/src/entities/workspace/model/workspace.types.ts +++ b/src/entities/workspace/model/workspace.types.ts @@ -9,6 +9,8 @@ export interface Workspace { description?: string; // 초대 링크 구성에 사용하는 초대 코드입니다. 생성 시 발급되며 없을 수 있습니다. inviteCode?: string | null; + // 초대 링크 활성화 여부입니다. false면 코드가 있어도 참여가 거부됩니다. + inviteEnabled?: boolean; } // get_my_workspaces RPC 반환 형태 (내 워크스페이스 목록) diff --git a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx index 6b26b71..6eca420 100644 --- a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx +++ b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx @@ -8,9 +8,11 @@ import { updateWorkspaceInfo, type Workspace } from '@/entities/workspace'; interface WorkspaceInfoFormProps { workspace: Workspace; + // 워크스페이스 정보 수정은 RLS상 소유자만 가능하므로 UI도 소유자에게만 허용한다. + canEdit: boolean; } -export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { +export function WorkspaceInfoForm({ workspace, canEdit }: WorkspaceInfoFormProps) { // committed: 마지막으로 저장된 값. 입력값과 비교해 변경 여부(dirty)를 판단한다. const [committedName, setCommittedName] = useState(workspace.name); const [committedDescription, setCommittedDescription] = useState(workspace.description ?? ''); @@ -21,7 +23,7 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { const [isSubmitting, setIsSubmitting] = useState(false); const isDirty = name !== committedName || description !== committedDescription; - const canSubmit = name.trim().length > 0 && isDirty && !isSubmitting; + const canSubmit = canEdit && name.trim().length > 0 && isDirty && !isSubmitting; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); @@ -60,7 +62,11 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { return (

워크스페이스 정보

-

워크스페이스 이름과 설명을 수정할 수 있어요.

+

+ {canEdit + ? '워크스페이스 이름과 설명을 수정할 수 있어요.' + : '워크스페이스 정보는 소유자만 수정할 수 있어요.'} +

@@ -84,24 +91,27 @@ export function WorkspaceInfoForm({ workspace }: WorkspaceInfoFormProps) { setDescription(event.target.value); setIsSaved(false); }} + disabled={!canEdit} placeholder="워크스페이스를 소개하는 문구를 입력하세요." rows={3} - className="mt-2 min-h-24 w-full resize-none rounded-2xl bg-slate-100 px-4 py-3 text-sm font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300" + className="mt-2 min-h-24 w-full resize-none rounded-2xl bg-slate-100 px-4 py-3 text-sm font-medium text-slate-900 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-indigo-300 disabled:cursor-not-allowed disabled:opacity-60" /> -
- - {isSaved && !isDirty ? ( - 저장되었습니다. - ) : null} -
+ {canEdit ? ( +
+ + {isSaved && !isDirty ? ( + 저장되었습니다. + ) : null} +
+ ) : null}
); diff --git a/src/features/manage-workspace-members/model/use-member-management.ts b/src/features/manage-workspace-members/model/use-member-management.ts index 13ddf5c..e0cb895 100644 --- a/src/features/manage-workspace-members/model/use-member-management.ts +++ b/src/features/manage-workspace-members/model/use-member-management.ts @@ -5,6 +5,8 @@ // 초대 링크는 서버에서 발급된 inviteCode와 런타임 origin으로 구성한다. // 이메일 초대는 백엔드 미비로 아직 목록에 '초대됨' 멤버를 추가하는 목업으로 동작한다. import { useMemo, useState, useSyncExternalStore } from 'react'; +import { toast } from 'sonner'; +import { setWorkspaceInviteEnabled } from '@/entities/workspace'; import type { WorkspaceMember } from '@/entities/workspace-member'; export type InviteMode = 'email' | 'link'; @@ -13,6 +15,7 @@ interface UseMemberManagementParams { workspaceId: string; initialMembers: WorkspaceMember[]; inviteCode: string | null; + inviteEnabled: boolean; } const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -43,11 +46,40 @@ export function useMemberManagement({ workspaceId, initialMembers, inviteCode, + inviteEnabled, }: UseMemberManagementParams) { const [members, setMembers] = useState(initialMembers); const [inviteMode, setInviteMode] = useState('email'); const [email, setEmail] = useState(''); + // 초대 링크 활성 여부는 서버값으로 seed하고, 토글은 낙관적으로 반영 후 실패 시 롤백한다. + const [isInviteEnabled, setIsInviteEnabled] = useState(inviteEnabled); + const [isTogglingInvite, setIsTogglingInvite] = useState(false); + + const toggleInviteEnabled = async () => { + if (isTogglingInvite) { + return; + } + + const next = !isInviteEnabled; + setIsInviteEnabled(next); + setIsTogglingInvite(true); + try { + await setWorkspaceInviteEnabled({ workspaceId, enabled: next }); + } catch (error) { + // 실패 시 이전 상태로 롤백한다. + setIsInviteEnabled(!next); + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '초대 링크 설정 변경에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsTogglingInvite(false); + } + }; + const origin = useSyncExternalStore( subscribeToOrigin, getOriginSnapshot, @@ -94,5 +126,8 @@ export function useMemberManagement({ canInvite, isDuplicate, inviteByEmail, + isInviteEnabled, + isTogglingInvite, + toggleInviteEnabled, }; } diff --git a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx index 068110a..0ebdf53 100644 --- a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx +++ b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx @@ -15,6 +15,10 @@ interface MemberInviteSectionProps { isDuplicate: boolean; onInviteByEmail: () => void; inviteLink: string; + isInviteEnabled: boolean; + isTogglingInvite: boolean; + onToggleInviteEnabled: () => void; + canManageInvite: boolean; } const INVITE_MODES: { key: InviteMode; label: string }[] = [ @@ -31,10 +35,20 @@ export function MemberInviteSection({ isDuplicate, onInviteByEmail, inviteLink, + isInviteEnabled, + isTogglingInvite, + onToggleInviteEnabled, + canManageInvite, }: MemberInviteSectionProps) { const [isCopied, setIsCopied] = useState(false); + const canCopy = isInviteEnabled && inviteLink.length > 0; + const handleCopy = async () => { + if (!canCopy) { + return; + } + try { await navigator.clipboard.writeText(inviteLink); setIsCopied(true); @@ -100,31 +114,71 @@ export function MemberInviteSection({ ) : ( -
-
-
+ +
+
+
+ +
+ )} -

- {inviteMode === 'email' - ? '아직 가입하지 않은 사용자는 가입 시 자동으로 연결됩니다.' - : '링크를 아는 사람은 누구나 이 워크스페이스에 참여할 수 있어요.'} -

+ {inviteMode === 'email' ? ( +

+ 아직 가입하지 않은 사용자는 가입 시 자동으로 연결됩니다. +

+ ) : null} {inviteMode === 'email' && isDuplicate ? (

이미 초대된 이메일이에요.

) : null} diff --git a/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx b/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx index 1ff2b9a..4cb21ee 100644 --- a/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx +++ b/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx @@ -12,6 +12,7 @@ interface MemberManagementPanelProps { initialMembers: WorkspaceMember[]; currentUserId: string; inviteCode: string | null; + inviteEnabled: boolean; } export function MemberManagementPanel({ @@ -19,6 +20,7 @@ export function MemberManagementPanel({ initialMembers, currentUserId, inviteCode, + inviteEnabled, }: MemberManagementPanelProps) { const { members, @@ -30,7 +32,14 @@ export function MemberManagementPanel({ canInvite, isDuplicate, inviteByEmail, - } = useMemberManagement({ workspaceId, initialMembers, inviteCode }); + isInviteEnabled, + isTogglingInvite, + toggleInviteEnabled, + } = useMemberManagement({ workspaceId, initialMembers, inviteCode, inviteEnabled }); + + // 초대 링크 활성화는 RLS상 소유자만 변경할 수 있어, 토글도 소유자에게만 허용한다. + const canManageInvite = + initialMembers.find((member) => member.userId === currentUserId)?.role === 'owner'; return (
@@ -43,6 +52,10 @@ export function MemberManagementPanel({ isDuplicate={isDuplicate} onInviteByEmail={inviteByEmail} inviteLink={inviteLink} + isInviteEnabled={isInviteEnabled} + isTogglingInvite={isTogglingInvite} + onToggleInviteEnabled={toggleInviteEnabled} + canManageInvite={canManageInvite} />
diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index 8acb007..1805bfb 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -785,6 +785,14 @@ export type Database = { } Returns: string } + get_invite_preview: { + Args: { p_code: string } + Returns: { + member_count: number + name: string + workspace_id: string + }[] + } get_my_workspaces: { Args: { p_user_id: string } Returns: { @@ -811,6 +819,10 @@ export type Database = { workspace_id: string }[] } + join_workspace_by_invite_code: { + Args: { p_code: string; p_user_id: string } + Returns: string + } replace_and_delete_work_shift_type: { Args: { p_deleted_shift_type_id: string diff --git a/src/views/invite/index.ts b/src/views/invite/index.ts new file mode 100644 index 0000000..c67b30c --- /dev/null +++ b/src/views/invite/index.ts @@ -0,0 +1 @@ +export { InviteAcceptView } from './ui/InviteAcceptView'; diff --git a/src/views/invite/ui/InviteAcceptView.tsx b/src/views/invite/ui/InviteAcceptView.tsx new file mode 100644 index 0000000..a953fe8 --- /dev/null +++ b/src/views/invite/ui/InviteAcceptView.tsx @@ -0,0 +1,58 @@ +'use client'; + +// 초대 수락 화면 — 워크스페이스 요약을 보여주고 "참여하기"로 멤버 등록 후 워크스페이스로 이동한다. +// 참여는 joinWorkspaceByInviteCode 서버액션을 호출하며, 이미 멤버여도 멱등하게 입장한다. +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { joinWorkspaceByInviteCode } from '@/entities/workspace'; +import { plusJakartaSans } from '@/shared/lib/fonts'; + +interface InviteAcceptViewProps { + code: string; + workspaceName: string; + memberCount: number; +} + +export function InviteAcceptView({ code, workspaceName, memberCount }: InviteAcceptViewProps) { + const router = useRouter(); + const [isJoining, setIsJoining] = useState(false); + + const handleJoin = async () => { + setIsJoining(true); + try { + const { workspaceId } = await joinWorkspaceByInviteCode(code); + // 참여 성공 시 워크스페이스로 이동한다. 이동으로 언마운트되므로 isJoining은 리셋하지 않는다. + router.push(`/workspaces/${workspaceId}`); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '워크스페이스 참여에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + setIsJoining(false); + } + }; + + return ( +
+
+

워크스페이스 초대

+

{workspaceName}

+

+ 현재 멤버 {memberCount}명이 함께하고 있어요. +

+ + +
+
+ ); +} diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx index 1ef51c4..05207de 100644 --- a/src/views/settings/ui/SettingsView.tsx +++ b/src/views/settings/ui/SettingsView.tsx @@ -29,6 +29,9 @@ export function SettingsView({ currentUserId, currentNickname, }: SettingsViewProps) { + // 워크스페이스 정보 수정은 RLS상 소유자만 가능하므로, 현재 사용자의 역할로 편집 권한을 판단한다. + const isOwner = members.find((member) => member.userId === currentUserId)?.role === 'owner'; + return (

설정

@@ -36,13 +39,16 @@ export function SettingsView({
- {activeTab === 'workspace' && } + {activeTab === 'workspace' && ( + + )} {activeTab === 'members' && ( )} {activeTab === 'profile' && ( diff --git a/supabase/migrations/20260713010000_create_invite_rpcs.sql b/supabase/migrations/20260713010000_create_invite_rpcs.sql new file mode 100644 index 0000000..218f2cd --- /dev/null +++ b/supabase/migrations/20260713010000_create_invite_rpcs.sql @@ -0,0 +1,82 @@ +-- 초대 참여 도메인 RPC 2종 +-- 공통: auth 연동 전이므로 p_user_id 파라미터로 유저를 받는다 (연동 후 auth.uid()로 교체 예정) +-- 비멤버는 RLS(workspaces_select_member)로 워크스페이스를 조회할 수 없으므로 +-- 초대 코드 해석/참여는 security definer 함수로 RLS를 우회해 처리한다. + +-- 초대 미리보기 — 활성(invite_enabled=true) + 유효한 코드일 때만 워크스페이스 요약을 반환한다. +-- 비활성/무효 코드는 빈 결과(no rows)를 반환해 참여 페이지에서 안내 처리한다. +create or replace function public.get_invite_preview(p_code text) +returns table ( + workspace_id uuid, + name text, + member_count int +) +language sql +stable +security definer +set search_path = public, pg_temp +as $$ + select + w.id, + w.name, + (select count(*)::int from workspace_members wm where wm.workspace_id = w.id) + from workspaces w + where w.invite_code = p_code + and w.invite_enabled = true; +$$; + +-- 초대 코드로 워크스페이스 참여 — 활성/유효 검증 후 본인을 멤버로 추가하고 workspace_id를 반환한다. +-- 이미 멤버면 그대로 입장(멱등). 닉네임 기본값은 프로필 실명(create_workspace와 동일). +create or replace function public.join_workspace_by_invite_code( + p_user_id uuid, + p_code text +) +returns uuid +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_workspace_id uuid; + v_nickname text; +begin + -- 활성 + 유효 코드의 워크스페이스만 대상으로 한다. + select w.id into v_workspace_id + from workspaces w + where w.invite_code = p_code + and w.invite_enabled = true; + + if v_workspace_id is null then + raise exception 'invalid_or_disabled_invite' + using message = '유효하지 않거나 비활성화된 초대 링크입니다.'; + end if; + + -- 이미 참여한 멤버면 재참여 없이 그대로 입장한다(멱등). + if exists ( + select 1 from workspace_members + where workspace_id = v_workspace_id and user_id = p_user_id + ) then + return v_workspace_id; + end if; + + -- 프로필 없는 유저면 여기서 실패(no rows 에러). + select real_name into strict v_nickname from profiles where id = p_user_id; + + -- workspace_members는 (workspace_id, workspace_nickname) 유니크 제약이 있어 + -- 실명이 기존 멤버와 충돌하면 짧은 접미사를 붙여 한 번 재시도한다. + begin + insert into workspace_members (workspace_id, user_id, workspace_nickname, role) + values (v_workspace_id, p_user_id, v_nickname, 'member'); + exception when unique_violation then + insert into workspace_members (workspace_id, user_id, workspace_nickname, role) + values ( + v_workspace_id, + p_user_id, + v_nickname || '-' || substr(encode(extensions.gen_random_bytes(2), 'hex'), 1, 4), + 'member' + ); + end; + + return v_workspace_id; +end; +$$; From d13a5afc7c28adfe10c756b907d5737eba2b89a7 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Tue, 14 Jul 2026 09:38:07 +0900 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=EC=9A=A9=20resend=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 50 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 51 insertions(+) diff --git a/package-lock.json b/package-lock.json index 5316680..58c68be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "19.2.4", "react-grid-layout": "^2.2.3", "react-hook-form": "^7.80.0", + "resend": "^6.17.2", "shadcn": "^4.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", @@ -3301,6 +3302,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -6597,6 +6604,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", @@ -9350,6 +9363,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -9947,6 +9966,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.17.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.2.tgz", + "integrity": "sha512-hbaXEORFIFfT2Bh03NsA/akTTTKkD1hiuJ88ke64c5dWVV6DyoLxzFve3OiZqVOQ+JKLJ5uVkPR6hlUslpJFoA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -10565,6 +10605,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 6befaca..5921ca4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-dom": "19.2.4", "react-grid-layout": "^2.2.3", "react-hook-form": "^7.80.0", + "resend": "^6.17.2", "shadcn": "^4.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", From 930a15c06fad2ea3545f68222f699b0c5f617714 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Tue, 14 Jul 2026 09:38:28 +0900 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80(RESEND=5FAPI=5FKEY,=20INVITE=5FEMAIL=5FFROM)?= =?UTF-8?q?=20env=20=EC=98=88=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 5abe02a..71eeae5 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,9 @@ # PUBLISHABLE_KEY에는 publishable key(sb_publishable_...)를 사용한다 NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= + +# 이메일 초대 발송(Resend) — https://resend.com 에서 API 키 발급 +# INVITE_EMAIL_FROM은 Resend에서 인증한 발신 도메인 주소여야 한다. +# 도메인 인증 전에는 테스트 모드라 본인 계정 이메일로만 발송된다(미설정 시 onboarding@resend.dev 사용). +RESEND_API_KEY= +INVITE_EMAIL_FROM= From d57ddf8e55cdd4c98ecddec75d6262514b3fb9e7 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Tue, 14 Jul 2026 09:39:04 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20=EB=A7=81=ED=81=AC=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=95=A1=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/api/send-invite-email.ts | 75 +++++++++++++++++++ src/entities/workspace/index.ts | 1 + src/shared/api/email/env.ts | 14 ++++ 3 files changed, 90 insertions(+) create mode 100644 src/entities/workspace/api/send-invite-email.ts create mode 100644 src/shared/api/email/env.ts diff --git a/src/entities/workspace/api/send-invite-email.ts b/src/entities/workspace/api/send-invite-email.ts new file mode 100644 index 0000000..12a35f2 --- /dev/null +++ b/src/entities/workspace/api/send-invite-email.ts @@ -0,0 +1,75 @@ +'use server'; + +// 이메일 초대 발송 서버액션 — 입력한 이메일로 초대 링크(invite_code)를 담은 메일을 보낸다. +// 초대 테이블/프로필 조회 없이 링크만 전달하므로, 수신자는 링크로 참여(/invite/[code])한다. +import { headers } from 'next/headers'; +import { Resend } from 'resend'; +import { z } from 'zod'; +import { getEmailEnv } from '@/shared/api/email/env'; +import { getWorkspaceById } from './get-workspace-by-id'; + +const sendInviteEmailInputSchema = z.object({ + workspaceId: z.guid(), + email: z.email('올바른 이메일 주소를 입력해주세요'), +}); + +export type SendInviteEmailInput = z.infer; + +// 서버에서 요청 헤더로 baseURL을 구성한다(클라이언트 origin에 의존하지 않음). +async function resolveOrigin(): Promise { + const headerList = await headers(); + const host = headerList.get('host'); + const proto = headerList.get('x-forwarded-proto') ?? 'http'; + return host ? `${proto}://${host}` : ''; +} + +export async function sendInviteEmail(input: SendInviteEmailInput): Promise { + const parsed = sendInviteEmailInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error(parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다'); + } + + // RLS(workspaces_select_member)로 멤버만 조회 가능 → 비멤버의 발송을 차단한다. + const workspace = await getWorkspaceById(parsed.data.workspaceId); + if (!workspace) { + throw new Error('워크스페이스를 찾을 수 없거나 접근 권한이 없습니다.'); + } + + if (!workspace.inviteEnabled || !workspace.inviteCode) { + throw new Error('초대 링크를 먼저 활성화해주세요.'); + } + + const origin = await resolveOrigin(); + if (!origin) { + throw new Error('초대 링크 주소를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'); + } + + const inviteUrl = `${origin}/invite/${workspace.inviteCode}`; + const { apiKey, from } = getEmailEnv(); + const resend = new Resend(apiKey); + + const { error } = await resend.emails.send({ + from, + to: parsed.data.email, + subject: `[Syncly] ${workspace.name} 워크스페이스에 초대되었어요`, + text: `${workspace.name} 워크스페이스에 초대되었습니다.\n아래 링크로 참여하세요:\n${inviteUrl}`, + html: ` +
+

${workspace.name} 워크스페이스에 초대되었어요.

+

아래 버튼을 눌러 참여하세요.

+

+ + 워크스페이스 참여하기 + +

+

버튼이 동작하지 않으면 이 링크를 복사해 열어주세요:
${inviteUrl}

+
+ `, + }); + + if (error) { + console.error('[sendInviteEmail] 발송 실패:', error); + throw new Error('초대 메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index ff1f560..d6de516 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -30,6 +30,7 @@ export { type UpdateWorkspaceInfoInput, } from './api/update-workspace-info'; export { joinWorkspaceByInviteCode } from './api/join-workspace-by-invite-code'; +export { sendInviteEmail, type SendInviteEmailInput } from './api/send-invite-email'; export { setWorkspaceInviteEnabled, type SetWorkspaceInviteEnabledInput, diff --git a/src/shared/api/email/env.ts b/src/shared/api/email/env.ts new file mode 100644 index 0000000..9f886e7 --- /dev/null +++ b/src/shared/api/email/env.ts @@ -0,0 +1,14 @@ +// 이메일(Resend) 환경 변수 접근 — 누락 시 초기에 바로 실패시켜 원인 파악을 쉽게 한다 +export function getEmailEnv() { + const apiKey = process.env.RESEND_API_KEY; + // 발신 주소는 Resend에서 인증한 도메인이어야 한다. 미설정 시 테스트용 온보딩 주소로 폴백한다. + const from = process.env.INVITE_EMAIL_FROM ?? 'Syncly '; + + if (!apiKey) { + throw new Error( + 'RESEND_API_KEY 환경 변수가 필요합니다 (.env.local 확인 — 양식은 .env.example 참고)', + ); + } + + return { apiKey, from }; +} From 4e079458cdbe7cfe7f78f9e254def3a84ed80410 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Tue, 14 Jul 2026 09:39:48 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=B0=9C=EC=86=A1=EC=9C=BC=EB=A1=9C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/use-member-management.ts | 58 ++++++++----------- .../ui/MemberInviteSection.tsx | 8 ++- .../ui/MemberManagementPanel.tsx | 2 + 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/features/manage-workspace-members/model/use-member-management.ts b/src/features/manage-workspace-members/model/use-member-management.ts index e0cb895..866f196 100644 --- a/src/features/manage-workspace-members/model/use-member-management.ts +++ b/src/features/manage-workspace-members/model/use-member-management.ts @@ -1,12 +1,12 @@ 'use client'; // 팀원 초대(이메일/링크)와 멤버 목록 상태를 관리합니다. -// 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받고, 훅은 로컬 상태로 seed만 한다. +// 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받는다. // 초대 링크는 서버에서 발급된 inviteCode와 런타임 origin으로 구성한다. -// 이메일 초대는 백엔드 미비로 아직 목록에 '초대됨' 멤버를 추가하는 목업으로 동작한다. +// 이메일 초대는 입력한 주소로 초대 링크가 담긴 메일을 발송한다(sendInviteEmail). import { useMemo, useState, useSyncExternalStore } from 'react'; import { toast } from 'sonner'; -import { setWorkspaceInviteEnabled } from '@/entities/workspace'; +import { sendInviteEmail, setWorkspaceInviteEnabled } from '@/entities/workspace'; import type { WorkspaceMember } from '@/entities/workspace-member'; export type InviteMode = 'email' | 'link'; @@ -20,22 +20,6 @@ interface UseMemberManagementParams { const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -function createMemberId() { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `member-${crypto.randomUUID()}`; - } - - return `member-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -function toNickname(email: string) { - return email.split('@')[0]; -} - -function toAvatarLabel(email: string) { - return email.charAt(0).toUpperCase(); -} - // origin은 클라이언트에서만 알 수 있는 값이라 useSyncExternalStore로 읽는다. // 서버 스냅샷은 빈 문자열이라 하이드레이션 불일치가 발생하지 않고, origin은 변하지 않으므로 구독은 no-op이다. const subscribeToOrigin = () => () => {}; @@ -48,9 +32,10 @@ export function useMemberManagement({ inviteCode, inviteEnabled, }: UseMemberManagementParams) { - const [members, setMembers] = useState(initialMembers); + const [members] = useState(initialMembers); const [inviteMode, setInviteMode] = useState('email'); const [email, setEmail] = useState(''); + const [isSendingInvite, setIsSendingInvite] = useState(false); // 초대 링크 활성 여부는 서버값으로 seed하고, 토글은 낙관적으로 반영 후 실패 시 롤백한다. const [isInviteEnabled, setIsInviteEnabled] = useState(inviteEnabled); @@ -94,26 +79,28 @@ export function useMemberManagement({ () => members.some((member) => member.email.toLowerCase() === trimmedEmail.toLowerCase()), [members, trimmedEmail], ); - const canInvite = EMAIL_PATTERN.test(trimmedEmail) && !isDuplicate; + const canInvite = EMAIL_PATTERN.test(trimmedEmail) && !isDuplicate && !isSendingInvite; - const inviteByEmail = () => { + const inviteByEmail = async () => { if (!canInvite) { return; } - setMembers((previous) => [ - ...previous, - { - workspaceId, - userId: createMemberId(), - workspaceNickname: toNickname(trimmedEmail), - avatarLabel: toAvatarLabel(trimmedEmail), - email: trimmedEmail, - role: 'member', - status: 'invited', - }, - ]); - setEmail(''); + setIsSendingInvite(true); + try { + await sendInviteEmail({ workspaceId, email: trimmedEmail }); + toast.success('초대 메일을 보냈어요.'); + setEmail(''); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : '초대 메일 발송에 실패했습니다. 잠시 후 다시 시도해주세요.', + ); + } finally { + setIsSendingInvite(false); + } }; return { @@ -126,6 +113,7 @@ export function useMemberManagement({ canInvite, isDuplicate, inviteByEmail, + isSendingInvite, isInviteEnabled, isTogglingInvite, toggleInviteEnabled, diff --git a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx index 0ebdf53..2e86419 100644 --- a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx +++ b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx @@ -14,6 +14,7 @@ interface MemberInviteSectionProps { canInvite: boolean; isDuplicate: boolean; onInviteByEmail: () => void; + isSendingInvite: boolean; inviteLink: string; isInviteEnabled: boolean; isTogglingInvite: boolean; @@ -34,6 +35,7 @@ export function MemberInviteSection({ canInvite, isDuplicate, onInviteByEmail, + isSendingInvite, inviteLink, isInviteEnabled, isTogglingInvite, @@ -110,7 +112,7 @@ export function MemberInviteSection({ className="flex h-11 shrink-0 items-center gap-2 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50" >