Skip to content

Feat/#27 조직 멤버 초대 및 수락 구현#42

Merged
jinnieusLab merged 19 commits intodevelopfrom
feat/#27
Feb 19, 2026
Merged

Feat/#27 조직 멤버 초대 및 수락 구현#42
jinnieusLab merged 19 commits intodevelopfrom
feat/#27

Conversation

@jinnieusLab
Copy link
Collaborator

@jinnieusLab jinnieusLab commented Feb 18, 2026

📌 관련 이슈

🚀 개요

조직 멤버가 다른 이를 이메일을 통해 조직으로 초대하고, 이메일을 받아 링크로 접속(초대 수락으로 간주)한 사람은 초대 되어 조직 멤버로 할당되도록 구현하였습니다.

📄 작업 내용

조직 초대 이메일 발송 API

: 조직 멤버가 조직에 초대하고 싶은 자의 이메일을 입력하면, 해당 이메일로 초대 링크가 보내짐.
POST /api/org/members/{orgId}/invitation

// 요청 예시
{
  "email": "newuser@gmail.com"
}
// 응답 예시
{
  "status": "OK",
  "data": {
    "orgId": 1,
    "message": "조직 멤버 초대 이메일을 전송하였습니다.",
    "email": "newuser@gmail.com"
  }
}

실제 메일 내용

image
// OrgServiceImpl 코드 일부
// Redis key = 임의의 UUID 토큰(조직 초대 이메일 내 링크를 구별)
        String token = UUID.randomUUID().toString();
          
// EmailService  코드 일부
        message.setText(baseUrl + "/api/org/invitations/" + token);

// application.yml 코드 일부
spring:
  application:
    name: WhereYouAd
    # 서버 기본 주소
    base-url: ${BASE_URL:http://localhost:8080}
  • 조직 초대 로직은 OrgService, 이메일 발송 로직은 EmailService로 구분하여 책임 분리
  • 접속 링크 뒤에 임의의 토큰을 부여하여, Redis에서 토큰(UUID)과 조직+초대 이메일을 각각 key:value로 하여 구분함.
  • 배포 시에는 해당 접속 링크는 배포 서버용 .env의 BASE_URL에 따라 public IP로 바뀜 (로컬과 달리 실제 접속 가능)
  • 회원가입 여부와 상관 없이, 회원이 아닌 자에게도 메일은 발송될 수 있음.

조직 초대 수락 API

POST /api/org/invitations/{token}

image image
  • 로그인 필수(@AuthenticationPrincipal) , 초대된 이메일과 로그인한 사용자가 일치해야함. 일치하지 않을 경우 ORG_INVITATION_FORBIDDEN_USER 403 에러 발생
  • (참고) 이메일 링크 접속 시(= GET 요청 시), 프론트엔드는 먼저 로그인 여부를 확인
    • 비로그인 유저: 즉시 로그인/회원가입 페이지로 리다이렉트, 로그인 완료 후 다시 초대 수락 API를 호출하도록 처리
    • 로그인 유저: 즉시 API를 호출하여 초대 수락

📸 스크린샷 / 테스트 결과 (선택)

image 조직 멤버로 추가된 것을 확인할 수 있음.

✅ 체크리스트

  • 브랜치 전략(GitHub Flow)을 준수했나요?
  • 메서드 단위로 코드가 잘 쪼개져 있나요?
  • 테스트 통과 확인
  • 서버 실행 확인
  • API 동작 확인

🔍 리뷰 포인트 (Review Points)

  • 초대 링크의 유효성을 관리하기 위해 Redis를 사용하고, 임의의 uuid를 링크에 넣는 식으로 구현했는데 괜찮을지 고민이네요..
  • 일단 프론트 측에서 조직 초대를 수락하는 화면은 따로 없는 듯해서, 링크에 접속하면 수락으로 간주하긴 했는데 이런 화면이 생기면 수정될 필요가 있을 것 같습니다

💬 리뷰어 가이드 (P-Rules)
P1: 필수 반영 (Critical) - 버그 가능성, 컨벤션 위반. 해결 전 머지 불가.
P2: 적극 권장 (Recommended) - 더 나은 대안 제시. 가급적 반영 권장.
P3: 제안 (Suggestion) - 아이디어 공유. 반영 여부는 드라이버 자율.
P4: 단순 확인/칭찬 (Nit) - 사소한 오타, 칭찬 등 피드백.

Summary by CodeRabbit

릴리즈 노트

  • 새로운 기능

    • 조직 멤버 초대 기능 추가: 이메일로 초대 발송 및 초대 토큰 생성(유효기간 포함).
    • 초대 수락 기능 추가: 토큰으로 초대 수락 시 조직에 멤버로 가입됩니다.
    • 자동 초대 이메일 발송: 초대 링크가 포함된 이메일 전송 기능 추가.
  • 버그 수정 / 개선

    • 초대 흐름 관련 유효성 검증 및 오류응답 강화(이메일 형식, 토큰 만료/무효, 권한 불일치, 중복 초대 등).
    • 애플리케이션 기본 URL 설정 추가로 초대 링크 생성 안정성 향상.

@jinnieusLab jinnieusLab self-assigned this Feb 18, 2026
@jinnieusLab jinnieusLab added the ✨ Feature 새로운 기능 추가 label Feb 18, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

Walkthrough

조직 멤버 초대 기능이 추가되었습니다. 초대 요청을 처리해 UUID 토큰을 Redis에 24시간 저장하고 초대 이메일을 발송하며, 토큰으로 수락 시 토큰 검증 후 조직에 멤버를 등록하는 sendOrgInvitation/acceptOrgInvitation 흐름이 도입되었습니다.

Changes

Cohort / File(s) Summary
DTOs
src/main/java/.../dto/request/OrgRequest.java, src/main/java/.../dto/response/OrgResponse.java
초대 요청용 OrgRequest.Invite 레코드(@NotBlank, @Email) 및 응답용 OrgResponse.OrgInvitationResponse 레코드 추가.
서비스 인터페이스/구현
src/main/java/.../domain/service/OrgService.java, src/main/java/.../domain/service/OrgServiceImpl.java
sendOrgInvitation(Long userId, Long orgId, String email)acceptOrgInvitation(Long userId, String token) 시그니처 추가 및 구현: Redis 토큰 생성/저장/조회/삭제, 이메일 전송 호출, 멤버 중복 검사 및 OrgMember 생성 로직 추가.
컨트롤러 & API 문서
src/main/java/.../presentation/OrgController.java, src/main/java/.../presentation/docs/OrgControllerDocs.java
POST /api/org/members/{orgId}/invitation 및 POST /api/org/invitations/{token} 엔드포인트 및 OpenAPI 문서 추가.
예외 코드
src/main/java/.../exception/code/OrgErrorCode.java
INVITE 관련 에러 코드 3개 추가: ORG_MEMBER_ALREADY_ACTIVE, ORG_INVITATION_INVALID, ORG_INVITATION_FORBIDDEN_USER (기존 ORG_SOFT_DELETED 제거).
영속성 레포지토리
src/main/java/.../persistence/repository/OrgMemberRepository.java
existsByUserAndOrganization(User user, Organization organization) 파생 쿼리 추가.
이메일 서비스
src/main/java/.../user/domain/service/EmailService.java
초대 이메일 전송용 sendEmailForOrgInvitation(String token, String toEmail, String orgName) 추가 및 spring.application.base-url 주입 사용.
설정
src/main/resources/application.yml
spring.application.base-url 설정 추가(기본값: http://localhost:8080).
기타
src/main/java/.../user/presentation/AuthController.java
EmailService import 추가(현재 미사용).
매퍼
src/main/java/.../application/mapper/OrgMemberConverter.java
일반 멤버 생성용 toOrgMemberMEMBER 메서드 추가(가입시간 now, 역할 MEMBER).

Sequence Diagram

sequenceDiagram
    participant Admin as 조직 관리자
    participant Controller as OrgController
    participant Service as OrgServiceImpl
    participant Redis as Redis
    participant Email as EmailService
    participant DB as Database

    Admin->>Controller: POST /api/org/members/{orgId}/invitation\n(body: 이메일)
    Controller->>Service: sendOrgInvitation(userId, orgId, email)
    Service->>DB: 조직/초대자 멤버십 검증
    Service->>DB: 이메일로 사용자 조회
    Service->>DB: OrgMember 존재 여부 확인
    alt 신규 초대 가능
        Service->>Redis: UUID 토큰 생성 및 저장\nkey=INVITE:{token}, value={orgId,email}, TTL=24h
        Service->>Email: sendEmailForOrgInvitation(token, email, orgName)
        Email-->>Admin: 초대 이메일 발송
        Service-->>Controller: OrgInvitationResponse(성공)
    else 이미 멤버/초대 상태
        Service-->>Controller: 에러(409)
    end

    Note over Admin,Email: 초대 수락 흐름

    Admin->>Controller: POST /api/org/invitations/{token}
    Controller->>Service: acceptOrgInvitation(userId, token)
    Service->>Redis: 토큰 조회/검증 -> {orgId, email}
    alt 토큰 유효
        Service->>DB: 현재 사용자 이메일 조회
        alt 이메일 일치 및 미가입
            Service->>DB: OrgMember 생성 (role=MEMBER/ADMIN-equivalent)
            Service->>Redis: 토큰 삭제
            Service-->>Controller: OrgInvitationResponse(성공)
        else 이메일 불일치
            Service-->>Controller: 에러(403)
        else 이미 멤버
            Service-->>Controller: 에러(409)
        end
    else 토큰 무효/만료
        Service-->>Controller: 에러(400)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Feat/#23 #24: 조직 DTO·서비스·컨트롤러 확장과 직접 겹치는 변경 사항(초대 관련 파일 수정).
  • Feat/#10 #11: Redis 및 이메일 인프라 추가와 관련(토큰 저장·메일 전송 연관).
  • Feat/#19 #20: OrgMember/Organization 모델·레포지토리 관련 변경이 초대 수락 로직의 기반이 됨.

Suggested reviewers

  • ojy0903
  • kingmingyu
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 구현된 주요 기능(조직 멤버 초대 및 수락)을 명확하게 요약하고 있으며, 변경사항의 핵심을 잘 전달합니다.
Description check ✅ Passed PR 설명이 템플릿의 대부분의 항목(관련 이슈, 개요, 작업 내용, 테스트 결과, 체크리스트, 리뷰 포인트)을 포함하고 있으며 상세합니다.
Linked Issues check ✅ Passed PR은 이슈 #27의 두 가지 주요 요구사항을 모두 충족합니다: 이메일을 통한 멤버 초대 기능과 메일로 초대 수락 기능이 완전히 구현되었습니다.
Out of Scope Changes check ✅ Passed PR의 모든 변경사항이 조직 멤버 초대 및 수락 기능과 직접 관련되어 있으며, 범위를 벗어난 변경은 없습니다.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#27

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (7)
src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java (1)

11-31: HTTP 상태 코드 주석이 중복 · 산발적으로 배치되어 있습니다.

// 400, // 403, // 409 섹션이 각각 두 번씩 등장합니다. 같은 상태 코드끼리 묶어서 한 번만 주석을 달면 가독성이 훨씬 좋아집니다.

♻️ 제안하는 정리
 `@Getter`
 `@AllArgsConstructor`
 public enum OrgErrorCode implements BaseErrorCode {
     // 400
     ORG_NAME_DUPLICATE(HttpStatus.BAD_REQUEST, "ORG_400_1", "사용자가 이미 속해있는 조직의 이름입니다."),
+    ORG_INVITATION_INVALID(HttpStatus.BAD_REQUEST, "ORG_INVITATION_400", "조직 초대 토큰이 만료되었거나 유효하지 않습니다."),

     // 403
     ORG_FORBIDDEN(HttpStatus.FORBIDDEN, "ORG_403_1", "해당 요청은 조직 생성자만 요청 가능합니다."),
+    ORG_INVITATION_FORBIDDEN_USER(HttpStatus.FORBIDDEN, "ORG_INVITATION_403_1", "초대된 이메일과 현재 로그인한 사용자의 이메일이 일치하지 않습니다."),

     // 404
     ORG_NOT_FOUND(HttpStatus.NOT_FOUND, "ORG_404_1", "해당 id 의 조직이 존재하지 않습니다."),

     // 409
     ORG_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_409_1", "해당 조직은 이미 활성화 상태 입니다."),
-    ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직의 멤버입니다."),
+    ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직의 멤버입니다.");

-    // 400
-    ORG_INVITATION_INVALID(HttpStatus.BAD_REQUEST, "ORG_INVITATION_400", "조직 초대 토큰이 만료되었거나 유효하지 않습니다."),
-
-    // 403
-    ORG_INVITATION_FORBIDDEN_USER(HttpStatus.FORBIDDEN, "ORG_INVITATION_403_1",
-            "초대된 이메일과 현재 로그인한 사용자의 이메일이 일치하지 않습니다.");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`
around lines 11 - 31, The HTTP status code comments are duplicated and scattered
across the enum entries (e.g., ORG_NAME_DUPLICATE, ORG_FORBIDDEN, ORG_NOT_FOUND,
ORG_ALREADY_ACTIVE, ORG_MEMBER_ALREADY_ACTIVE, ORG_INVITATION_INVALID,
ORG_INVITATION_FORBIDDEN_USER); consolidate and reorder the enum so each HTTP
status code comment appears once and groups all entries for that status together
(for example a single "// 400" above ORG_NAME_DUPLICATE and
ORG_INVITATION_INVALID, a single "// 403" above ORG_FORBIDDEN and
ORG_INVITATION_FORBIDDEN_USER, a single "// 404" above ORG_NOT_FOUND, and a
single "// 409" above ORG_ALREADY_ACTIVE and ORG_MEMBER_ALREADY_ACTIVE) to
improve readability.
src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java (2)

63-63: 이메일 링크가 백엔드 API URL(/api/org/invitations/{token})을 직접 가리킵니다.

사용자가 이메일의 링크를 클릭하면 백엔드 API를 직접 호출하게 되어, 브라우저에 아래와 같은 JSON이 그대로 표시됩니다.

{"status":"OK","data":{"orgId":1,"message":"조직 멤버 초대 이메일을 수락하였습니다.","email":"..."}}

이는 사용자 경험(UX) 측면에서 좋지 않습니다. 일반적으로 초대 이메일 링크는 프론트엔드 URL을 가리키고, 프론트엔드가 토큰을 읽어 API를 호출하거나 로그인 페이지로 리다이렉트하는 방식을 사용합니다.

PR 설명에서 "인증되지 않은 사용자는 로그인/회원가입으로 리다이렉트"를 언급하고 있으므로, 아래와 같이 프론트엔드 URL로 변경하는 것을 권장합니다.

♻️ 제안하는 수정 방향

application.yml에 프론트엔드 초대 수락 경로를 추가:

app:
  base-url: ${BASE_URL:http://localhost:8080}
  frontend-invite-path: /org/accept-invite  # 프론트엔드 경로

EmailService.java:

-message.setText(baseUrl + "/api/org/invitations/" + token);
+message.setText(frontendBaseUrl + frontendInvitePath + "?token=" + token);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java`
at line 63, Email link currently points directly to the backend API in
EmailService (message.setText(baseUrl + "/api/org/invitations/" + token));
change it to point to the frontend invite acceptance route by composing the URL
from the app base URL (app.base-url) and a new config key
(app.frontend-invite-path, e.g. "/org/accept-invite") and append the token as a
query parameter (or path segment your frontend expects) so the email opens the
frontend which then reads the token and calls the backend; update EmailService
to use the frontend-invite-path config instead of "/api/org/invitations/" when
building the message URL.

17-17: 클래스 레벨 @Transactional 안에서 이메일을 발송하면 "이메일 발송은 됐지만 트랜잭션이 롤백된" 상황이 발생할 수 있습니다.

OrgServiceImpl에서 초대 로직이 대략 다음 순서로 실행된다고 가정해봅시다:

  1. Redis에 토큰 저장
  2. sendEmailForOrgInvitation() 호출 → 이메일 실제 발송 ✅
  3. 이후 어떤 이유로 예외 발생 → DB 트랜잭션 롤백

이 경우 이메일은 이미 발송됐지만, 서비스 상태는 일관성을 잃을 수 있습니다. 이메일 발송은 롤백이 불가능한 외부 사이드 이펙트이므로, 메인 트랜잭션이 커밋된 이후에 발송하는 것이 안전합니다.

Spring의 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)을 활용하면 트랜잭션 커밋 후에 이메일을 발송하도록 구현할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java`
at line 17, The class-level `@Transactional` on EmailService causes email sends to
run inside the main DB transaction and can result in emails being sent even when
the transaction later rolls back; instead, emit an application event (e.g.,
OrgInvitationEmailEvent) from OrgServiceImpl after you finish preparing the
invite and remove or avoid using `@Transactional` around the actual send path,
then create a separate listener (e.g., EmailEventListener) with a method that
handles OrgInvitationEmailEvent and is annotated with
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) which calls
EmailService.sendEmailForOrgInvitation; alternatively, if you must keep
EmailService as a bean, mark sendEmailForOrgInvitation with non-transactional
propagation (NOT_SUPPORTED) and trigger it from the AFTER_COMMIT listener so the
external email side-effect only runs after the main transaction commits.
src/main/resources/application.yml (1)

7-8: spring.application.base-url은 Spring 예약 네임스페이스에 커스텀 프로퍼티를 혼용하는 방식입니다.

spring.application은 Spring Boot가 예약한 네임스페이스(name, version 등)입니다. 커스텀 프로퍼티를 이 아래에 정의하면 향후 Spring Boot 업그레이드 시 예약어 충돌이 발생할 수 있고, IDE 자동완성도 올바르게 동작하지 않을 수 있습니다.

app.base-url처럼 프로젝트 전용 prefix를 사용하면 더 명확합니다.

♻️ 제안하는 수정 (application.yml)
 spring:
   application:
     name: WhereYouAd
-    # 서버 기본 주소
-    base-url: ${BASE_URL:http://localhost:8080}
+
+# 애플리케이션 공통 설정
+app:
+  base-url: ${BASE_URL:http://localhost:8080}

EmailService.java@Value도 함께 업데이트해야 합니다:

-@Value("${spring.application.base-url}")
+@Value("${app.base-url}")
 private String baseUrl;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/application.yml` around lines 7 - 8, Replace the custom
property key from the reserved spring namespace to a project-specific prefix by
renaming base-url to app.base-url in application.yml, and update the
corresponding injection in EmailService.java by changing the `@Value` placeholder
from "${base-url...}" (or "${spring.application.base-url...}") to
"${app.base-url...}" (preserving the existing environment fallback like
${BASE_URL:http://localhost:8080} if present) so the property and the `@Value`
annotation reference the new app.base-url name consistently.
src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java (3)

176-179: split(":")에 limit을 지정하여 안전성을 높이세요.

현재 Redis value 형식이 orgId:email인데, split(":")은 모든 :을 기준으로 분리합니다. 이론적으로 이메일에는 :이 포함되지 않지만, 방어적으로 split(":", 2)를 사용하면 value 형식이 변경되거나 예상치 못한 데이터가 들어와도 안전합니다.

또한 Long.parseLong(valueForSplit[0])(Line 190)에서 Redis 데이터가 손상된 경우 NumberFormatException이 발생할 수 있으니, 적절한 예외 처리를 고려해주세요.

♻️ 수정 제안
-        String[] valueForSplit = value.split(":");
-        String email = valueForSplit[1];
+        String[] valueForSplit = value.split(":", 2);
+        if (valueForSplit.length < 2) {
+            throw new OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID);
+        }
+        String email = valueForSplit[1];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 176 - 179, The code uses value.split(":") to extract orgId and
email; change to value.split(":", 2) when building valueForSplit to avoid
over-splitting, validate that valueForSplit.length == 2 and handle the invalid
format (e.g., log and skip or throw a controlled exception) before using
valueForSplit[1] as email; additionally wrap Long.parseLong(valueForSplit[0])
(used later) in try/catch or use a parsing helper to catch NumberFormatException
and handle corrupted Redis data gracefully (log the bad value with context and
skip/propagate a clear exception) — look for the variables/methods value,
valueForSplit, and the Long.parseLong(...) call in OrgServiceImpl to make these
changes.

167-170: token == null 체크는 불필요합니다.

Spring MVC에서 @PathVariable String token은 URL 경로의 일부이므로, 값이 없으면 404가 반환됩니다. null이 전달되는 경우는 사실상 없습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 167 - 170, In acceptOrgInvitation(Long userId, String token) in
OrgServiceImpl, remove the redundant token == null check and its
OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID) throw since `@PathVariable` token
will not be null; instead rely on your existing token validation logic (e.g.,
the method that verifies expiration/validity) to throw OrgHandler with
OrgErrorCode.ORG_INVITATION_INVALID when the token is actually invalid/expired,
and keep any other related checks intact.

142-163: @Transactional 내에서 이메일 발송(blocking I/O)을 수행하고 있습니다.

현재 sendOrgInvitation은 클래스 레벨 @Transactional 안에서 실행됩니다. 이메일 발송은 외부 SMTP 서버와의 네트워크 통신으로, 응답이 느리거나 타임아웃 될 경우 DB 커넥션을 오래 점유하게 됩니다. 또한:

  1. Redis 쓰기 후 이메일 발송 실패 시, 트랜잭션 롤백으로 DB는 복구되지만 Redis 데이터는 남아 있게 됩니다.
  2. 이메일 발송 성공 후 트랜잭션 커밋 실패 시에도 이메일은 이미 발송된 상태입니다.

이메일 발송은 트랜잭션 밖으로 분리하거나, @TransactionalEventListener(AFTER_COMMIT) 패턴을 사용하는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 142 - 163, sendOrgInvitation currently performs blocking I/O (redis
write + emailService.sendEmailForOrgInvitation) while inside the transactional
boundary of OrgServiceImpl.sendOrgInvitation, which can hold DB connections and
lead to inconsistent external state; instead, remove
redisUtil.setDataExpire(...) and emailService.sendEmailForOrgInvitation(...)
from sendOrgInvitation and publish an OrganizationInvitationEvent (containing
token, orgId, email, orgName) via ApplicationEventPublisher before returning;
implement a separate listener class (e.g., OrgInvitationListener) with a method
annotated `@TransactionalEventListener`(phase = AFTER_COMMIT) that receives
OrganizationInvitationEvent and performs redisUtil.setDataExpire(...) and
emailService.sendEmailForOrgInvitation(...), handling/logging failures and
retries there; also fix the user existence check in sendOrgInvitation (the
orgMemberRepository.existsByUser(user) branch) to actually throw new
OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE) instead of just constructing
it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/application/dto/request/OrgRequest.java`:
- Around line 25-28: Add javax.validation `@Email` validation to the Invite DTO so
invalid addresses are rejected before persistence; update the OrgRequest.Invite
record's email field to use both `@NotBlank` and `@Email` (with an appropriate
message) and ensure the import for javax.validation.constraints.Email is present
so controllers/validators block bad values like "notanemail" before they reach
Redis or JavaMailSender.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`:
- Around line 148-152: In OrgServiceImpl inside the
userRepository.findUserByEmail(email) block replace the current existence check
that calls orgMemberRepository.existsByUser(user) and the non-thrown new
OrgHandler(...) with a check against the specific organization using a method
like orgMemberRepository.existsByUserAndOrganization(user, organization); if
that returns true, throw the exception (throw new
OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE)); add the repository method
existsByUserAndOrganization(User user, Organization organization) to
OrgMemberRepository if it doesn't exist and update the block to reference
OrgHandler and OrgErrorCode as shown so the duplicate-member validation actually
prevents sending invites.
- Line 197: The code in OrgServiceImpl currently saves every invited user as
ADMIN via orgMemberRepository.save(OrgMemberConverter.toOrgMemberADMIN(user,
organization)); change this so invited users are created with a safe lower
privilege (e.g., MEMBER) instead of ADMIN: replace the call to toOrgMemberADMIN
with a converter method that creates a MEMBER org member (e.g.,
toOrgMemberMEMBER or toOrgMember with a Role.MEMBER parameter) and persist that;
if OrgMemberConverter lacks such a method, add a new converter method (e.g.,
toOrgMemberMEMBER(User user, Organization org) or overload toOrgMember(User,
Organization, Role)) that sets the member role to MEMBER and use that from
OrgServiceImpl when saving invitations.
- Around line 190-195: 현재 OrgServiceImpl의 멤버 중복 체크에서
orgMemberRepository.existsByUser(user)를 사용해 다른 조직의 멤버 여부까지 차단하고 있으므로, 해당 조직에 대한
멤버 여부만 검사하도록 변경하세요: OrgServiceImpl에서 existsByUser(user) 호출을
orgMemberRepository.existsByOrganizationAndUser(organization, user) (또는 유사명
existsByOrgAndUser)로 바꾸고, OrgMemberRepository에 존재하지 않으면 해당 시그니처의 메서드(예: boolean
existsByOrganizationAndUser(Organization organization, User user))를 추가해 사용자가 현재
organization에 이미 속해 있으면 OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE)를 던지도록
수정하세요.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`:
- Around line 23-24: OrgErrorCode 열거형의 ORG_MEMBER_ALREADY_ACTIVE 항목 메시지가 "이미 해당
조직에 초대되어있습니다."로 되어 있어 오해의 소지가 있으니 이 enum 상수(ORG_MEMBER_ALREADY_ACTIVE)의 설명 문자열을
조직에 이미 멤버임을 명확히 나타내는 문구로 변경하세요; 예를 들어 OrgErrorCode의 ORG_MEMBER_ALREADY_ACTIVE 값을
찾아 기존 메시지를 "이미 조직의 멤버입니다." 또는 동등한 명확한 한국어 문구로 교체하면 됩니다.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/persistence/repository/OrgMemberRepository.java`:
- Around line 47-48: Change the repository check to be organization-specific by
renaming/updating OrgMemberRepository.existsByUser(User) to
existsByUserAndOrganization(User user, Organization organization) so it verifies
membership within the given organization; update both call sites in
sendOrgInvitation and confirmInvitation to pass the organization argument and
use the new method, and in sendOrgInvitation replace the current OrgHandler
instantiation with a thrown exception (throw new
OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE)) so the duplicate-member
check actually aborts the flow.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/docs/OrgControllerDocs.java`:
- Around line 100-109: Add an authentication principal parameter to the
sendOrgInvitation method in OrgControllerDocs so Swagger shows auth is required:
include a parameter annotated with `@AuthenticationPrincipal` (e.g., a
UserPrincipal or MemberDetails parameter) alongside the existing `@PathVariable`
Long orgId and `@RequestBody` `@Valid` OrgRequest.Invite request, and update the
`@ApiResponses` on sendOrgInvitation to include a 401 response (description: "로그인
필요") so OrgResponse.OrgInvitationResponse and the Invite contract reflect the
authenticated call.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/OrgController.java`:
- Around line 115-119: Change the mapping on OrgController.acceptOrgInvitation
to use a non-GET verb and add the leading slash: replace the
`@GetMapping`("invitations/{token}") annotation with
`@PostMapping`("/invitations/{token}") (or `@PutMapping` if you prefer idempotent
semantics) because acceptOrgInvitation performs state changes (creates OrgMember
and deletes the token in Redis) so it must not be exposed as GET; update any
callers/tests to POST (or PUT) accordingly and keep the method signature and
call to orgService.acceptOrgInvitation(userId, token) unchanged.
- Around line 109-113: The sendOrgInvitation controller lacks authentication and
authorization; update the controller method sendOrgInvitation to accept the
authenticated principal (e.g., `@AuthenticationPrincipal` Long userId or your
security principal) and pass that userId into OrgService, and update
OrgService.sendOrgInvitation signature to accept (Long orgId, Long userId,
String email) and enforce that userId is a member or admin of the org before
sending the invite; ensure the service throws an appropriate exception (e.g.,
AccessDeniedException) when the user is not authorized and keep
OrgRequest.Invite.email() usage for the email parameter.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java`:
- Around line 55-70: sendEmailForOrgInvitation currently always calls
emailSender.send() and skips the test-email guard used elsewhere; update
sendEmailForOrgInvitation to first call the existing isTestEmail(toEmail) check
(same logic as emailSendTemplate) and if true skip actual sending and write a
log entry instead, otherwise build the SimpleMailMessage (using baseUrl and
senderEmail) and call emailSender.send(message) inside the existing try/catch so
MailException still maps to throw new
UserHandler(UserErrorCode.USER_EMAIL_NOT_VALID); keep references to
sendEmailForOrgInvitation, isTestEmail, emailSendTemplate, emailSender, baseUrl,
senderEmail, and preserve current exception handling.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/presentation/AuthController.java`:
- Line 5: Remove the unused import of EmailService from AuthController: delete
the line importing
com.whereyouad.WhereYouAd.domains.user.domain.service.EmailService in the
AuthController file since there is no reference to EmailService in the class
(AuthController) to avoid unused-import warnings.

---

Nitpick comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`:
- Around line 176-179: The code uses value.split(":") to extract orgId and
email; change to value.split(":", 2) when building valueForSplit to avoid
over-splitting, validate that valueForSplit.length == 2 and handle the invalid
format (e.g., log and skip or throw a controlled exception) before using
valueForSplit[1] as email; additionally wrap Long.parseLong(valueForSplit[0])
(used later) in try/catch or use a parsing helper to catch NumberFormatException
and handle corrupted Redis data gracefully (log the bad value with context and
skip/propagate a clear exception) — look for the variables/methods value,
valueForSplit, and the Long.parseLong(...) call in OrgServiceImpl to make these
changes.
- Around line 167-170: In acceptOrgInvitation(Long userId, String token) in
OrgServiceImpl, remove the redundant token == null check and its
OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID) throw since `@PathVariable` token
will not be null; instead rely on your existing token validation logic (e.g.,
the method that verifies expiration/validity) to throw OrgHandler with
OrgErrorCode.ORG_INVITATION_INVALID when the token is actually invalid/expired,
and keep any other related checks intact.
- Around line 142-163: sendOrgInvitation currently performs blocking I/O (redis
write + emailService.sendEmailForOrgInvitation) while inside the transactional
boundary of OrgServiceImpl.sendOrgInvitation, which can hold DB connections and
lead to inconsistent external state; instead, remove
redisUtil.setDataExpire(...) and emailService.sendEmailForOrgInvitation(...)
from sendOrgInvitation and publish an OrganizationInvitationEvent (containing
token, orgId, email, orgName) via ApplicationEventPublisher before returning;
implement a separate listener class (e.g., OrgInvitationListener) with a method
annotated `@TransactionalEventListener`(phase = AFTER_COMMIT) that receives
OrganizationInvitationEvent and performs redisUtil.setDataExpire(...) and
emailService.sendEmailForOrgInvitation(...), handling/logging failures and
retries there; also fix the user existence check in sendOrgInvitation (the
orgMemberRepository.existsByUser(user) branch) to actually throw new
OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE) instead of just constructing
it.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`:
- Around line 11-31: The HTTP status code comments are duplicated and scattered
across the enum entries (e.g., ORG_NAME_DUPLICATE, ORG_FORBIDDEN, ORG_NOT_FOUND,
ORG_ALREADY_ACTIVE, ORG_MEMBER_ALREADY_ACTIVE, ORG_INVITATION_INVALID,
ORG_INVITATION_FORBIDDEN_USER); consolidate and reorder the enum so each HTTP
status code comment appears once and groups all entries for that status together
(for example a single "// 400" above ORG_NAME_DUPLICATE and
ORG_INVITATION_INVALID, a single "// 403" above ORG_FORBIDDEN and
ORG_INVITATION_FORBIDDEN_USER, a single "// 404" above ORG_NOT_FOUND, and a
single "// 409" above ORG_ALREADY_ACTIVE and ORG_MEMBER_ALREADY_ACTIVE) to
improve readability.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java`:
- Line 63: Email link currently points directly to the backend API in
EmailService (message.setText(baseUrl + "/api/org/invitations/" + token));
change it to point to the frontend invite acceptance route by composing the URL
from the app base URL (app.base-url) and a new config key
(app.frontend-invite-path, e.g. "/org/accept-invite") and append the token as a
query parameter (or path segment your frontend expects) so the email opens the
frontend which then reads the token and calls the backend; update EmailService
to use the frontend-invite-path config instead of "/api/org/invitations/" when
building the message URL.
- Line 17: The class-level `@Transactional` on EmailService causes email sends to
run inside the main DB transaction and can result in emails being sent even when
the transaction later rolls back; instead, emit an application event (e.g.,
OrgInvitationEmailEvent) from OrgServiceImpl after you finish preparing the
invite and remove or avoid using `@Transactional` around the actual send path,
then create a separate listener (e.g., EmailEventListener) with a method that
handles OrgInvitationEmailEvent and is annotated with
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) which calls
EmailService.sendEmailForOrgInvitation; alternatively, if you must keep
EmailService as a bean, mark sendEmailForOrgInvitation with non-transactional
propagation (NOT_SUPPORTED) and trigger it from the AFTER_COMMIT listener so the
external email side-effect only runs after the main transaction commits.

In `@src/main/resources/application.yml`:
- Around line 7-8: Replace the custom property key from the reserved spring
namespace to a project-specific prefix by renaming base-url to app.base-url in
application.yml, and update the corresponding injection in EmailService.java by
changing the `@Value` placeholder from "${base-url...}" (or
"${spring.application.base-url...}") to "${app.base-url...}" (preserving the
existing environment fallback like ${BASE_URL:http://localhost:8080} if present)
so the property and the `@Value` annotation reference the new app.base-url name
consistently.

Comment on lines +23 to +24
// 409
ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직에 초대되어있습니다."),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ORG_MEMBER_ALREADY_ACTIVE 메시지가 오해를 줄 수 있습니다.

이 에러는 초대를 수락하려 할 때 이미 조직의 멤버인 경우에 발생합니다. 그런데 메시지 "이미 해당 조직에 초대되어있습니다"는 '초대는 받았지만 아직 수락 전인 대기 상태'처럼 읽힐 수 있어 혼란스럽습니다. 실제 상황을 더 정확하게 전달하는 메시지로 바꾸는 게 좋습니다.

✏️ 제안하는 수정
- ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직에 초대되어있습니다."),
+ ORG_MEMBER_ALREADY_ACTIVE(HttpStatus.CONFLICT, "ORG_MEMBER_409_1", "이미 해당 조직의 멤버입니다."),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`
around lines 23 - 24, OrgErrorCode 열거형의 ORG_MEMBER_ALREADY_ACTIVE 항목 메시지가 "이미 해당
조직에 초대되어있습니다."로 되어 있어 오해의 소지가 있으니 이 enum 상수(ORG_MEMBER_ALREADY_ACTIVE)의 설명 문자열을
조직에 이미 멤버임을 명확히 나타내는 문구로 변경하세요; 예를 들어 OrgErrorCode의 ORG_MEMBER_ALREADY_ACTIVE 값을
찾아 기존 메시지를 "이미 조직의 멤버입니다." 또는 동등한 명확한 한국어 문구로 교체하면 됩니다.

Comment on lines +55 to +70
// 조직 멤버 초대 이메일 발송
public void sendEmailForOrgInvitation(String token, String toEmail, String orgName) {
try {
// 이메일 전송
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(toEmail);

message.setSubject("[Where You Ad] 조직 " + orgName + "에 초대 되었습니다.");
message.setText(baseUrl + "/api/org/invitations/" + token);
message.setFrom(senderEmail);

emailSender.send(message);
} catch (MailException e) { // 예외 발생
throw new UserHandler(UserErrorCode.USER_EMAIL_NOT_VALID); // 통합 응답 처리 예외로 반환
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

이메일 발송 시 테스트 이메일(isTestEmail) 체크가 누락되어 있습니다.

기존의 emailSendTemplate()에서는 isTestEmail() 검사로 test* / *.example.com 주소에 대해 실제 발송을 건너뛰고 로그로 대체합니다. 그러나 sendEmailForOrgInvitation()은 이 체크 없이 바로 emailSender.send()를 호출합니다. 개발/테스트 환경에서 테스트용 이메일로 초대를 보내면 발신 계정이 스팸 처리될 수 있습니다 (기존 주석에서도 이 우려를 언급하고 있습니다).

🛡️ 제안하는 수정
 public void sendEmailForOrgInvitation(String token, String toEmail, String orgName) {
+    if (isTestEmail(toEmail)) {
+        log.warn("[TEST 모드] 조직 초대 이메일 발송 건너뜀. 수신자: {}, token: {}", toEmail, token);
+        return;
+    }
     try {
         SimpleMailMessage message = new SimpleMailMessage();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/user/domain/service/EmailService.java`
around lines 55 - 70, sendEmailForOrgInvitation currently always calls
emailSender.send() and skips the test-email guard used elsewhere; update
sendEmailForOrgInvitation to first call the existing isTestEmail(toEmail) check
(same logic as emailSendTemplate) and if true skip actual sending and write a
log entry instead, otherwise build the SimpleMailMessage (using baseUrl and
senderEmail) and call emailSender.send(message) inside the existing try/catch so
MailException still maps to throw new
UserHandler(UserErrorCode.USER_EMAIL_NOT_VALID); keep references to
sendEmailForOrgInvitation, isTestEmail, emailSendTemplate, emailSender, baseUrl,
senderEmail, and preserve current exception handling.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (7)
src/main/java/com/whereyouad/WhereYouAd/domains/organization/persistence/repository/OrgMemberRepository.java (2)

47-48: 이전 리뷰 지적을 잘 반영해주셨네요 👍 — 반환 타입과 들여쓰기 소소하게 개선하면 더 좋을 것 같아요.

이전에 지적했던 "전체 조직 대상 멤버 체크" 버그가 existsByUserAndOrganization으로 정확하게 수정되었습니다. Spring Data JPA가 SELECT COUNT(*) > 0 FROM org_member WHERE user_id = ? AND org_id = ? 쿼리를 자동으로 생성해주므로 N+1 문제 없이 효율적으로 동작합니다.

두 가지 작은 개선점을 제안드립니다:

  1. 반환 타입: Boolean (래퍼 타입) 대신 boolean (원시 타입)을 사용하는 것이 Spring Data JPA의 관례에 맞습니다. 래퍼 타입은 이론적으로 null을 반환할 수 있어 호출부에서 NullPointerException의 위험이 생깁니다. (실제로 Spring Data가 null을 반환하지는 않지만, 타입 선언 자체가 혼란을 줄 수 있어요.)

  2. 들여쓰기: line 48의 들여쓰기가 8칸(double indent)으로, 다른 메서드(4칸)와 불일치합니다.

✨ 제안 수정 (diff)
-        Boolean existsByUserAndOrganization(User user, Organization organization);
+    boolean existsByUserAndOrganization(User user, Organization organization);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/persistence/repository/OrgMemberRepository.java`
around lines 47 - 48, Change the return type of the repository method
existsByUserAndOrganization in OrgMemberRepository from the wrapper Boolean to
the primitive boolean to follow Spring Data JPA conventions and avoid nullable
semantics, and fix the method declaration indentation to match other methods
(use 4-space indent instead of the current double-indent) so formatting is
consistent; update the signature in the OrgMemberRepository interface
accordingly.

47-48: ifPresent 람다 내 예외 처리 패턴 개선이 필요합니다.

좋은 소식은 이전 리뷰의 핵심 요구사항들이 잘 반영되었다는 점입니다:

  • existsByUserexistsByUserAndOrganization로 완전히 교체됨
  • ✅ 모든 호출 지점에 throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE) 추가됨

다만 159-161줄의 패턴 개선이 필요합니다. 현재처럼 ifPresent 람다 내에서 예외를 던지는 방식은 의도가 명확하지 않으며, 함수형 프로그래밍의 부작용(side effect)으로 예외 처리를 시도하는 안티패턴입니다.

더 명확한 패턴으로 개선하세요:

// 현재 (가독성 낮음)
userRepository.findUserByEmail(email).ifPresent(user -> {
    if (orgMemberRepository.existsByUserAndOrganization(user, organization)) {
        throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);
    }
});

// 제안 방식 (의도가 명확함)
userRepository.findUserByEmail(email).ifPresent(user -> {
    if (orgMemberRepository.existsByUserAndOrganization(user, organization)) {
        throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);
    }
});
// 또는
Optional<User> userOpt = userRepository.findUserByEmail(email);
if (userOpt.isPresent() && orgMemberRepository.existsByUserAndOrganization(userOpt.get(), organization)) {
    throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);
}

참고로 204-205줄의 다른 메서드에서는 조건문으로 깔끔하게 처리되어 있으므로, 동일한 스타일로 통일하면 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/persistence/repository/OrgMemberRepository.java`
around lines 47 - 48, Replace the throw-inside-ifPresent pattern with an
explicit conditional to avoid side-effectful lambdas: call
userRepository.findUserByEmail(email) into an Optional (e.g., userOpt) and then
check if userOpt.isPresent() &&
orgMemberRepository.existsByUserAndOrganization(userOpt.get(), organization) and
throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE) in that
conditional; this aligns the style with the other method and makes the control
flow around orgMemberRepository.existsByUserAndOrganization and OrgHandler
clearer.
src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/docs/OrgControllerDocs.java (1)

100-110: sendOrgInvitation의 API 응답 코드에 401403이 빠져 있습니다.

이 엔드포인트는 로그인이 필요하고(@AuthenticationPrincipal), 서비스 레이어에서 초대자가 해당 조직의 멤버가 아니면 ORG_FORBIDDEN(403)을 던집니다. acceptOrgInvitation에는 401, 403이 잘 문서화되어 있는 것과 비교하면, sendOrgInvitation에도 동일하게 추가하는 것이 일관적입니다.

📝 수정 제안
     `@ApiResponses`({
             `@ApiResponse`(responseCode = "200", description = "성공"),
+            `@ApiResponse`(responseCode = "401", description = "로그인 필요"),
+            `@ApiResponse`(responseCode = "403", description = "조직 멤버가 아닌 사용자의 요청"),
             `@ApiResponse`(responseCode = "404", description = "조직을 찾을 수 없음"),
             `@ApiResponse`(responseCode = "409", description = "이미 조직에 가입된 사용자")
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/docs/OrgControllerDocs.java`
around lines 100 - 110, Add missing API response documentation for HTTP 401 and
403 to the sendOrgInvitation operation: update the `@ApiResponses` on the
sendOrgInvitation method to include a `@ApiResponse`(responseCode = "401",
description = "인증 필요") and a `@ApiResponse`(responseCode = "403", description =
"권한 없음 / ORG_FORBIDDEN") so it matches acceptOrgInvitation and reflects that the
endpoint requires authentication (`@AuthenticationPrincipal` userId) and may throw
ORG_FORBIDDEN when the caller is not a member.
src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/OrgController.java (1)

118-118: @PostMapping 경로에 선행 /가 빠져 있습니다.

다른 모든 매핑("/create", "/{orgId}", "/members/{orgId}" 등)은 선행 /를 사용하고 있는데, "invitations/{token}"/가 빠져 있어 일관성이 떨어집니다. Spring이 자동으로 /를 삽입해 주므로 기능상 문제는 없지만, 컨벤션 통일을 위해 수정하는 것이 좋겠습니다.

♻️ 수정 제안
-    `@PostMapping`("invitations/{token}")
+    `@PostMapping`("/invitations/{token}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/OrgController.java`
at line 118, Update the `@PostMapping` on the invitations endpoint in
OrgController: change the annotation `@PostMapping`("invitations/{token}") to
include the leading slash so it becomes `@PostMapping`("/invitations/{token}"),
ensuring consistency with other mappings like "/create" and "/{orgId}" in the
OrgController class and preserving existing behavior.
src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java (3)

188-189: split(":")split(":", 2)로 변경하면 더 안전합니다.

현재 Redis value 형식이 "orgId:email"인데, split(":")은 구분자가 여러 개일 경우 예상치 못한 결과를 낼 수 있습니다. 예를 들어 이메일에 :가 포함된 극단적인 케이스(RFC상 quoted local part에서 가능)에서 valueForSplit[1]이 이메일 전체가 아닌 일부만 가져올 수 있습니다.

split(":", 2)를 사용하면 첫 번째 :만 기준으로 분리하므로 안전합니다.

♻️ 수정 제안
-        String[] valueForSplit = value.split(":");
-        String email = valueForSplit[1];
+        String[] valueForSplit = value.split(":", 2);
+        String email = valueForSplit[1];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 188 - 189, In OrgServiceImpl update the parsing of the Redis value:
replace the current split usage on the variable value (currently done in
String[] valueForSplit = value.split(":"); and then reading valueForSplit[1]
into email) with a bounded split using split(":", 2) so only the first ':' is
used as a separator; also ensure you defensively check the resulting array
length before accessing index 1 to avoid IndexOutOfBounds in malformed cases.

200-201: Long.parseLong()에 대한 방어 코드가 필요합니다.

Redis 데이터가 손상되거나 예상치 못한 형식일 경우, Long.parseLong(valueForSplit[0])에서 NumberFormatException이 발생하여 500 에러로 이어집니다. 이를 적절한 비즈니스 예외(ORG_INVITATION_INVALID)로 변환하면 클라이언트에게 명확한 에러 응답을 줄 수 있습니다.

🛡️ 수정 제안
-        Organization organization = orgRepository.findById(Long.parseLong(valueForSplit[0]))
-                .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND));
+        Long orgId;
+        try {
+            orgId = Long.parseLong(valueForSplit[0]);
+        } catch (NumberFormatException e) {
+            throw new OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID);
+        }
+        Organization organization = orgRepository.findById(orgId)
+                .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 200 - 201, The code calling Long.parseLong(valueForSplit[0]) can
throw NumberFormatException and must be defended: update OrgServiceImpl so you
first parse the id into a long inside a try/catch (or pre-validate numeric) and
assign to a variable (e.g., parsedOrgId) and then call
orgRepository.findById(parsedOrgId). If parsing fails, catch
NumberFormatException and throw the business exception OrgHandler with
OrgErrorCode.ORG_INVITATION_INVALID instead of letting the NFE propagate; keep
the existing orElseThrow(()-> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND)) for
repository misses.

164-168: Redis key 설계가 깔끔합니다. 다만 rate limiting 고려가 필요할 수 있습니다.

INVITE: prefix + UUID 토큰 패턴과 24시간 TTL은 잘 설계되었습니다. 👍

다만 운영 관점에서, 동일 이메일/조직에 대한 중복 초대를 방지하는 로직이 없습니다. 악의적 사용자가 같은 이메일로 반복 호출하면 Redis에 토큰이 계속 쌓이고 이메일이 반복 발송됩니다. 현재 단계에서 필수는 아니지만, 향후 rate limiting 또는 동일 조직+이메일 조합에 대한 중복 초대 방지를 고려해 보시면 좋겠습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 164 - 168, Add guard logic around the invite token creation in
OrgServiceImpl: before generating the UUID/token and calling
redisUtil.setDataExpire, check for an existing pending invite for the same
orgId+email (e.g. a reverse key like "INVITE_BY:"+orgId+":"+email via
redisUtil.getData or use SETNX semantics) and either reuse the existing token or
return an error to prevent duplicate invites; if you reuse/create a token ensure
you set both keys ("INVITE:"+token -> orgId:email and
"INVITE_BY:"+orgId+":"+email -> token) atomically (or with SETNX and
expirations) so repeated calls for the same org+email are blocked, and
optionally increment a short-lived counter key (e.g. "INVITE_RATE:"+email) with
expiry to enforce rate limiting using INCR + EXPIRE.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`:
- Line 207: The orgMemberRepository.save(...) call in OrgServiceImpl is using
OrgMemberConverter.toOrgMemberADMIN which wrongly grants ADMIN and sets joinedAt
to organization.getCreatedAt(); change this to create a MEMBER entry by adding a
converter method like OrgMemberConverter.toOrgMemberMEMBER that sets role to
OrgRole.MEMBER and joinedAt to LocalDateTime.now(), then update the
OrgServiceImpl save invocation to use that new toOrgMemberMEMBER method so
invited users start as MEMBER with the correct current joinedAt timestamp.

---

Nitpick comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`:
- Around line 188-189: In OrgServiceImpl update the parsing of the Redis value:
replace the current split usage on the variable value (currently done in
String[] valueForSplit = value.split(":"); and then reading valueForSplit[1]
into email) with a bounded split using split(":", 2) so only the first ':' is
used as a separator; also ensure you defensively check the resulting array
length before accessing index 1 to avoid IndexOutOfBounds in malformed cases.
- Around line 200-201: The code calling Long.parseLong(valueForSplit[0]) can
throw NumberFormatException and must be defended: update OrgServiceImpl so you
first parse the id into a long inside a try/catch (or pre-validate numeric) and
assign to a variable (e.g., parsedOrgId) and then call
orgRepository.findById(parsedOrgId). If parsing fails, catch
NumberFormatException and throw the business exception OrgHandler with
OrgErrorCode.ORG_INVITATION_INVALID instead of letting the NFE propagate; keep
the existing orElseThrow(()-> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND)) for
repository misses.
- Around line 164-168: Add guard logic around the invite token creation in
OrgServiceImpl: before generating the UUID/token and calling
redisUtil.setDataExpire, check for an existing pending invite for the same
orgId+email (e.g. a reverse key like "INVITE_BY:"+orgId+":"+email via
redisUtil.getData or use SETNX semantics) and either reuse the existing token or
return an error to prevent duplicate invites; if you reuse/create a token ensure
you set both keys ("INVITE:"+token -> orgId:email and
"INVITE_BY:"+orgId+":"+email -> token) atomically (or with SETNX and
expirations) so repeated calls for the same org+email are blocked, and
optionally increment a short-lived counter key (e.g. "INVITE_RATE:"+email) with
expiry to enforce rate limiting using INCR + EXPIRE.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/persistence/repository/OrgMemberRepository.java`:
- Around line 47-48: Change the return type of the repository method
existsByUserAndOrganization in OrgMemberRepository from the wrapper Boolean to
the primitive boolean to follow Spring Data JPA conventions and avoid nullable
semantics, and fix the method declaration indentation to match other methods
(use 4-space indent instead of the current double-indent) so formatting is
consistent; update the signature in the OrgMemberRepository interface
accordingly.
- Around line 47-48: Replace the throw-inside-ifPresent pattern with an explicit
conditional to avoid side-effectful lambdas: call
userRepository.findUserByEmail(email) into an Optional (e.g., userOpt) and then
check if userOpt.isPresent() &&
orgMemberRepository.existsByUserAndOrganization(userOpt.get(), organization) and
throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE) in that
conditional; this aligns the style with the other method and makes the control
flow around orgMemberRepository.existsByUserAndOrganization and OrgHandler
clearer.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/docs/OrgControllerDocs.java`:
- Around line 100-110: Add missing API response documentation for HTTP 401 and
403 to the sendOrgInvitation operation: update the `@ApiResponses` on the
sendOrgInvitation method to include a `@ApiResponse`(responseCode = "401",
description = "인증 필요") and a `@ApiResponse`(responseCode = "403", description =
"권한 없음 / ORG_FORBIDDEN") so it matches acceptOrgInvitation and reflects that the
endpoint requires authentication (`@AuthenticationPrincipal` userId) and may throw
ORG_FORBIDDEN when the caller is not a member.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/OrgController.java`:
- Line 118: Update the `@PostMapping` on the invitations endpoint in
OrgController: change the annotation `@PostMapping`("invitations/{token}") to
include the leading slash so it becomes `@PostMapping`("/invitations/{token}"),
ensuring consistency with other mappings like "/create" and "/{orgId}" in the
OrgController class and preserving existing behavior.

if (orgMemberRepository.existsByUserAndOrganization(user, organization))
throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);

orgMemberRepository.save(OrgMemberConverter.toOrgMemberADMIN(user, organization));
Copy link
Collaborator

@ojy0903 ojy0903 Feb 19, 2026

Choose a reason for hiding this comment

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

P3: 여기서 사용되는 toOrgMemberADMIN() 은 회원이 조직 생성할 때 ADMIN 권한을 갖는 OrgMember 를 생성하는 메서드라서 멤버 초대 라면 권한을 우선 MEMBER 로 하도록 하고, 이후에 권한 변경을 하는 것이 더 좋을 것 같아요! OrgMemberConverter 내부에 toOrgMemberMEMBER() 등으로 메서드를 만들어서 role 을 OrgRole.MEMBER 로 설정하도록 하면 어떨까요?

Copy link
Collaborator Author

@jinnieusLab jinnieusLab Feb 19, 2026

Choose a reason for hiding this comment

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

넵! 우선 권한을 전부 ADMIN으로 처리할까 했는데, 말씀해주신 것처럼 초대 수락하여 조직에 추가되는 경우는 권한을 MEMBER로 기본적으로 두는 게 나을 듯 하네요!

+) OrgMember 컨버터 내 toOrgMemberMEMBER() 메서드 구현하는 것으로 변경해두었습니다!

Copy link
Collaborator

@ojy0903 ojy0903 left a comment

Choose a reason for hiding this comment

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

P4: 고생하셨습니다! 초대 이메일 전송에서도 테스트 계정(이메일이 test@example.com 형식) 일 경우에는 보내지 않는 방법을 적용 해야할까 생각 해봤는데, 초대 URL 을 보내는 방식이여서 서버 로그로 대체하긴 힘들 것 같고 지금처럼 진행하되 프론트쪽에서 연동할 때는 "테스트 계정으로 조직 초대 이메일 전송은 권장되지 않으니 실제로 사용 가능한 이메일 값으로 API 요청 바란다" 고 전달 해주면 될 거 같아요!!

@ojy0903
Copy link
Collaborator

ojy0903 commented Feb 19, 2026

conflict 는 제 조직 조회 PR 머지해서 생긴 것 같은데 제 코드를 반영하는 방식으로 해주시면 될 거 같습니다!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java (1)

237-238: token == null 체크는 사실상 도달 불가능한 코드입니다.

token@PathVariable로 바인딩되므로, Spring이 URL 매핑 시점에서 null인 경우를 처리합니다 (경로 자체가 매칭되지 않아 404 반환). 코드가 실행되는 시점에서는 token이 항상 non-null입니다.

방어적 코딩 차원에서 남겨두는 것은 문제없지만, 제거해도 동작에 영향이 없습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 237 - 238, The null check and exception throw for token in
OrgServiceImpl (the "if (token == null) throw new
OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID);") is unreachable because token
is bound via `@PathVariable` and cannot be null at execution time; remove that
conditional from the method in OrgServiceImpl to simplify the code (or, if you
prefer defensive logging only, replace it with a non-throwing null-guard that
logs but does not change control flow), ensuring references to token and
OrgHandler/OrgErrorCode remain intact where used elsewhere.
src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java (1)

24-37: enum 상수 정렬이 HTTP 상태 코드 기준으로 흐트러져 있습니다.

현재 순서가 400 → 403 → 404 → 409 → 410 → 409 → 400 → 403으로 되어 있어, 나중에 새로운 에러 코드를 추가할 때 어디에 넣어야 할지 혼란스러울 수 있어요. 초대 관련 에러 코드들도 동일한 HTTP 상태 코드끼리 묶어서 정리하면 가독성이 훨씬 좋아집니다.

예를 들어 ORG_INVITATION_INVALID는 상단 400 그룹에, ORG_INVITATION_FORBIDDEN_USER는 403 그룹에, ORG_MEMBER_ALREADY_ACTIVE는 409 그룹에 배치하는 식입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`
around lines 24 - 37, The enum constants in OrgErrorCode are out of order by
HTTP status and should be regrouped for clarity: move ORG_INVITATION_INVALID
into the 400 group, ORG_INVITATION_FORBIDDEN_USER into the 403 group, and
ORG_MEMBER_ALREADY_ACTIVE into the 409 group so all entries are grouped by
HttpStatus (e.g., place ORG_INVITATION_INVALID with other BAD_REQUEST entries,
ORG_INVITATION_FORBIDDEN_USER with FORBIDDEN entries, and
ORG_MEMBER_ALREADY_ACTIVE with CONFLICT entries); update or preserve the comment
markers (e.g., "// 400", "// 403", "// 409", "// 410") accordingly to reflect
the new ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`:
- Around line 258-268: Move the Redis token consumption ahead of the DB write to
prevent TOCTOU races: call redisUtil.deleteData("INVITE:"+token) first and
verify it actually deleted the key (use the method's boolean/response to check
success), if deletion failed throw the appropriate OrgHandler (e.g., token
invalid/consumed) and abort; only after successful deletion, load the
Organization via orgRepository.findById(...), perform the duplicate check with
orgMemberRepository.existsByUserAndOrganization(user, organization) and then
call orgMemberRepository.save(OrgMemberConverter.toOrgMemberMEMBER(user,
organization)); also ensure the enclosing method is transactional so the member
save is atomic relative to other DB ops.
- Around line 246-247: In OrgServiceImpl where you parse the Redis string into
parts using value.split(":"), the current logic (String[] valueForSplit =
value.split(":"); String email = valueForSplit[1];) will break if the email
contains colons; change the split to limit to two parts (e.g., use
value.split(":", 2)) so only the first colon is used as the delimiter and then
derive email from the second element, ensuring valueForSplit and email
extraction remain safe even when the email contains colons.
- Around line 206-213: The current check in OrgServiceImpl only verifies
membership via orgMemberRepository.existsByUserAndOrganization (allowing any
MEMBER to invite) which mismatches the OrgControllerDocs; update the
invite-authority check to require the inviter be an ADMIN: after loading User
sender (userRepository.findById) retrieve the OrgMember (e.g.,
orgMemberRepository.findByUserAndOrganization or equivalent) for the sender and
assert its role/authority equals ADMIN, throwing
OrgHandler(OrgErrorCode.ORG_FORBIDDEN) if not; ensure you remove or replace the
simple existsByUserAndOrganization check and use the OrgMember role field for
the authorization decision so only ADMINs can invite.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/docs/OrgControllerDocs.java`:
- Around line 152-162: Update the Swagger docs for acceptOrgInvitation to
include the 409 response for already-active members: add an
`@ApiResponse`(responseCode = "409", description = "이미 조직
멤버(ORG_MEMBER_ALREADY_ACTIVE)") to the method declaration so the API contract
matches OrgServiceImpl.acceptOrgInvitation throwing ORG_MEMBER_ALREADY_ACTIVE;
ensure the description clearly states this is the conflict when the user is
already a member.

---

Duplicate comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`:
- Around line 30-31: ORG_MEMBER_ALREADY_ACTIVE의 사용자 메시지가 혼동을 주므로 OrgErrorCode
클래스에서 해당 열거상수 ORG_MEMBER_ALREADY_ACTIVE의 메시지 값을 "이미 해당 조직의 멤버입니다."로 수정하세요; 상수
이름(HttpStatus.CONFLICT, "ORG_MEMBER_409_1")은 그대로 두고 세 번째 파라미터 문자열만 정확한 문구로 교체해
주세요.

---

Nitpick comments:
In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`:
- Around line 237-238: The null check and exception throw for token in
OrgServiceImpl (the "if (token == null) throw new
OrgHandler(OrgErrorCode.ORG_INVITATION_INVALID);") is unreachable because token
is bound via `@PathVariable` and cannot be null at execution time; remove that
conditional from the method in OrgServiceImpl to simplify the code (or, if you
prefer defensive logging only, replace it with a non-throwing null-guard that
logs but does not change control flow), ensuring references to token and
OrgHandler/OrgErrorCode remain intact where used elsewhere.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/exception/code/OrgErrorCode.java`:
- Around line 24-37: The enum constants in OrgErrorCode are out of order by HTTP
status and should be regrouped for clarity: move ORG_INVITATION_INVALID into the
400 group, ORG_INVITATION_FORBIDDEN_USER into the 403 group, and
ORG_MEMBER_ALREADY_ACTIVE into the 409 group so all entries are grouped by
HttpStatus (e.g., place ORG_INVITATION_INVALID with other BAD_REQUEST entries,
ORG_INVITATION_FORBIDDEN_USER with FORBIDDEN entries, and
ORG_MEMBER_ALREADY_ACTIVE with CONFLICT entries); update or preserve the comment
markers (e.g., "// 400", "// 403", "// 409", "// 410") accordingly to reflect
the new ordering.

Comment on lines +206 to +213
// 초대자와 조직 관계 검증 (초대자가 조직의 멤버인지 확인)
User sender = userRepository.findById(userId)
.orElseThrow(() -> new UserHandler(UserErrorCode.USER_NOT_FOUND));

if (!orgMemberRepository.existsByUserAndOrganization(sender, organization)) {
// 초대자가 조직 멤버가 아님 -> 권한 없음
throw new OrgHandler(OrgErrorCode.ORG_FORBIDDEN);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# OrgControllerDocs에서 sendOrgInvitation 관련 설명 찾기
find . -type f -name "*ControllerDocs*.java" | head -20

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 472


🏁 Script executed:

# OrgServiceImpl 파일 위치 및 내용 확인
find . -type f -name "OrgServiceImpl.java" -exec wc -l {} \;

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 174


🏁 Script executed:

# sendOrgInvitation 메서드 전체 구현 찾기
rg -n "sendOrgInvitation" --type=java -C 15

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 16434


🏁 Script executed:

# OrgRole enum 구조 확인
find . -type f -name "OrgRole.java" -o -name "OrgRole*.java"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 164


🏁 Script executed:

# orgMemberRepository 메서드 확인
rg -n "orgMemberRepository|OrgMemberRepository" --type=java -A 3

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 11013


🏁 Script executed:

cat src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/constant/OrgRole.java

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 187


🏁 Script executed:

find . -type f -name "OrgErrorCode.java" -exec cat {} \;

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 1694


🏁 Script executed:

# sendOrgInvitation 메서드 전체 흐름 확인
sed -n '202,230p' src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 1451


Swagger 문서와 초대 권한 검증이 불일치합니다.

OrgControllerDocs에서는 "조직 관리자가 이메일을 입력하여 새로운 멤버를 초대합니다"라고 명시했는데, 실제 코드는 existsByUserAndOrganization만 검사하여 일반 MEMBER도 초대를 보낼 수 있습니다.

의도적으로 모든 멤버가 초대할 수 있다면 Swagger 설명을 수정하고, ADMIN만 허용해야 한다면 역할 검증 로직을 추가하세요.

ADMIN만 초대 가능하도록 수정하는 경우 예시
         if (!orgMemberRepository.existsByUserAndOrganization(sender, organization)) {
             throw new OrgHandler(OrgErrorCode.ORG_FORBIDDEN);
         }

+        // ADMIN만 초대 가능하도록 역할 검증
+        OrgMember senderMember = orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
+                .orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_MEMBER_NOT_FOUND));
+        if (senderMember.getRole() != OrgRole.ADMIN) {
+            throw new OrgHandler(OrgErrorCode.ORG_MEMBER_FORBIDDEN);
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 206 - 213, The current check in OrgServiceImpl only verifies
membership via orgMemberRepository.existsByUserAndOrganization (allowing any
MEMBER to invite) which mismatches the OrgControllerDocs; update the
invite-authority check to require the inviter be an ADMIN: after loading User
sender (userRepository.findById) retrieve the OrgMember (e.g.,
orgMemberRepository.findByUserAndOrganization or equivalent) for the sender and
assert its role/authority equals ADMIN, throwing
OrgHandler(OrgErrorCode.ORG_FORBIDDEN) if not; ensure you remove or replace the
simple existsByUserAndOrganization check and use the OrgMember role field for
the authorization decision so only ADMINs can invite.

Comment on lines +246 to +247
String[] valueForSplit = value.split(":");
String email = valueForSplit[1];
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the file and check its structure
find . -type f -name "OrgServiceImpl.java" | head -5

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Once we have the file path, let's examine the relevant sections
# Find lines 200-270 to see the full context of both methods
if [ -f "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java" ]; then
  sed -n '200,270p' "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java"
else
  find . -name "OrgServiceImpl.java" -exec sed -n '200,270p' {} \;
fi

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 3050


🏁 Script executed:

#!/bin/bash
# Let's also check where the Redis value is being set (the "orgId:email" format)
if [ -f "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java" ]; then
  grep -n "redisTemplate\|setValue\|set(" "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java" | head -20
fi

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Search for Redis operations and INVITE token handling
rg -n "INVITE|redis|set\(" --type=java "src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java"

Repository: WhereYouAd/WhereYouAd-Backend

Length of output: 307


⚠️ Redis 값 파싱 시 split(":")의 한계 — 안전하지 않은 분할

Redis에 "orgId:email" 형태로 저장하되, split(":")로 분리하는 것은 이메일에 콜론이 포함될 경우 문제가 됩니다. RFC 5321 표준상 이메일 로컬 부분은 따옴표 처리 시 특수문자(: 포함) 사용이 가능하며, 이 경우 파싱이 깨집니다.

예: "user:name"@example.com 형태의 이메일이 Redis에 123:user:name@example.com으로 저장되면, split(":")[1]user만 추출되어 나머지 :name@example.com을 잃습니다.

더 근본적으로, Redis 값 포맷에 콜론이 추가되거나 변경되는 순간 이 파싱 로직은 깨질 수 있습니다. split(":", 2)를 사용하면 첫 번째 콜론만 기준으로 분리되어 안전합니다.

✅ 수정 제안
-        String[] valueForSplit = value.split(":");
+        String[] valueForSplit = value.split(":", 2);
         String email = valueForSplit[1];
📝 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
String[] valueForSplit = value.split(":");
String email = valueForSplit[1];
String[] valueForSplit = value.split(":", 2);
String email = valueForSplit[1];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 246 - 247, In OrgServiceImpl where you parse the Redis string into
parts using value.split(":"), the current logic (String[] valueForSplit =
value.split(":"); String email = valueForSplit[1];) will break if the email
contains colons; change the split to limit to two parts (e.g., use
value.split(":", 2)) so only the first colon is used as the delimiter and then
derive email from the second element, ensuring valueForSplit and email
extraction remain safe even when the email contains colons.

Comment on lines +258 to +268
Organization organization = orgRepository.findById(Long.parseLong(valueForSplit[0]))
.orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND));

// 이미 멤버인지 중복 체크
if (orgMemberRepository.existsByUserAndOrganization(user, organization))
throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);

orgMemberRepository.save(OrgMemberConverter.toOrgMemberMEMBER(user, organization));

// Redis 사용 토큰 삭제
redisUtil.deleteData("INVITE:" + token);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

토큰 수락 시 TOCTOU(Time-of-Check to Time-of-Use) 경합 조건이 있습니다.

현재 흐름:

  1. Line 262: 멤버 중복 체크 ✅
  2. Line 265: OrgMember 저장
  3. Line 268: Redis 토큰 삭제

문제는 두 사용자가 동시에 같은 토큰으로 수락 요청을 보내면, 둘 다 중복 체크를 통과하고 멤버가 두 번 생성될 수 있다는 것입니다. Redis 토큰 삭제가 DB 저장 이후에 일어나기 때문에, 그 사이 시간 동안 다른 요청이 동일 토큰을 사용할 수 있어요.

해결 방법: Redis 토큰 삭제를 먼저 수행하고(일종의 원자적 잠금 역할), 삭제 성공 시에만 멤버를 등록하세요. 이렇게 하면 토큰을 먼저 소비한 요청만 진행됩니다.

🛡️ 수정 제안
+        // 토큰을 먼저 소비하여 동시 수락 방지 (원자적 삭제)
+        String redisKey = "INVITE:" + token;
+        redisUtil.deleteData(redisKey);

         // 이미 멤버인지 중복 체크
         if (orgMemberRepository.existsByUserAndOrganization(user, organization))
             throw new OrgHandler(OrgErrorCode.ORG_MEMBER_ALREADY_ACTIVE);

         orgMemberRepository.save(OrgMemberConverter.toOrgMemberMEMBER(user, organization));

-        // Redis 사용 토큰 삭제
-        redisUtil.deleteData("INVITE:" + token);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/domain/service/OrgServiceImpl.java`
around lines 258 - 268, Move the Redis token consumption ahead of the DB write
to prevent TOCTOU races: call redisUtil.deleteData("INVITE:"+token) first and
verify it actually deleted the key (use the method's boolean/response to check
success), if deletion failed throw the appropriate OrgHandler (e.g., token
invalid/consumed) and abort; only after successful deletion, load the
Organization via orgRepository.findById(...), perform the duplicate check with
orgMemberRepository.existsByUserAndOrganization(user, organization) and then
call orgMemberRepository.save(OrgMemberConverter.toOrgMemberMEMBER(user,
organization)); also ensure the enclosing method is transactional so the member
save is atomic relative to other DB ops.

Comment on lines +152 to +162
@Operation(summary = "조직 초대 수락 API", description = "이메일로 받은 초대 토큰을 통해 조직 가입을 수락합니다. (로그인 필수, 본인 확인)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공"),
@ApiResponse(responseCode = "400", description = "유효하지 않거나 만료된 토큰"),
@ApiResponse(responseCode = "401", description = "로그인 필요"),
@ApiResponse(responseCode = "403", description = "초대된 이메일과 로그인한 사용자가 불일치")
})
public ResponseEntity<DataResponse<OrgResponse.OrgInvitationResponse>> acceptOrgInvitation(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable String token
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

acceptOrgInvitation에 409 응답 코드가 빠져 있습니다.

OrgServiceImpl.acceptOrgInvitation에서 이미 조직 멤버인 경우 ORG_MEMBER_ALREADY_ACTIVE (409 Conflict)를 던지는데, Swagger 문서에는 이 응답이 누락되어 있습니다. API 사용자(프론트엔드 개발자)가 이 케이스를 처리하지 못할 수 있어요.

✏️ 수정 제안
     `@ApiResponses`({
             `@ApiResponse`(responseCode = "200", description = "성공"),
             `@ApiResponse`(responseCode = "400", description = "유효하지 않거나 만료된 토큰"),
             `@ApiResponse`(responseCode = "401", description = "로그인 필요"),
-            `@ApiResponse`(responseCode = "403", description = "초대된 이메일과 로그인한 사용자가 불일치")
+            `@ApiResponse`(responseCode = "403", description = "초대된 이메일과 로그인한 사용자가 불일치"),
+            `@ApiResponse`(responseCode = "409", description = "이미 해당 조직의 멤버인 경우")
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/organization/presentation/docs/OrgControllerDocs.java`
around lines 152 - 162, Update the Swagger docs for acceptOrgInvitation to
include the 409 response for already-active members: add an
`@ApiResponse`(responseCode = "409", description = "이미 조직
멤버(ORG_MEMBER_ALREADY_ACTIVE)") to the method declaration so the API contract
matches OrgServiceImpl.acceptOrgInvitation throwing ORG_MEMBER_ALREADY_ACTIVE;
ensure the description clearly states this is the conflict when the user is
already a member.

Copy link
Collaborator

@kingmingyu kingmingyu left a comment

Choose a reason for hiding this comment

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

P4: Redis를 이용해서 초대하는 방식 좋은 것 같습니다! 저도 당장은 다른 방식으로 구현하는 방식이 떠오르지 않아서 uuid로 구분하는 것이 좋다고 생각합니다! 고생하셨습니다!!

@jinnieusLab jinnieusLab merged commit a0eb899 into develop Feb 19, 2026
2 checks passed
@jinnieusLab jinnieusLab deleted the feat/#27 branch February 19, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 조직 멤버 초대 기능 구현

3 participants

Comments