tenantId(); $query = UserInvitation::query() ->where('tenant_id', $tenantId) ->with(['role:id,name', 'inviter:id,name']); // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 이메일 검색 if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('email', 'like', "%{$search}%"); }); } // 정렬 $sortBy = $params['sort_by'] ?? 'created_at'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); // 페이지네이션 $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 사용자 초대 발송 */ public function invite(array $data): UserInvitation { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // role 문자열 → role_id 변환 (React 호환) if (! empty($data['role']) && empty($data['role_id'])) { $role = Role::where('name', $data['role']) ->where('tenant_id', $tenantId) ->first(); $data['role_id'] = $role?->id; } return DB::transaction(function () use ($data, $tenantId, $userId) { $email = $data['email']; // 이미 테넌트에 가입된 사용자인지 확인 $existingUser = User::where('email', $email)->first(); if ($existingUser) { $existingMembership = UserTenant::where('tenant_id', $tenantId) ->where('user_id', $existingUser->id) ->whereNull('deleted_at') ->exists(); if ($existingMembership) { throw new BadRequestHttpException(__('error.invitation.already_member')); } } // 이미 대기 중인 초대가 있는지 확인 $pendingInvitation = UserInvitation::where('tenant_id', $tenantId) ->where('email', $email) ->pending() ->first(); if ($pendingInvitation) { throw new BadRequestHttpException(__('error.invitation.already_pending')); } // 초대 생성 $invitation = new UserInvitation; $invitation->tenant_id = $tenantId; $invitation->email = $email; $invitation->role_id = $data['role_id'] ?? null; $invitation->message = $data['message'] ?? null; $invitation->token = UserInvitation::generateToken(); $invitation->status = UserInvitation::STATUS_PENDING; $invitation->invited_by = $userId; $invitation->expires_at = UserInvitation::calculateExpiresAt($data['expires_days'] ?? UserInvitation::DEFAULT_EXPIRES_DAYS); $invitation->save(); // 초대 이메일 발송 (비동기 처리 권장) $this->sendInvitationEmail($invitation); return $invitation->load(['role:id,name', 'inviter:id,name']); }); } /** * 초대 수락 */ public function accept(string $token, array $userData): User { return DB::transaction(function () use ($token, $userData) { // 토큰으로 초대 조회 (테넌트 스코프 제외) $invitation = UserInvitation::withoutGlobalScopes() ->where('token', $token) ->first(); if (! $invitation) { throw new NotFoundHttpException(__('error.invitation.not_found')); } if (! $invitation->canAccept()) { if ($invitation->isExpired()) { $invitation->markAsExpired(); throw new BadRequestHttpException(__('error.invitation.expired')); } throw new BadRequestHttpException(__('error.invitation.invalid_status')); } // 기존 사용자 확인 또는 신규 생성 $user = User::where('email', $invitation->email)->first(); if (! $user) { // 신규 사용자 생성 $user = new User; $user->email = $invitation->email; $user->name = $userData['name']; $user->password = $userData['password']; $user->phone = $userData['phone'] ?? null; $user->save(); } // 테넌트 멤버십 생성 $userTenant = new UserTenant; $userTenant->user_id = $user->id; $userTenant->tenant_id = $invitation->tenant_id; $userTenant->is_active = true; $userTenant->is_default = ! $user->userTenants()->exists(); // 첫 테넌트면 기본으로 $userTenant->joined_at = now(); $userTenant->save(); // 역할 부여 (있는 경우) if ($invitation->role_id) { $user->assignRole($invitation->role->name); } // 초대 수락 처리 $invitation->markAsAccepted(); return $user; }); } /** * 초대 취소 */ public function cancel(int $id): bool { $tenantId = $this->tenantId(); $invitation = UserInvitation::where('tenant_id', $tenantId) ->findOrFail($id); if (! $invitation->canCancel()) { throw new BadRequestHttpException(__('error.invitation.cannot_cancel')); } $invitation->markAsCancelled(); return true; } /** * 초대 재발송 */ public function resend(int $id): UserInvitation { $tenantId = $this->tenantId(); $invitation = UserInvitation::where('tenant_id', $tenantId) ->findOrFail($id); if ($invitation->status !== UserInvitation::STATUS_PENDING) { throw new BadRequestHttpException(__('error.invitation.cannot_resend')); } // 만료 기간 연장 및 토큰 재생성 $invitation->token = UserInvitation::generateToken(); $invitation->expires_at = UserInvitation::calculateExpiresAt(); $invitation->save(); // 이메일 재발송 $this->sendInvitationEmail($invitation); return $invitation->load(['role:id,name', 'inviter:id,name']); } /** * 만료된 초대 일괄 처리 (스케줄러용) */ public function expirePendingInvitations(): int { return UserInvitation::withoutGlobalScopes() ->expiredPending() ->update(['status' => UserInvitation::STATUS_EXPIRED]); } /** * 초대 이메일 발송 */ protected function sendInvitationEmail(UserInvitation $invitation): void { // TODO: 실제 메일 발송 로직 구현 // Mail::to($invitation->email)->queue(new UserInvitationMail($invitation)); // 현재는 로그로 대체 \Log::info('User invitation email sent', [ 'email' => $invitation->email, 'token' => $invitation->token, 'tenant_id' => $invitation->tenant_id, ]); } }