bp/app/Services/InvitationService.php

390 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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();
}
}