Skip to content

[feat] 멤버 Actions 드롭다운 및 Export 기능 구현 - #240

Merged
haesol2022 merged 30 commits into
devfrom
feat/#117/members-actions
Apr 17, 2026
Merged

[feat] 멤버 Actions 드롭다운 및 Export 기능 구현#240
haesol2022 merged 30 commits into
devfrom
feat/#117/members-actions

Conversation

@haesol2022

@haesol2022 haesol2022 commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

📌 Summary

멤버 페이지의 미구현 기능(Actions 드롭다운, 멤버 리스트 PDF/CSV Export) 완성

📄 Tasks

Quick Actions

  • exportMembersAction, exportMemberGrassAction 서버 액션 추가 (manager만 가능)
  • MemberExportButton 컴포넌트 추가, manager에게만 노출
  • PDF / CSV 두 가지 형식으로 export 지원

PDF export

  • jsPDF + jspdf-autotable 동적 import (번들 사이즈 최적화)
  • 1페이지: 멤버 목록 테이블 (이름 / 이메일 / 역할 / 가입일) + 12주 활동 점수 랭킹
  • 이후 멤버별 1페이지씩: 활동 잔디 그리드 + 날짜별 활동 상세 테이블
  • 잔디 색상은 스페이스 테마 컬러 기반, intensity에 따라 불투명도 조절

CSV export

  • 멤버별 이름 / 이메일 / 역할 / 가입일 / 12주 총점 / 활동한 날 / 연속 활동 / 오늘 점수 / 최근 7일 점수 포함
  • BOM(\uFEFF) 추가로 Excel 한글 깨짐 방지

멤버 리스트

  • Status 컬럼 제거 (member-table.tsx, member-row.tsx, MemberTableSkeleton)
  • Actions 드롭다운 구현 - 공통 Dropdown 미사용, shadcn DropdownMenu 직접 사용
    • 공통 컴포넌트가 cn()으로 text-sm sm:text-base를 고정 적용해 className prop으로 폰트 크기 오버라이드 불가한 이슈

역할 변경 가능 조건
(manager만 누구든 역할 변경 가능, 나머지는 전부 불가)

요청자 \ 대상 manager moderator member
manager O O O
moderator X X X
member X X X

추방 가능 조건

요청자 \ 대상 manager moderator member
manager O O O
moderator X X O
member X X X

추가 제약

  • 자기 자신은 추방 불가
  • manager는 최소 1명 보장 - 마지막 manager는 역할 변경 및 추방 불가
    • 단, 다른 사람을 manager로 먼저 올린 뒤 본인을 강등하는 것은 가능

이슈 대비 추가 작업

  • CSV export 추가
  • 마지막 manager 보호 로직 추가 (manager 최소 1명 보장)
  • 자기 자신 추방 불가 처리 추가
  • 멤버 목록 본인 최상단 고정 추가
  • PDF export에 가입일 + 12주 활동 랭킹 테이블 추가

별도 Issue 사항

  • 소셜 로그인 유저의 이메일이 PDF/CSV export 시 -로 표시
  • GET /{teamId}/meetings/{meetingId}/participants 응답의 user 객체에 email 필드가 없어 멤버 승인 시점에 저장이 불가한 상태
  • 필요시 백엔드 측에 user.email 응답 추가 요청 예정

기타

  • getSpaceMembersUseCasecurrentUserId 옵션 추가, 본인을 목록 최상단에 고정
  • changeRoleAction 실행 시 CACHE_TAGS.member도 추가 무효화

👀 To Reviewer

  • 마지막 manager 보호 로직이 kick-member, change-role 양쪽에 중복으로 존재하고 있습니다. 로직 규모가 크지 않아 현재는 그대로 유지했는데, 별도 함수로 분리하는 게 나을지 의견 부탁드립니다.

  • moderator의 추방 대상 제한 로직을 현재 assertCanKick에서 처리하고 있습니다. 권한 검증 책임을 assert 함수에 두는 게 맞는지, 아니면 use-case로 옮기는 게 적절한지 의견 부탁드립니다.

  • jsPDF는 SVG 삽입 미지원으로 로고를 /public/images/moum-zip-logo.png (PNG)로 삽입했습니다.

  • jsPDF는 woff 폰트 미지원으로 ttf 파일을 base64로 변환해 PDF에 추가했습니다.

  • 현재 /public/fonts/Pretendard-Regular.ttf, Pretendard-Bold.ttf를 직접 추가했는데, 이 위치가 적절한지 또는 다른 관리 방법이 있는지 의견 부탁드립니다.

  • PDF 레이아웃 디자인이 괜찮은지, 추가될 내용이 있다면 의견 주시면 감사하겠습니다 ㅎㅎ

📸 Screenshot

스크린샷 2026-04-16 160558 image

@haesol2022 haesol2022 self-assigned this Apr 16, 2026
@haesol2022
haesol2022 requested a review from a team as a code owner April 16, 2026 07:00
@haesol2022
haesol2022 requested review from Jeayeong-Hong and removed request for a team April 16, 2026 07:00
@haesol2022 haesol2022 added ✨ Feature 새로운 기능 추가 / 퍼블리싱 🛠️ Refactor 코드 리팩토링, QA 반영 labels Apr 16, 2026
@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

멤버 페이지에 권한 기반 멤버 관리 기능을 추가합니다. 마네저는 다른 멤버의 역할을 변경하고, 마네저/모더레이터는 멤버를 추방할 수 있으며, 마네저는 멤버 목록을 PDF 및 CSV로 내보낼 수 있습니다. 새로운 서버 액션, 유스케이스, 권한 검증 함수가 추가됩니다.

Changes

Cohort / File(s) Summary
의존성 추가
apps/web/package.json
jspdf 및 jspdf-autotable 패키지 추가
서버 액션
apps/web/src/_pages/members/action.ts
멤버 추방, 역할 변경, 멤버 목록/잔디 데이터 내보내기 서버 액션 추가
멤버 내보내기 UI
apps/web/src/_pages/members/components/member-export-button.tsx
PDF/CSV 내보내기 기능을 포함한 MemberExportButton 컴포넌트 추가
멤버 테이블 및 행
apps/web/src/_pages/members/ui/member-table.tsx, apps/web/src/_pages/members/ui/member-row.tsx
상태 컬럼 제거, 멤버 행에 권한 기반 액션 드롭다운 추가, currentUserId 전달
권한 기반 유스케이스
apps/web/src/_pages/members/use-cases/kick-member.ts, apps/web/src/_pages/members/use-cases/change-role.ts, apps/web/src/_pages/members/use-cases/get-space-members.ts
멤버 추방, 역할 변경 유스케이스 추가 및 currentUserId 기반 멤버 순서 변경 기능 추가
권한 검증 유틸
apps/web/src/features/space/lib/assert-permission.ts
멤버 추방 및 역할 변경 권한 검증 함수 추가
페이지 및 관련 컴포넌트
apps/web/src/app/[space-slug]/members/page.tsx, apps/web/src/app/[space-slug]/members/_components/member-right-section.tsx
멤버 내보내기 버튼 추가, currentUserId 전달, 멤버 순서 정렬 로직 추가

Sequence Diagram(s)

sequenceDiagram
    actor User as 사용자 (마네저)
    participant Client as 클라이언트 (MemberRow)
    participant ServerAction as 서버 액션
    participant UseCase as 유스케이스 (changeRoleUseCase)
    participant PermLib as 권한 검증
    participant DB as 데이터베이스
    participant Cache as 캐시 무효화

    User->>Client: 멤버 역할 변경 클릭
    Client->>ServerAction: changeRoleAction(slug, userId, newRole)
    ServerAction->>UseCase: changeRoleUseCase(spaceId, targetUserId, newRole, requester)
    UseCase->>PermLib: assertCanChangeRole(requester)
    PermLib-->>UseCase: 권한 확인 (마네저만 가능)
    UseCase->>DB: 모든 멤버 조회
    DB-->>UseCase: 멤버 목록
    UseCase->>DB: 마네저 카운트 검증
    alt 유효한 변경
        UseCase->>DB: 멤버 역할 업데이트
        DB-->>UseCase: 성공
        UseCase->>Cache: CACHE_TAGS.members 무효화
        Cache-->>ServerAction: 캐시 갱신
    else 마지막 마네저 오류
        UseCase-->>ServerAction: Error: 마지막 마네저 제거 불가
    end
    ServerAction-->>Client: 결과 반환
    Client-->>User: 토스트 메시지 표시
Loading
sequenceDiagram
    actor User as 사용자 (마네저)
    participant Client as 클라이언트 (MemberExportButton)
    participant ServerAction as 서버 액션
    participant UseCase as 유스케이스 (memberQueries, getGrassUseCase)
    participant Export as 내보내기 로직
    participant File as 파일 생성

    User->>Client: PDF 내보내기 클릭
    Client->>ServerAction: exportMembersAction(slug)
    ServerAction->>UseCase: 권한 검증 (마네저만)
    UseCase->>UseCase: memberQueries.findAllBySpaceId
    UseCase-->>ServerAction: 멤버 목록 반환
    Client->>ServerAction: exportMemberGrassAction(slug, userId) [각 멤버별]
    ServerAction->>UseCase: getGrassUseCase(spaceId, userId)
    UseCase-->>ServerAction: 잔디 데이터 반환
    Client->>Export: jsPDF + jspdf-autotable로 PDF 생성
    Export->>File: 폰트, 로고, 멤버 목록 페이지 추가
    Export->>File: 멤버별 잔디 리포트 페이지 추가 (색상 그리드, 요약)
    File-->>Client: PDF Blob 생성
    Client-->>User: PDF 파일 다운로드
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • Jeayeong-Hong
  • miloupark

Poem

🐰 멤버 관리의 마법이 피어나니
역할 변경과 추방의 권한 체계,
PDF와 CSV로 데이터 담아내고,
잔디 리포트 페이지마다 반짝이네!
마네저만의 특별한 권한으로,
팀의 흐름을 우아하게 그려낸다. 📊✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed PR은 Issue #117의 모든 핵심 요구사항을 구현했습니다: PDF/CSV export, Status 컬럼 제거, Actions 드롭다운(역할 변경/추방), 권한별 노출, assertPermission 패턴 준수, 서버 액션 구현.
Out of Scope Changes check ✅ Passed 모든 변경사항이 Issue #117의 멤버 페이지 기능 구현과 직접 관련되어 있습니다. 불필요한 범위 외 변경은 없습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/_pages/members/action.ts`:
- Around line 42-51: The new kickMemberAction is missing the error handling
pattern used by addSpaceMemberAction; update kickMemberAction to append the same
.catch(handleAppError) (or if intentional, add a clear comment) so errors from
getSpaceContext, kickMemberUseCase or updateTag are handled consistently;
reference the kickMemberAction function and use the existing handleAppError
utility (same pattern as addSpaceMemberAction) and ensure calls involving
getSpaceContext, kickMemberUseCase and updateTag(CACHE_TAGS.members(...)) are
covered.
- Around line 83-87: The exportMemberGrassAction function is missing the
manager-role authorization present in exportMembersAction; update
exportMemberGrassAction to retrieve the current user/session after
getSpaceContext(slug) and perform the same manager role check used in
exportMembersAction (the same helper or logic that verifies the caller is a
space manager) before calling getGrassUseCase(space.spaceId, userId), so only
managers can request another member's grass data.

In `@apps/web/src/_pages/members/components/member-export-button.tsx`:
- Line 185: The reduce initializes with grass.days[0], which is undefined when
grass.days is empty and leads to runtime errors when accessing
bestDay.date/score; update the logic in member-export-button.tsx (the const
bestDay declaration) to guard against an empty array—either return a sensible
default object (e.g., {date: null, score: 0}) before calling reduce or
short-circuit and handle the empty case (early return or conditional rendering)
so subsequent accesses to bestDay.date and bestDay.score are safe.
- Line 151: The export is producing "Invalid Date" because formatDate is being
passed the string "-" for missing joinedAt; update the code so formatDate
gracefully handles non-date inputs (e.g., "-" or undefined) by returning a
fallback string (like "-" or empty) instead of attempting to parse, or change
exportMembersAction to pass null/undefined for missing joinedAt and have
formatDate check for falsy values; specifically, adjust the formatDate function
to detect non-date values (NaN after Date parsing or inputs === "-" or falsy)
and return the fallback, and ensure the members mapping in
member-export-button.tsx (the body: members.map(...) line) uses the safe
formatDate behavior.
- Around line 282-299: The CSV is vulnerable because fields like m.nickname,
m.email (and other string fields) may contain commas, quotes or newlines and
some values starting with =,+,-,@ can enable CSV injection; add a helper (e.g.,
escapeCsv) that doubles internal quotes and wraps fields containing
commas/quotes/newlines in double quotes, and also neutralizes leading =/+/ -/@
(for example by prefixing a single quote) then call this helper for every string
field produced in the members.map (reference symbols: members, grassList,
formatDate, header) so every value in the returned array is escaped before
join(",") and the final rows join("\n") remains unchanged.

In `@apps/web/src/_pages/members/ui/member-row.tsx`:
- Around line 33-36: The UI shows a kick button for moderators even when the
target is a manager, which mismatches server-side assertCanKick; update the
canKick calculation in MemberRow (variable canKick in member-row.tsx) to mirror
server rules: allow kick when membership.role === "manager" for any other user,
but when membership.role === "moderator" only allow kicking if member.role !==
"manager" and member.userId !== membership.userId; ensure showActions still uses
the updated canKick and that this logic aligns with assertCanKick in
kick-member.ts.

In `@apps/web/src/_pages/members/use-cases/change-role.ts`:
- Around line 13-23: The check that ensures at least one manager (using
memberQueries.findAllBySpaceId, targetMember, managerCount, and newRole) and the
subsequent memberQueries.update call must be executed atomically to avoid race
conditions; wrap the read-and-conditional-update in a single database
transaction (use your project's transaction API or
memberQueries.transaction/withTransaction) so you re-read or lock the relevant
rows (filter by spaceId and targetUserId) inside the transaction, verify
managerCount <= 1 before allowing role change, and perform the update within the
same transaction to guarantee the "최소 1명의 manager" invariant.
- Around line 15-24: 현재 로직은 targetMember가 존재하지 않아도 memberQueries.update(spaceId,
targetUserId, { role: newRole })를 호출하므로, allMembers에서 찾은 targetMember가 없을 경우 즉시
예외를 던지도록 보호 로직을 추가하세요; 구체적으로 change-role.ts의 targetMember 검사 후(대상 멤버를 찾는 const
targetMember = allMembers.find(...)) 존재하지 않으면 Error를 throw하고 이후 manager 카운트 검사 및
memberQueries.update 호출은 그 아래에서 계속 실행되도록 흐름을 정리하세요.

In `@apps/web/src/_pages/members/use-cases/get-space-members.ts`:
- Around line 25-33: The current code applies the "current user first" sort
after fetchMembers, so if the current user isn't on the fetched page they won't
be promoted; update the flow to perform priority sorting before pagination by
changing fetchMembers to accept an optional currentUserId (or add a query param)
and implement server-side/order-by logic that places userId === currentUserId
first and then applies page/pageSize; call fetchMembers(spaceId, opts.page,
opts.currentUserId) (or equivalent) from get-space-members.ts and remove the
post-pagination filtering using members.filter in this file.

In `@apps/web/src/_pages/members/use-cases/kick-member.ts`:
- Around line 11-19: The current check using memberQueries.findAllBySpaceId +
memberQueries.remove has a TOCTOU race: check of target.role and managerCount
(via memberQueries.findAllBySpaceId) is not atomic with the deletion
(memberQueries.remove), so concurrent requests can remove the last manager. Fix
by making the operation atomic—either wrap in a DB transaction that locks the
relevant rows (e.g., SELECT ... FOR UPDATE on members filtered by spaceId) and
re-check managerCount before calling memberQueries.remove, or perform a
conditional delete/update in the database that only deletes the targetUserId if
the count of managers after deletion would remain >=1 (e.g., DELETE ... WHERE id
= :targetUserId AND role = 'manager' THEN verify affectedRows and throw if would
drop below 1). Ensure you reference target.role and targetUserId so the code
re-validates invariants inside the transaction/conditional delete and throws an
error if the deletion cannot be performed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 126bf3f4-634f-4eb6-b7f2-5ae063ef9682

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7374b and 409463e.

⛔ Files ignored due to path filters (4)
  • apps/web/public/fonts/Pretendard-Bold.ttf is excluded by !**/*.ttf
  • apps/web/public/fonts/Pretendard-Regular.ttf is excluded by !**/*.ttf
  • apps/web/public/images/moum-zip-logo.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • apps/web/package.json
  • apps/web/src/_pages/members/action.ts
  • apps/web/src/_pages/members/components/member-export-button.tsx
  • apps/web/src/_pages/members/ui/member-row.tsx
  • apps/web/src/_pages/members/ui/member-table.tsx
  • apps/web/src/_pages/members/use-cases/change-role.ts
  • apps/web/src/_pages/members/use-cases/get-space-members.ts
  • apps/web/src/_pages/members/use-cases/kick-member.ts
  • apps/web/src/app/[space-slug]/members/_components/member-right-section.tsx
  • apps/web/src/app/[space-slug]/members/page.tsx
  • apps/web/src/features/space/lib/assert-permission.ts

Comment thread apps/web/src/_pages/members/action.ts
Comment thread apps/web/src/_pages/members/action.ts
Comment thread apps/web/src/_pages/members/components/member-export-button.tsx
) => {
doc.addPage();

const bestDay = grass.days.reduce((best: GrassDay, d: GrassDay) => (d.score > best.score ? d : best), grass.days[0]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

빈 배열에서 런타임 에러 발생 가능.

grass.days가 빈 배열일 경우 grass.days[0]undefined가 되어 bestDay.datebestDay.score 접근 시 에러가 발생합니다.

🐛 빈 배열 처리 추가
-  const bestDay = grass.days.reduce((best: GrassDay, d: GrassDay) => (d.score > best.score ? d : best), grass.days[0]);
+  const bestDay = grass.days.length > 0
+    ? grass.days.reduce((best: GrassDay, d: GrassDay) => (d.score > best.score ? d : best), grass.days[0])
+    : { date: "-", score: 0 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const bestDay = grass.days.reduce((best: GrassDay, d: GrassDay) => (d.score > best.score ? d : best), grass.days[0]);
const bestDay = grass.days.length > 0
? grass.days.reduce((best: GrassDay, d: GrassDay) => (d.score > best.score ? d : best), grass.days[0])
: { date: "-", score: 0 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/_pages/members/components/member-export-button.tsx` at line 185,
The reduce initializes with grass.days[0], which is undefined when grass.days is
empty and leads to runtime errors when accessing bestDay.date/score; update the
logic in member-export-button.tsx (the const bestDay declaration) to guard
against an empty array—either return a sensible default object (e.g., {date:
null, score: 0}) before calling reduce or short-circuit and handle the empty
case (early return or conditional rendering) so subsequent accesses to
bestDay.date and bestDay.score are safe.

Comment thread apps/web/src/_pages/members/components/member-export-button.tsx
Comment thread apps/web/src/_pages/members/ui/member-row.tsx
Comment thread apps/web/src/_pages/members/use-cases/change-role.ts Outdated
Comment thread apps/web/src/_pages/members/use-cases/change-role.ts Outdated
Comment thread apps/web/src/_pages/members/use-cases/get-space-members.ts Outdated
Comment thread apps/web/src/_pages/members/use-cases/kick-member.ts Outdated
@haesol2022
haesol2022 merged commit 781ac4a into dev Apr 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능 추가 / 퍼블리싱 🛠️ Refactor 코드 리팩토링, QA 반영

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 멤버 페이지 기능 구현

1 participant