Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions src/app/invite/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -p 'src/app/invite' --type f

Repository: TeampleRun/syncly

Length of output: 186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== invite route files =="
fd -a . src/app/invite

echo
echo "== page.tsx (numbered) =="
cat -n src/app/invite/[code]/page.tsx

echo
echo "== search getInvitePreview usage/definition =="
rg -n "getInvitePreview|error\.tsx" src/app src -g '!**/node_modules/**' || true

Repository: TeampleRun/syncly

Length of output: 2705


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== get-invite-preview implementation =="
cat -n src/entities/workspace/api/get-invite-preview.ts

echo
echo "== error.tsx under src/app/invite =="
fd -a 'error.tsx' src/app/invite || true

echo
echo "== error.tsx under src/app =="
fd -a 'error.tsx' src/app || true

echo
echo "== search for invite route error boundaries =="
rg -n "error\.tsx|ErrorBoundary" src/app/invite src/app -g '!**/node_modules/**' || true

Repository: TeampleRun/syncly

Length of output: 1377


초대 조회 실패도 사용자용 처리로 분기하세요 getInvitePreview가 예외를 던지는데, 여기서는 null만 처리해서 RPC 실패 시 기본 에러 화면으로 떨어집니다. 이 라우트에 error.tsx를 두거나 여기서 예외를 잡아 초대 화면용 실패 메시지를 보여주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/invite/`[code]/page.tsx at line 14, Update the invite route around
getInvitePreview(code) so RPC exceptions follow the invite-specific user-facing
failure path instead of reaching the generic error screen. Either add the
route’s error.tsx handling or catch the exception in the page and render the
existing invite failure message, while preserving the current null-result
handling.


if (!preview) {
return (
<div
className={`${plusJakartaSans.className} flex min-h-dvh items-center justify-center px-4`}
>
<section className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<h1 className="text-xl font-bold text-slate-950">유효하지 않은 초대예요</h1>
<p className="mt-2 text-sm text-slate-500">
링크가 만료되었거나 초대가 비활성화되었을 수 있어요. 초대한 사람에게 새 링크를
요청해 주세요.
</p>
<Link
href="/workspaces"
className="mt-8 inline-flex h-11 items-center justify-center rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500"
>
내 워크스페이스로
</Link>
</section>
</div>
);
}
Comment on lines +16 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

초대 실패 화면 UI를 app 레이어에 직접 구현했습니다.

InvitePage는 유효한 초대는 InviteAcceptView로 위임하지만, "유효하지 않은 초대" 화면은 app 레이어에서 직접 마크업을 구현하고 있습니다. src/views/inviteInviteInvalidView 같은 뷰 컴포넌트를 추가해 이 분기도 위임하면 일관성이 생기고, InviteAcceptView와 공유하는 카드/레이아웃 스타일도 재사용할 수 있습니다.

♻️ 제안: 뷰로 분리
-  if (!preview) {
-    return (
-      <div
-        className={`${plusJakartaSans.className} flex min-h-dvh items-center justify-center px-4`}
-      >
-        <section className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
-          <h1 className="text-xl font-bold text-slate-950">유효하지 않은 초대예요</h1>
-          <p className="mt-2 text-sm text-slate-500">
-            링크가 만료되었거나 초대가 비활성화되었을 수 있어요. 초대한 사람에게 새 링크를
-            요청해 주세요.
-          </p>
-          <Link
-            href="/workspaces"
-            className="mt-8 inline-flex h-11 items-center justify-center rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500"
-          >
-            내 워크스페이스로
-          </Link>
-        </section>
-      </div>
-    );
-  }
+  if (!preview) {
+    return <InviteInvalidView />;
+  }

As per path instructions for src/app/**/*.{ts,tsx}: "compose screens by importing views rather than implementing page UI directly in the app layer."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/invite/`[code]/page.tsx around lines 16 - 36, Move the invalid-invite
markup from the InvitePage !preview branch into a new InviteInvalidView under
the invite views layer, then render that view from InvitePage. Preserve the
existing Korean text, workspace link, and visual styling, while reusing shared
invite card/layout styles where appropriate.

Source: Path instructions


return (
<InviteAcceptView
code={code}
workspaceName={preview.name}
memberCount={preview.memberCount}
/>
);
}
19 changes: 11 additions & 8 deletions src/app/workspaces/[workspaceId]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -29,16 +26,22 @@ 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 (
<SettingsView
workspace={workspace}
workspaceId={workspaceId}
activeTab={parseSettingsTab(tab)}
members={members}
currentUserId={mockCurrentWorkspaceMember.userId}
currentNickname={mockCurrentWorkspaceMember.workspaceNickname}
currentUserId={currentUserId}
currentNickname={currentNickname}
/>
);
}
45 changes: 45 additions & 0 deletions src/entities/workspace-member/api/update-my-nickname.ts
Original file line number Diff line number Diff line change
@@ -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<typeof updateMyNicknameInputSchema>;

export async function updateMyNickname(input: UpdateMyNicknameInput): Promise<void> {
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`);
}
3 changes: 3 additions & 0 deletions src/entities/workspace-member/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/entities/workspace/api/get-invite-preview.ts
Original file line number Diff line number Diff line change
@@ -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<InvitePreview | null> => {
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,
};
});
4 changes: 3 additions & 1 deletion src/entities/workspace/api/get-workspace-by-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const getWorkspaceById = cache(async (workspaceId: string): Promise<Works
const supabase = await createSupabaseServerClient();
const { data, error } = await supabase
.from('workspaces')
.select('id, name, description, purpose')
.select('id, name, description, purpose, invite_code, invite_enabled')
.eq('id', workspaceId)
.maybeSingle();

Expand All @@ -25,5 +25,7 @@ export const getWorkspaceById = cache(async (workspaceId: string): Promise<Works
name: data.name,
description: data.description ?? undefined,
purpose: toUiPurpose(data.purpose),
inviteCode: data.invite_code,
inviteEnabled: data.invite_enabled,
};
});
28 changes: 28 additions & 0 deletions src/entities/workspace/api/join-workspace-by-invite-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use server';

// 초대 코드로 워크스페이스 참여 서버액션 — join_workspace_by_invite_code RPC
// (활성/유효 검증 + 멤버십 insert를 security definer 함수가 트랜잭션으로 처리)
import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

export async function joinWorkspaceByInviteCode(code: string): Promise<{ workspaceId: string }> {
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 };
}
Loading