390 lines
14 KiB
PHP
390 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\OrganizationUserModel;
|
||
use App\Models\OrganizationModel;
|
||
use App\Models\UserModel;
|
||
use Config\Services;
|
||
|
||
class InvitationService
|
||
{
|
||
protected OrganizationUserModel $orgUserModel;
|
||
protected OrganizationModel $orgModel;
|
||
protected UserModel $userModel;
|
||
protected string $baseUrl;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->orgUserModel = new OrganizationUserModel();
|
||
$this->orgModel = new OrganizationModel();
|
||
$this->userModel = new UserModel();
|
||
$this->baseUrl = rtrim(config('App')->baseURL, '/');
|
||
}
|
||
|
||
/**
|
||
* Создание приглашения в организацию
|
||
*
|
||
* @param int $organizationId ID организации
|
||
* @param string $email Email приглашаемого
|
||
* @param string $role Роль (admin, manager, guest)
|
||
* @param int $invitedBy ID пользователя, отправляющего приглашение
|
||
* @return array ['success' => bool, 'message' => string, 'invite_link' => string, 'invitation_id' => int]
|
||
*/
|
||
public function createInvitation(int $organizationId, string $email, string $role, int $invitedBy): array
|
||
{
|
||
// Валидация email
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Некорректный email адрес',
|
||
'invite_link' => '',
|
||
'invitation_id' => 0,
|
||
];
|
||
}
|
||
|
||
// Получаем организацию
|
||
$organization = $this->orgModel->find($organizationId);
|
||
if (!$organization) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Организация не найдена',
|
||
'invite_link' => '',
|
||
'invitation_id' => 0,
|
||
];
|
||
}
|
||
|
||
// Проверяем, существует ли пользователь
|
||
$existingUser = $this->userModel->where('email', $email)->first();
|
||
$userId = $existingUser['id'] ?? null;
|
||
|
||
// Проверяем, не состоит ли уже пользователь в организации
|
||
if ($userId) {
|
||
$existingMembership = $this->orgUserModel
|
||
->where('organization_id', $organizationId)
|
||
->where('user_id', $userId)
|
||
->first();
|
||
|
||
if ($existingMembership) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Пользователь уже состоит в этой организации',
|
||
'invite_link' => '',
|
||
'invitation_id' => 0,
|
||
];
|
||
}
|
||
|
||
// Проверяем, есть ли уже приглашение
|
||
if ($this->orgUserModel->hasPendingInvite($organizationId, $userId)) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Приглашение для этого пользователя уже отправлено',
|
||
'invite_link' => '',
|
||
'invitation_id' => 0,
|
||
];
|
||
}
|
||
}
|
||
|
||
// Генерируем токен приглашения
|
||
$inviteToken = $this->generateToken();
|
||
$inviteExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||
|
||
// Создаем запись приглашения
|
||
$invitationData = [
|
||
'organization_id' => $organizationId,
|
||
'user_id' => $userId, // NULL для новых пользователей
|
||
'role' => $role,
|
||
'status' => OrganizationUserModel::STATUS_PENDING,
|
||
'invite_token' => $inviteToken,
|
||
'invite_expires_at' => $inviteExpiresAt,
|
||
'invited_by' => $invitedBy,
|
||
];
|
||
|
||
$invitationId = $this->orgUserModel->createInvitation($invitationData);
|
||
|
||
if (!$invitationId) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Ошибка при создании приглашения',
|
||
'invite_link' => '',
|
||
'invitation_id' => 0,
|
||
];
|
||
}
|
||
|
||
// Если пользователь новый - создаем "теневую" запись в users
|
||
if (!$existingUser) {
|
||
$this->createShadowUser($email);
|
||
}
|
||
|
||
// Формируем ссылку приглашения
|
||
$inviteLink = $this->baseUrl . '/invitation/accept/' . $inviteToken;
|
||
|
||
// Отправляем email
|
||
$emailSent = $this->sendInvitationEmail($email, $organization['name'], $role, $inviteLink);
|
||
|
||
return [
|
||
'success' => $emailSent,
|
||
'message' => $emailSent
|
||
? 'Приглашение успешно отправлено'
|
||
: 'Приглашение создано, но не удалось отправить email',
|
||
'invite_link' => $inviteLink,
|
||
'invitation_id' => $invitationId,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Принятие приглашения
|
||
*/
|
||
public function acceptInvitation(string $token, int $userId): array
|
||
{
|
||
$invitation = $this->orgUserModel->findByInviteToken($token);
|
||
|
||
if (!$invitation) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Приглашение не найдено или уже обработано',
|
||
];
|
||
}
|
||
|
||
// Обновляем приглашение
|
||
$updated = $this->orgUserModel->acceptInvitation($invitation['id'], $userId);
|
||
|
||
if (!$updated) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Ошибка при принятии приглашения',
|
||
];
|
||
}
|
||
|
||
// Если это был теневой пользователь - привязываем к реальному
|
||
if ($invitation['user_id'] === null) {
|
||
$this->bindShadowUser($invitation['organization_id'], $userId);
|
||
}
|
||
|
||
// Получаем организацию для редиректа
|
||
$organization = $this->orgModel->find($invitation['organization_id']);
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => 'Приглашение принято',
|
||
'organization_id' => $invitation['organization_id'],
|
||
'organization_name' => $organization['name'] ?? '',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Отклонение приглашения
|
||
*/
|
||
public function declineInvitation(string $token): array
|
||
{
|
||
$invitation = $this->orgUserModel->findByInviteToken($token);
|
||
|
||
if (!$invitation) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Приглашение не найдено или уже обработано',
|
||
];
|
||
}
|
||
|
||
$deleted = $this->orgUserModel->declineInvitation($invitation['id']);
|
||
|
||
return [
|
||
'success' => $deleted,
|
||
'message' => $deleted ? 'Приглашение отклонено' : 'Ошибка при отклонении приглашения',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Отзыв приглашения (отправителем)
|
||
*/
|
||
public function cancelInvitation(int $invitationId, int $organizationId): array
|
||
{
|
||
$invitation = $this->orgUserModel
|
||
->where('id', $invitationId)
|
||
->where('organization_id', $organizationId)
|
||
->where('status', OrganizationUserModel::STATUS_PENDING)
|
||
->first();
|
||
|
||
if (!$invitation) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Приглашение не найдено',
|
||
];
|
||
}
|
||
|
||
$deleted = $this->orgUserModel->cancelInvitation($invitationId);
|
||
|
||
return [
|
||
'success' => $deleted,
|
||
'message' => $deleted ? 'Приглашение отозвано' : 'Ошибка при отзыве приглашения',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Повторная отправка приглашения
|
||
*/
|
||
public function resendInvitation(int $invitationId, int $organizationId): array
|
||
{
|
||
$invitation = $this->orgUserModel
|
||
->where('id', $invitationId)
|
||
->where('organization_id', $organizationId)
|
||
->where('status', OrganizationUserModel::STATUS_PENDING)
|
||
->first();
|
||
|
||
if (!$invitation) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Приглашение не найдено',
|
||
];
|
||
}
|
||
|
||
// Генерируем новый токен
|
||
$newToken = $this->generateToken();
|
||
$newExpiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||
$this->orgUserModel->update($invitationId, [
|
||
'invite_token' => $newToken,
|
||
'invite_expires_at' => $newExpiresAt,
|
||
'invited_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
|
||
// Получаем email пользователя
|
||
$user = $this->userModel->find($invitation['user_id']);
|
||
if (!$user) {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Пользователь не найден',
|
||
];
|
||
}
|
||
|
||
// Формируем ссылку
|
||
$organization = $this->orgModel->find($organizationId);
|
||
$inviteLink = $this->baseUrl . '/invitation/accept/' . $newToken;
|
||
|
||
// Отправляем email
|
||
$sent = $this->sendInvitationEmail(
|
||
$user['email'],
|
||
$organization['name'],
|
||
$invitation['role'],
|
||
$inviteLink
|
||
);
|
||
|
||
return [
|
||
'success' => $sent,
|
||
'message' => $sent ? 'Приглашение отправлено повторно' : 'Ошибка отправки',
|
||
'invite_link' => $inviteLink,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Генерация уникального токена
|
||
*/
|
||
protected function generateToken(): string
|
||
{
|
||
do {
|
||
$token = bin2hex(random_bytes(32));
|
||
$exists = $this->orgUserModel->where('invite_token', $token)->first();
|
||
} while ($exists);
|
||
|
||
return $token;
|
||
}
|
||
|
||
/**
|
||
* Создание "теневого" пользователя для новых приглашенных
|
||
*/
|
||
protected function createShadowUser(string $email): int
|
||
{
|
||
$token = bin2hex(random_bytes(32));
|
||
$tokenExpiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
||
|
||
return $this->userModel->insert([
|
||
'email' => $email,
|
||
'name' => '', // Заполнится при регистрации
|
||
'password' => null, // Без пароля до регистрации
|
||
'email_verified' => 0,
|
||
'verification_token' => $token,
|
||
'token_expires_at' => $tokenExpiresAt,
|
||
'created_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Привязка теневого пользователя к реальному
|
||
*/
|
||
protected function bindShadowUser(int $organizationId, int $userId): void
|
||
{
|
||
// Находим теневую запись по email пользователя
|
||
$user = $this->userModel->find($userId);
|
||
|
||
if ($user && empty($user['password'])) {
|
||
// Обновляем все pending приглашения этого пользователя
|
||
$this->orgUserModel
|
||
->where('user_id', null)
|
||
->where('status', OrganizationUserModel::STATUS_PENDING)
|
||
->set(['user_id' => $userId])
|
||
->update();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Отправка email с приглашением
|
||
*/
|
||
protected function sendInvitationEmail(string $email, string $orgName, string $role, string $inviteLink): bool
|
||
{
|
||
$roleLabels = [
|
||
'owner' => 'Владелец',
|
||
'admin' => 'Администратор',
|
||
'manager' => 'Менеджер',
|
||
'guest' => 'Гость',
|
||
];
|
||
|
||
$roleLabel = $roleLabels[$role] ?? $role;
|
||
|
||
$emailService = service('email');
|
||
$emailService->setTo($email);
|
||
$emailService->setSubject('Приглашение в организацию ' . $orgName);
|
||
|
||
$message = <<<HTML
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<style>
|
||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||
.header { background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
||
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
|
||
.role-badge { display: inline-block; background: #EEF2FF; color: #4F46E5; padding: 4px 12px; border-radius: 20px; font-size: 14px; }
|
||
.button { display: inline-block; background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
|
||
.footer { text-align: center; color: #6b7280; font-size: 12px; padding: 20px; }
|
||
.invite-link { word-break: break-all; font-size: 12px; color: #6b7280; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>Приглашение в Бизнес.Точка</h1>
|
||
</div>
|
||
<div class="content">
|
||
<p>Вас приглашают присоединиться к организации <strong>{$orgName}</strong></p>
|
||
<p>Ваша роль: <span class="role-badge">{$roleLabel}</span></p>
|
||
<p>Нажмите кнопку ниже, чтобы принять или отклонить приглашение:</p>
|
||
<p style="text-align: center;">
|
||
<a href="{$inviteLink}" class="button">Принять приглашение</a>
|
||
</p>
|
||
<p>Если кнопка не работает, скопируйте ссылку и откройте в браузере:</p>
|
||
<p class="invite-link">{$inviteLink}</p>
|
||
<p>Ссылка действительна 7 дней.</p>
|
||
</div>
|
||
<div class="footer">
|
||
<p>© Бизнес.Точка</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
HTML;
|
||
|
||
$emailService->setMessage($message);
|
||
return $emailService->send();
|
||
}
|
||
}
|