[feat] 멤버 Actions 드롭다운 및 Export 기능 구현 - #240
Conversation
📝 WalkthroughWalkthrough멤버 페이지에 권한 기반 멤버 관리 기능을 추가합니다. 마네저는 다른 멤버의 역할을 변경하고, 마네저/모더레이터는 멤버를 추방할 수 있으며, 마네저는 멤버 목록을 PDF 및 CSV로 내보낼 수 있습니다. 새로운 서버 액션, 유스케이스, 권한 검증 함수가 추가됩니다. Changes
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: 토스트 메시지 표시
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 파일 다운로드
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (4)
apps/web/public/fonts/Pretendard-Bold.ttfis excluded by!**/*.ttfapps/web/public/fonts/Pretendard-Regular.ttfis excluded by!**/*.ttfapps/web/public/images/moum-zip-logo.pngis excluded by!**/*.pngpnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (11)
apps/web/package.jsonapps/web/src/_pages/members/action.tsapps/web/src/_pages/members/components/member-export-button.tsxapps/web/src/_pages/members/ui/member-row.tsxapps/web/src/_pages/members/ui/member-table.tsxapps/web/src/_pages/members/use-cases/change-role.tsapps/web/src/_pages/members/use-cases/get-space-members.tsapps/web/src/_pages/members/use-cases/kick-member.tsapps/web/src/app/[space-slug]/members/_components/member-right-section.tsxapps/web/src/app/[space-slug]/members/page.tsxapps/web/src/features/space/lib/assert-permission.ts
| ) => { | ||
| doc.addPage(); | ||
|
|
||
| const bestDay = grass.days.reduce((best: GrassDay, d: GrassDay) => (d.score > best.score ? d : best), grass.days[0]); |
There was a problem hiding this comment.
빈 배열에서 런타임 에러 발생 가능.
grass.days가 빈 배열일 경우 grass.days[0]이 undefined가 되어 bestDay.date 및 bestDay.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.
| 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.
- exportMemberGrassAction 권한 검사 추가 - 모든 서버 액션에 .catch 적용하여 에러 응답 형식 통일 - 액션 진입점 권한 체크 로직 추가 - CSV 인젝션 방어 및 활동 데이터 부재 시 런타임 에러 처리
📌 Summary
멤버 페이지의 미구현 기능(Actions 드롭다운, 멤버 리스트 PDF/CSV Export) 완성
📄 Tasks
Quick Actions
exportMembersAction,exportMemberGrassAction서버 액션 추가 (manager만 가능)MemberExportButton컴포넌트 추가, manager에게만 노출PDF export
jsPDF+jspdf-autotable동적 import (번들 사이즈 최적화)CSV export
\uFEFF) 추가로 Excel 한글 깨짐 방지멤버 리스트
member-table.tsx,member-row.tsx,MemberTableSkeleton)DropdownMenu직접 사용cn()으로text-sm sm:text-base를 고정 적용해classNameprop으로 폰트 크기 오버라이드 불가한 이슈역할 변경 가능 조건
(manager만 누구든 역할 변경 가능, 나머지는 전부 불가)
추방 가능 조건
추가 제약
기타
getSpaceMembersUseCase에currentUserId옵션 추가, 본인을 목록 최상단에 고정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