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=
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",
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/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-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 dc1b68d..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/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/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/api/update-workspace-info.ts b/src/entities/workspace/api/update-workspace-info.ts
new file mode 100644
index 0000000..4bab60d
--- /dev/null
+++ b/src/entities/workspace/api/update-workspace-info.ts
@@ -0,0 +1,37 @@
+'use server';
+
+// 워크스페이스 정보(이름·설명) 수정 서버액션 — workspaces update
+// RLS(workspaces_update_owner)에 의해 소유자만 수정할 수 있다.
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+import { createSupabaseServerClient } from '@/shared/api/supabase/server';
+import { createWorkspaceSchema } from '../model/create-workspace.schema';
+
+// 생성 폼과 동일한 이름·설명 검증에 대상 워크스페이스 id를 더한다.
+const updateWorkspaceInfoInputSchema = createWorkspaceSchema.extend({ id: z.guid() });
+
+export type UpdateWorkspaceInfoInput = z.infer;
+
+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..d6de516 100644
--- a/src/entities/workspace/index.ts
+++ b/src/entities/workspace/index.ts
@@ -25,3 +25,13 @@ 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';
+export { joinWorkspaceByInviteCode } from './api/join-workspace-by-invite-code';
+export { sendInviteEmail, type SendInviteEmailInput } from './api/send-invite-email';
+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 d3a11a1..911d49a 100644
--- a/src/entities/workspace/model/workspace.types.ts
+++ b/src/entities/workspace/model/workspace.types.ts
@@ -7,6 +7,10 @@ export interface Workspace {
purpose: WorkspacePurpose;
// 설정 페이지에서 편집하는 워크스페이스 소개 문구입니다. (선택 값)
description?: string;
+ // 초대 링크 구성에 사용하는 초대 코드입니다. 생성 시 발급되며 없을 수 있습니다.
+ inviteCode?: string | null;
+ // 초대 링크 활성화 여부입니다. false면 코드가 있어도 참여가 거부됩니다.
+ inviteEnabled?: boolean;
}
// 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..6eca420 100644
--- a/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx
+++ b/src/features/manage-workspace-info/ui/WorkspaceInfoForm.tsx
@@ -1,15 +1,18 @@
'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;
+ // 워크스페이스 정보 수정은 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 ?? '');
@@ -17,28 +20,53 @@ 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 = canEdit && 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 (
워크스페이스 정보
- 워크스페이스 이름과 설명을 수정할 수 있어요.
+
+ {canEdit
+ ? '워크스페이스 이름과 설명을 수정할 수 있어요.'
+ : '워크스페이스 정보는 소유자만 수정할 수 있어요.'}
+
);
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..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,9 +1,12 @@
'use client';
// 팀원 초대(이메일/링크)와 멤버 목록 상태를 관리합니다.
-// 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받고, 훅은 로컬 상태로 seed만 한다.
-// 백엔드 연동 전이므로 초대는 목록에 '초대됨' 상태의 멤버를 추가하는 목업으로 동작합니다.
-import { useMemo, useState } from 'react';
+// 멤버 목록은 서버(RSC)에서 조회해 initialMembers로 주입받는다.
+// 초대 링크는 서버에서 발급된 inviteCode와 런타임 origin으로 구성한다.
+// 이메일 초대는 입력한 주소로 초대 링크가 담긴 메일을 발송한다(sendInviteEmail).
+import { useMemo, useState, useSyncExternalStore } from 'react';
+import { toast } from 'sonner';
+import { sendInviteEmail, setWorkspaceInviteEnabled } from '@/entities/workspace';
import type { WorkspaceMember } from '@/entities/workspace-member';
export type InviteMode = 'email' | 'link';
@@ -11,59 +14,93 @@ export type InviteMode = 'email' | 'link';
interface UseMemberManagementParams {
workspaceId: string;
initialMembers: WorkspaceMember[];
+ inviteCode: string | null;
+ inviteEnabled: boolean;
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-function createMemberId() {
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
- return `member-${crypto.randomUUID()}`;
- }
+// origin은 클라이언트에서만 알 수 있는 값이라 useSyncExternalStore로 읽는다.
+// 서버 스냅샷은 빈 문자열이라 하이드레이션 불일치가 발생하지 않고, origin은 변하지 않으므로 구독은 no-op이다.
+const subscribeToOrigin = () => () => {};
+const getOriginSnapshot = () => window.location.origin;
+const getOriginServerSnapshot = () => '';
- return `member-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
-}
+export function useMemberManagement({
+ workspaceId,
+ initialMembers,
+ inviteCode,
+ inviteEnabled,
+}: UseMemberManagementParams) {
+ const [members] = useState(initialMembers);
+ const [inviteMode, setInviteMode] = useState('email');
+ const [email, setEmail] = useState('');
+ const [isSendingInvite, setIsSendingInvite] = useState(false);
-function toNickname(email: string) {
- return email.split('@')[0];
-}
+ // 초대 링크 활성 여부는 서버값으로 seed하고, 토글은 낙관적으로 반영 후 실패 시 롤백한다.
+ const [isInviteEnabled, setIsInviteEnabled] = useState(inviteEnabled);
+ const [isTogglingInvite, setIsTogglingInvite] = useState(false);
-function toAvatarLabel(email: string) {
- return email.charAt(0).toUpperCase();
-}
+ const toggleInviteEnabled = async () => {
+ if (isTogglingInvite) {
+ return;
+ }
-export function useMemberManagement({ workspaceId, initialMembers }: UseMemberManagementParams) {
- const [members, setMembers] = useState(initialMembers);
- const [inviteMode, setInviteMode] = useState('email');
- const [email, setEmail] = useState('');
+ 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 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(
() => 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 {
@@ -76,5 +113,9 @@ export function useMemberManagement({ workspaceId, initialMembers }: UseMemberMa
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 068110a..2e86419 100644
--- a/src/features/manage-workspace-members/ui/MemberInviteSection.tsx
+++ b/src/features/manage-workspace-members/ui/MemberInviteSection.tsx
@@ -14,7 +14,12 @@ interface MemberInviteSectionProps {
canInvite: boolean;
isDuplicate: boolean;
onInviteByEmail: () => void;
+ isSendingInvite: boolean;
inviteLink: string;
+ isInviteEnabled: boolean;
+ isTogglingInvite: boolean;
+ onToggleInviteEnabled: () => void;
+ canManageInvite: boolean;
}
const INVITE_MODES: { key: InviteMode; label: string }[] = [
@@ -30,11 +35,22 @@ export function MemberInviteSection({
canInvite,
isDuplicate,
onInviteByEmail,
+ isSendingInvite,
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);
@@ -96,37 +112,77 @@ 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"
>
- 초대
+ {isSendingInvite ? '보내는 중…' : '초대'}
) : (
-
-
-
-
{inviteLink}
+ <>
+
+
+
초대 링크 활성화
+
+ {canManageInvite
+ ? '켜면 링크를 아는 사람이 워크스페이스에 참여할 수 있어요.'
+ : '초대 링크는 워크스페이스 소유자만 변경할 수 있어요.'}
+
+
+
+ 초대 링크 활성화
+
+
-
- {isCopied ? (
-
- ) : (
-
- )}
- {isCopied ? '복사됨' : '복사'}
-
-
+
+
+
+
+
+ {isInviteEnabled ? inviteLink : '초대 링크가 비활성화되어 있어요.'}
+
+
+
+ {isCopied ? (
+
+ ) : (
+
+ )}
+ {isCopied ? '복사됨' : '복사'}
+
+
+ >
)}
-
- {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 9af4c43..0993c75 100644
--- a/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx
+++ b/src/features/manage-workspace-members/ui/MemberManagementPanel.tsx
@@ -11,12 +11,16 @@ interface MemberManagementPanelProps {
workspaceId: string;
initialMembers: WorkspaceMember[];
currentUserId: string;
+ inviteCode: string | null;
+ inviteEnabled: boolean;
}
export function MemberManagementPanel({
workspaceId,
initialMembers,
currentUserId,
+ inviteCode,
+ inviteEnabled,
}: MemberManagementPanelProps) {
const {
members,
@@ -28,7 +32,15 @@ export function MemberManagementPanel({
canInvite,
isDuplicate,
inviteByEmail,
- } = useMemberManagement({ workspaceId, initialMembers });
+ isSendingInvite,
+ isInviteEnabled,
+ isTogglingInvite,
+ toggleInviteEnabled,
+ } = useMemberManagement({ workspaceId, initialMembers, inviteCode, inviteEnabled });
+
+ // 초대 링크 활성화는 RLS상 소유자만 변경할 수 있어, 토글도 소유자에게만 허용한다.
+ const canManageInvite =
+ initialMembers.find((member) => member.userId === currentUserId)?.role === 'owner';
return (
@@ -40,7 +52,12 @@ export function MemberManagementPanel({
canInvite={canInvite}
isDuplicate={isDuplicate}
onInviteByEmail={inviteByEmail}
+ isSendingInvite={isSendingInvite}
inviteLink={inviteLink}
+ isInviteEnabled={isInviteEnabled}
+ isTogglingInvite={isTogglingInvite}
+ onToggleInviteEnabled={toggleInviteEnabled}
+ canManageInvite={canManageInvite}
/>
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 };
+}
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}명이 함께하고 있어요.
+
+
+
+ {isJoining ? '참여하는 중…' : '참여하기'}
+
+
+
+ );
+}
diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx
index 0dddc57..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,15 +39,21 @@ export function SettingsView({
- {activeTab === 'workspace' && }
+ {activeTab === 'workspace' && (
+
+ )}
{activeTab === 'members' && (
)}
- {activeTab === 'profile' && }
+ {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;
+$$;